diff --git a/.github/actions/setup-arithmetization-riscv/action.yml b/.github/actions/setup-arithmetization-riscv/action.yml index bf86a805b2a..8f471fad396 100644 --- a/.github/actions/setup-arithmetization-riscv/action.yml +++ b/.github/actions/setup-arithmetization-riscv/action.yml @@ -44,6 +44,27 @@ runs: echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" echo "${HOME}/.local/share/mise/shims" >> "${GITHUB_PATH}" + # blst + mcl are not packaged for Ubuntu; `install-native-crypto-deps` (riscv-guests/Makefile) + # builds them from the pinned tags once and installs into /usr/local — the SAME command a + # developer runs locally, and the same one .github/actions/setup-riscv-guests calls, so there + # is one recipe to keep in sync instead of two. The l2-execution guest's native host tools + # (l2-execution-wrap, l2-execution-runner) link them via the default `zig build` step this job + # also runs, even though the benchmark itself only consumes the freestanding riscv64 ELF. + - name: Cache blst + mcl builds + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + /usr/local/lib/libblst.a + /usr/local/lib/libmcl.so + /usr/local/include/blst.h + /usr/local/include/blst_aux.h + /usr/local/include/mcl + key: crypto-libs-blst-e7f90de551e8df682f3cc99067d204d8b90d27ad-mcl-0499298adcfad3bbcebf77f17700ebbe97166060 + + - name: Install native crypto deps (blst, mcl) + shell: bash + run: make -C riscv-guests install-native-crypto-deps + - name: Install riscv64-unknown-elf toolchain # Install the riscv64-unknown-elf toolchain # Use the xpack-dev-tools/riscv-none-elf-gcc-xpack for riscv64-none-elf-gcc diff --git a/.github/actions/setup-riscv-guests/action.yml b/.github/actions/setup-riscv-guests/action.yml index 50b8c6d4b91..dcef1b76d81 100644 --- a/.github/actions/setup-riscv-guests/action.yml +++ b/.github/actions/setup-riscv-guests/action.yml @@ -20,51 +20,23 @@ runs: version: ${{ steps.zigversion.outputs.version }} dependency-hash-paths: riscv-guests/*/build.zig.zon - - name: Install apt crypto dependencies - shell: bash - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends libsecp256k1-dev libssl-dev build-essential git - - # blst + mcl are not packaged for Ubuntu; build once from pinned tags and cache the artifacts. - # Bump the two SHAs here AND in the cache key together. + # blst + mcl are not packaged for Ubuntu; `install-native-crypto-deps` builds them from the + # pinned tags once and installs into /usr/local — the SAME command a developer runs locally. + # Cache /usr/local's result directly so a hit skips straight to a no-op inside that target. - name: Cache blst + mcl builds - id: crypto-cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: - path: ~/.crypto-libs + path: | + /usr/local/lib/libblst.a + /usr/local/lib/libmcl.so + /usr/local/include/blst.h + /usr/local/include/blst_aux.h + /usr/local/include/mcl key: crypto-libs-blst-e7f90de551e8df682f3cc99067d204d8b90d27ad-mcl-0499298adcfad3bbcebf77f17700ebbe97166060 - - name: Build blst and mcl from source - if: steps.crypto-cache.outputs.cache-hit != 'true' - shell: bash - run: | - BLST_SHA=e7f90de551e8df682f3cc99067d204d8b90d27ad # v0.3.16 - MCL_SHA=0499298adcfad3bbcebf77f17700ebbe97166060 # v3.06 - STAGE="$HOME/.crypto-libs" - mkdir -p "$STAGE/lib" "$STAGE/include" - - git clone https://github.com/supranational/blst.git "$HOME/blst-src" - git -C "$HOME/blst-src" checkout --detach "$BLST_SHA" - (cd "$HOME/blst-src" && ./build.sh) - cp "$HOME/blst-src/libblst.a" "$STAGE/lib/" - cp "$HOME/blst-src/bindings/blst.h" "$HOME/blst-src/bindings/blst_aux.h" "$STAGE/include/" - - git clone https://github.com/herumi/mcl.git "$HOME/mcl-src" - git -C "$HOME/mcl-src" checkout --detach "$MCL_SHA" - make -C "$HOME/mcl-src" -j"$(nproc)" - cp "$HOME/mcl-src/lib/libmcl.so" "$STAGE/lib/" - cp -r "$HOME/mcl-src/include/mcl" "$STAGE/include/" - - - name: Install blst and mcl into /usr/local + - name: Install native crypto deps (blst, mcl) shell: bash - run: | - sudo cp "$HOME/.crypto-libs/lib/libblst.a" /usr/local/lib/ - sudo cp "$HOME/.crypto-libs/lib/libmcl.so" /usr/local/lib/ - sudo rm -f /usr/local/lib/libmcl.a - sudo cp "$HOME/.crypto-libs/include/blst.h" "$HOME/.crypto-libs/include/blst_aux.h" /usr/local/include/ - sudo cp -r "$HOME/.crypto-libs/include/mcl" /usr/local/include/ - sudo ldconfig + run: make -C riscv-guests install-native-crypto-deps # Zig package fetches (zesu, zesu-zkvm, EF zkevm fixtures) land in each guest's zig-pkg/. - name: Cache Zig package fetches diff --git a/.github/workflows/arithmetization-execution-specs-ssz-fixtures.yml b/.github/workflows/arithmetization-execution-specs-ssz-fixtures.yml deleted file mode 100644 index 4f8df2d264c..00000000000 --- a/.github/workflows/arithmetization-execution-specs-ssz-fixtures.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: arithmetization execution-specs SSZ fixtures - -on: - schedule: - - cron: '0 3 * * 1' - workflow_dispatch: - -permissions: - contents: read - actions: read - -jobs: - run-fixtures: - name: l2-execution execution-specs SSZ fixtures - runs-on: gha-lfdt-lineth-ss-ubuntu-24-amd64-large - timeout-minutes: 120 - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: false - - - name: Setup RISC-V guests environment - uses: ./.github/actions/setup-riscv-guests - - - name: Install Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 - with: - go-version: 1.25.7 - cache-dependency-path: "**/*.sum" - - - name: Install zkc - run: make -C arithmetization install-zkc - - - name: Run execution-specs SSZ fixtures - env: - ZKC_REF: "" - run: make -C riscv-guests/l2-execution run-execution-specs-ssz-fixtures diff --git a/.github/workflows/arithmetization-guest-programs-run.yml b/.github/workflows/arithmetization-guest-programs-run.yml index 8c3c569b106..1fa9f5efe33 100644 --- a/.github/workflows/arithmetization-guest-programs-run.yml +++ b/.github/workflows/arithmetization-guest-programs-run.yml @@ -2,14 +2,18 @@ name: Tracer guest programs run - l2-execution, Add in Zig and Blake in Rust on: pull_request: - branches: - - main + # No branches: filter — this must also run on PRs targeting stacked, non-main + # integration branches (e.g. feat/l2-execution-guest-rollup-v2 and its own stack of + # PRs), not just PRs whose base is main. The paths: filter below still scopes it. paths: - 'arithmetization/**' - 'riscv-guests/l2-execution/**' - 'riscv-guests/build_common/**' + - 'riscv-guests/guest-common/**' - 'riscv-guests/lineth-accelerators/**' + - 'riscv-guests/Makefile' - '.github/actions/setup-arithmetization-riscv/**' + - '.github/actions/setup-zig/**' - '.github/workflows/arithmetization-*.yml' push: branches: @@ -18,8 +22,11 @@ on: - 'arithmetization/**' - 'riscv-guests/l2-execution/**' - 'riscv-guests/build_common/**' + - 'riscv-guests/guest-common/**' - 'riscv-guests/lineth-accelerators/**' + - 'riscv-guests/Makefile' - '.github/actions/setup-arithmetization-riscv/**' + - '.github/actions/setup-zig/**' - '.github/workflows/arithmetization-*.yml' workflow_call: inputs: diff --git a/.github/workflows/arithmetization-riscv-act4-test.yml b/.github/workflows/arithmetization-riscv-act4-test.yml index 949191da700..6a069d8abf4 100644 --- a/.github/workflows/arithmetization-riscv-act4-test.yml +++ b/.github/workflows/arithmetization-riscv-act4-test.yml @@ -2,18 +2,25 @@ name: Tracer riscv ACT4 test on: pull_request: - branches: - - main + # No branches: filter — this must also run on PRs targeting stacked, non-main + # integration branches (e.g. feat/l2-execution-guest-rollup-v2 and its own stack of + # PRs), not just PRs whose base is main. The paths: filter below still scopes it. paths: - 'arithmetization/**' + - 'riscv-guests/build_common/**' + - 'riscv-guests/Makefile' - '.github/actions/setup-arithmetization-riscv/**' + - '.github/actions/setup-zig/**' - '.github/workflows/arithmetization-*.yml' push: branches: - main paths: - 'arithmetization/**' + - 'riscv-guests/build_common/**' + - 'riscv-guests/Makefile' - '.github/actions/setup-arithmetization-riscv/**' + - '.github/actions/setup-zig/**' - '.github/workflows/arithmetization-*.yml' workflow_call: inputs: diff --git a/.github/workflows/riscv-guests-host-tests.yml b/.github/workflows/riscv-guests-host-tests.yml index 609437bd1d7..374fa17095e 100644 --- a/.github/workflows/riscv-guests-host-tests.yml +++ b/.github/workflows/riscv-guests-host-tests.yml @@ -1,20 +1,21 @@ # Host-machine CI for the RISC-V guest programs (riscv-guests/): # - host-tests: `make test` — the top-level orchestrator fans the native unit tests out to every # guest in GUESTS (single-fixture smoke test, delegated-accel integration test, …). -# - spec-tests: `make spec-test` in l2-execution — the FULL EF execution-spec-tests zkevm -# stateless suite (~2,880 fixture files / ~16.8k blocks) run through the guest on the host. -# This is deliberately NOT orchestrated: the EF suite only exists for the EVM-execution guest. +# - reference-tests: `make reference-test` in l2-execution — the dummy-wrap-vs-fixture-truth +# reference-test guard (extended_vanilla_runner.zig), over the Amsterdam EF corpus. # Both run the guest logic natively (no RISC-V, no ZKC); running the guest through the ZKC # interpreter is covered separately, by its own dedicated workflow. name: riscv-guests host tests on: pull_request: - branches: - - main + # No branches: filter — this must also run on PRs targeting stacked, non-main + # integration branches (e.g. feat/l2-execution-guest-rollup-v2 and its own stack of + # PRs), not just PRs whose base is main. The paths: filter below still scopes it. paths: - 'riscv-guests/**' - '.github/actions/setup-riscv-guests/**' + - '.github/actions/setup-zig/**' - '.github/workflows/riscv-guests-host-tests.yml' push: branches: @@ -22,6 +23,7 @@ on: paths: - 'riscv-guests/**' - '.github/actions/setup-riscv-guests/**' + - '.github/actions/setup-zig/**' - '.github/workflows/riscv-guests-host-tests.yml' workflow_dispatch: @@ -53,8 +55,8 @@ jobs: working-directory: riscv-guests timeout-minutes: 20 - spec-tests: - name: l2-execution EF spec tests (host) + reference-tests: + name: l2-execution extended-guest reference-test guards (host) runs-on: gha-lfdt-lineth-ss-ubuntu-24-amd64-large steps: - name: Checkout repository @@ -65,8 +67,8 @@ jobs: - name: Setup Environment uses: ./.github/actions/setup-riscv-guests - - name: Run full EF zkevm stateless fixture suite - run: make spec-test + - name: Run extended-guest reference-test guards + run: make reference-test working-directory: riscv-guests/l2-execution timeout-minutes: 30 diff --git a/.github/workflows/riscv-guests-zkc-interpreter-run.yml b/.github/workflows/riscv-guests-zkc-interpreter-run.yml index 0a3f24bcd10..0e5cca1b785 100644 --- a/.github/workflows/riscv-guests-zkc-interpreter-run.yml +++ b/.github/workflows/riscv-guests-zkc-interpreter-run.yml @@ -17,17 +17,18 @@ name: riscv-guests zkc interpreter run on: pull_request: - branches: - - main + # No branches: filter — this must also run on PRs targeting stacked, non-main + # integration branches (e.g. feat/l2-execution-guest-rollup-v2 and its own stack of + # PRs), not just PRs whose base is main. The paths: filter below still scopes it. paths: - 'riscv-guests/**' - 'arithmetization/src/main/riscv/**' # main.zkc — the interpreter (ZKC_MAIN) - 'arithmetization/src/main/lib/**' # zkc stdlib used by the interpreter - - 'arithmetization/src/main/wrappers/**' # linea_zkvm_accel — imported by the guest build (l2-execution/build.zig) - 'arithmetization/src/test/Makefile' # elf-exec / elf-to-json / linker-script - 'arithmetization/src/test/scripts/**' # elf_to_json_gen - 'arithmetization/Makefile' # install-zkc / ZKC_REF - '.github/actions/setup-riscv-guests/**' + - '.github/actions/setup-zig/**' - '.github/workflows/riscv-guests-zkc-interpreter-run.yml' push: branches: @@ -36,11 +37,11 @@ on: - 'riscv-guests/**' - 'arithmetization/src/main/riscv/**' - 'arithmetization/src/main/lib/**' - - 'arithmetization/src/main/wrappers/**' - 'arithmetization/src/test/Makefile' - 'arithmetization/src/test/scripts/**' - 'arithmetization/Makefile' - '.github/actions/setup-riscv-guests/**' + - '.github/actions/setup-zig/**' - '.github/workflows/riscv-guests-zkc-interpreter-run.yml' workflow_dispatch: inputs: diff --git a/.husky/commit-msg b/.husky/commit-msg index 4d4094b9623..84076b8b0bd 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -28,7 +28,7 @@ ALLOWED_SCOPES=\ 'coordinator prover prover-ray verifier-ray postman tx-exclusion-api '\ 'linea-besu contracts sdk-core sdk-ethers sdk-viem '\ 'tracer sequencer state-recovery jvm-libs blob-libs '\ -'e2e ci docker deps misc maru' +'e2e ci docker deps misc maru riscv-guest' # Match overall structure: ()!?: HEADER_RE="^(${ALLOWED_TYPES})\\(([^)]+)\\)!?: .+" diff --git a/riscv-guests/.gitignore b/riscv-guests/.gitignore index f05ab68c6e7..9cf41f6c7d3 100644 --- a/riscv-guests/.gitignore +++ b/riscv-guests/.gitignore @@ -7,3 +7,4 @@ zig-out/ zig-pkg/ .cache/ *.objdump +*/.cache/ diff --git a/riscv-guests/Makefile b/riscv-guests/Makefile index f2cef57016e..889eea71738 100644 --- a/riscv-guests/Makefile +++ b/riscv-guests/Makefile @@ -18,7 +18,7 @@ ZIG ?= zig # Mirrors the CI `zig fmt --check` invocation (riscv-guests-host-tests.yml) and scales with GUESTS. FMT_PATHS := build_common/build.zig $(foreach g,$(GUESTS),$(g)/build.zig $(g)/src $(g)/test) -.PHONY: compile test fetch clean fmt fmt-check help +.PHONY: compile test fetch clean fmt fmt-check help install-native-crypto-deps # Each common target fans out to every guest's own Makefile. compile test fetch clean: @@ -27,6 +27,60 @@ compile test fetch clean: $(MAKE) -C $$g $@ ZIG="$(ZIG)" || exit $$?; \ done +# Native crypto libs (blst, mcl) that zesu's native host backend links — needed to build/run any +# guest's native host tools (unit tests, the wrap/runner CLIs), never for the freestanding riscv64 +# guest ELF itself. Not packaged for Ubuntu, so built from pinned upstream tags; idempotent (skips +# already-installed libs), so this is the SAME command whether run in CI or on a dev machine — +# CI's job is only to cache the result, not to own a separate copy of this recipe. Bump the two SHAs +# below when updating (keep in sync with any CI cache key that also names them). +BLST_SHA := e7f90de551e8df682f3cc99067d204d8b90d27ad +MCL_SHA := 0499298adcfad3bbcebf77f17700ebbe97166060 + +install-native-crypto-deps: + @set -eu; \ + uname_s=$$(uname -s); \ + if [ "$$uname_s" = "Darwin" ]; then \ + command -v brew >/dev/null 2>&1 || { echo "Homebrew not found: https://brew.sh"; exit 1; }; \ + brew install secp256k1 openssl || true; \ + prefix=$$(brew --prefix); \ + sudo=""; \ + else \ + sudo=$$(command -v sudo >/dev/null 2>&1 && echo sudo || echo ""); \ + $$sudo apt-get update -qq; \ + $$sudo apt-get install -y --no-install-recommends libsecp256k1-dev libssl-dev build-essential git; \ + prefix=/usr/local; \ + fi; \ + if [ -f "$$prefix/lib/libblst.a" ]; then \ + echo "blst already installed at $$prefix/lib/libblst.a"; \ + else \ + echo "Building blst from source ($(BLST_SHA))..."; \ + rm -rf /tmp/blst-src; \ + git clone https://github.com/supranational/blst.git /tmp/blst-src; \ + git -C /tmp/blst-src checkout --detach $(BLST_SHA); \ + (cd /tmp/blst-src && ./build.sh); \ + $$sudo mkdir -p "$$prefix/lib" "$$prefix/include"; \ + $$sudo cp /tmp/blst-src/libblst.a "$$prefix/lib/"; \ + $$sudo cp /tmp/blst-src/bindings/blst.h /tmp/blst-src/bindings/blst_aux.h "$$prefix/include/"; \ + fi; \ + if [ -f "$$prefix/lib/libmcl.so" ] || [ -f "$$prefix/lib/libmcl.dylib" ]; then \ + echo "mcl already installed under $$prefix/lib"; \ + else \ + echo "Building mcl from source ($(MCL_SHA))..."; \ + rm -rf /tmp/mcl-src; \ + git clone https://github.com/herumi/mcl.git /tmp/mcl-src; \ + git -C /tmp/mcl-src checkout --detach $(MCL_SHA); \ + $(MAKE) -C /tmp/mcl-src; \ + $$sudo mkdir -p "$$prefix/lib" "$$prefix/include"; \ + if [ "$$uname_s" = "Darwin" ]; then \ + $$sudo cp /tmp/mcl-src/lib/libmcl.dylib "$$prefix/lib/" 2>/dev/null || $$sudo cp /tmp/mcl-src/lib/libmcl.a "$$prefix/lib/"; \ + else \ + $$sudo rm -f "$$prefix/lib/libmcl.a"; \ + $$sudo cp /tmp/mcl-src/lib/libmcl.so "$$prefix/lib/"; \ + $$sudo ldconfig; \ + fi; \ + $$sudo cp -r /tmp/mcl-src/include/mcl "$$prefix/include/"; \ + fi + # Formatting spans build_common + all guests, so it lives here (not fanned out). `fmt` rewrites in # place; `fmt-check` only verifies — the same gate CI runs. fmt: @@ -44,4 +98,5 @@ help: @echo " make clean # remove each guest's zig-out/ and .zig-cache/" @echo " make fmt # format build_common + all guest sources in place (zig fmt)" @echo " make fmt-check # verify formatting without writing (the CI gate)" + @echo " make install-native-crypto-deps # install blst/mcl for native host-tool builds" @echo "" diff --git a/riscv-guests/README.md b/riscv-guests/README.md index 3ae77e76f2e..29973f25a8a 100644 --- a/riscv-guests/README.md +++ b/riscv-guests/README.md @@ -13,7 +13,7 @@ riscv-guests/ l2-execution/ Vanilla EVM execution guest: build.zig + build.zig.zon + Makefile + src/ + test/ ``` -Within a guest, `src/` holds **only the production code that ships in the rv64im object/ELF**; host-only code (unit tests, the spec-test harness, fixture parsing) lives in `test/`, and committed sample/test data in `test/testdata/`. The split mirrors what `build.zig` builds: the object + `elf` step compile `src/`; `zig build test` / `spec-tests` compile `test/`. (Automated tests pull their EF fixtures from the lazy `execution_spec_tests_zkevm` dependency, not from committed data — `test/testdata/` is just the manual ZkC-run samples.) +Within a guest, `src/` holds **only the production code that ships in the rv64im object/ELF**; host-only code (unit tests, the reference-test harness, fixture parsing) lives in `test/`, and committed sample/test data in `test/testdata/`. The split mirrors what `build.zig` builds: the object + `elf` step compile `src/`; `zig build test` / `extended-vanilla` compile `test/`. (Automated tests pull their EF fixtures from the lazy `execution_spec_tests_zkevm` dependency, not from committed data — `test/testdata/` is just the manual ZkC-run samples.) **Add a guest:** create `riscv-guests//` (its own `build.zig`, `build.zig.zon`, `Makefile`, `src/` for production code + `test/` for host tests, depending on `../build_common`) and append `` to `GUESTS` in the top-level `Makefile`. Future guests (Rollup, Aggregation) slot in this way — each with its own dependencies and compile/lint sequence. @@ -63,18 +63,18 @@ make -C l2-execution compile ZIG=/path/to/zig IN_ORIGIN=0x08800000 # override `make -C l2-execution compile` builds the guest as a **statically-linked rv64im ELF** under `/zig-out/bin/` — the [zkvm-standards](https://github.com/eth-act/zkvm-standards/blob/main/standards/riscv-target/target.md) artifact ("Object Format: ELF, statically linked"). `make test` runs the native Zig unit tests (see [Native test dependencies](#native-test-dependencies)). -### Spec tests (l2-execution only — full EF zkevm fixture suite) +### Reference tests (l2-execution only — full EF zkevm fixture suite) -The EF stateless-fixture suite is specific to the EVM-execution guest, so `spec-test` is an **l2-execution target**, not an orchestrated one (a rollup/aggregation guest has no equivalent). `make test` is the fast single-fixture smoke test; the full suite: +l2-execution is the Rollup's extended guest, not a vanilla EVM-execution guest — the EF stateless-fixture suite is used as a reference test: it asserts the dummy-wrapped extended guest (`runL2Execution`) agrees with the fixture's own expected validity verdict on block validity (the reference-test corpus is the source of truth — no second, independently re-run implementation needed), so `reference-test` is an **l2-execution target**, not an orchestrated one (a rollup/aggregation guest has no equivalent). `make test` is the fast single-fixture smoke test; the full suite: ```bash -make -C l2-execution spec-test ZIG=/path/to/zig -make -C l2-execution spec-test ZIG=/path/to/zig SPEC_ARGS="--fork Amsterdam" -make -C l2-execution spec-test ZIG=/path/to/zig SPEC_ARGS="--match bal_self_transfer" -make -C l2-execution spec-test ZIG=/path/to/zig SPEC_ARGS="--report-only" +make -C l2-execution reference-test ZIG=/path/to/zig +make -C l2-execution reference-test ZIG=/path/to/zig REFERENCE_ARGS="--fork Amsterdam" +make -C l2-execution reference-test ZIG=/path/to/zig REFERENCE_ARGS="--match bal_self_transfer" +make -C l2-execution reference-test ZIG=/path/to/zig REFERENCE_ARGS="--report-only" ``` -The runner walks the `blockchain_tests/` tree from the lazy `execution_spec_tests_zkevm` dependency and runs every block through the guest, failing if any output differs from the fixture's expected `statelessOutputBytes`. The corpus walking/reporting is reusable ([`spec_runner.zig`](l2-execution/test/spec_runner.zig)); a future extended-execution guest supplies its own input **adapter** ([`evm_spec_runner.zig`](l2-execution/test/evm_spec_runner.zig) is the vanilla one). +The runner walks the `blockchain_tests/` tree from the lazy `execution_spec_tests_zkevm` dependency and, for every block, wraps it into a dummy-filled extended input and checks the extended guest's validity verdict against the fixture's own expected `successful_validation` result — see [`extended_vanilla_runner.zig`](l2-execution/test/extended_vanilla_runner.zig). The corpus walking/reporting is reusable ([`spec_runner.zig`](l2-execution/test/spec_runner.zig)); `extended_vanilla_runner.zig` supplies the only adapter that plugs into it today. ## Continuous Integration @@ -83,9 +83,9 @@ Two workflows guard the guests. [`riscv-guests-host-tests.yml`](../.github/workflows/riscv-guests-host-tests.yml) runs on every PR touching `riscv-guests/**`, with two parallel host-machine jobs: - **Guest unit tests** — `zig fmt --check` plus the orchestrated `make test` (every guest in `GUESTS`). -- **l2-execution EF spec tests** — the full fixture suite via `make spec-test` (fail-hard; ~2,900 files / ~23k blocks, minutes on a warm cache). +- **l2-execution extended-guest reference-test guards** — the full EF fixture suite via `make reference-test` (fail-hard; ~2,900 files / ~23k blocks, minutes on a warm cache). -[`riscv-guests-zkc-interpreter-run.yml`](../.github/workflows/riscv-guests-zkc-interpreter-run.yml) runs the complementary guest **under zkc**: it builds the l2-execution guest with the prover-accelerated keccak op (`KECCAK_ACCEL=true`) and executes it on the committed sample input via `make -C l2-execution exec ZKC_EXEC_FLAGS="--quiet --gogen --fast"` (the ELF → JSON → `zkc` path described below). Execution uses zkc's **generated-Go backend in fast mode** (`--gogen --fast`) rather than the tree-walking interpreter, because tracing is not implemented yet — a far lighter path (tens of MB, seconds). It triggers on `riscv-guests/**` **and** the interpreter program + tooling it depends on under `arithmetization/` (the `main.zkc` program, the zkc stdlib, the keccak wrapper, and `elf_to_json_gen`), and tracks the `zkc` `main` branch by default (override with the `zkc-ref` workflow input). This is a *runnability* gate — output-correctness over the full corpus is the host spec-test suite's job above. +[`riscv-guests-zkc-interpreter-run.yml`](../.github/workflows/riscv-guests-zkc-interpreter-run.yml) runs the complementary guest **under zkc**: it builds the l2-execution guest with the prover-accelerated keccak op (`KECCAK_ACCEL=true`) and executes it on the committed sample input via `make -C l2-execution exec ZKC_EXEC_FLAGS="--quiet --gogen --fast"` (the ELF → JSON → `zkc` path described below). Execution uses zkc's **generated-Go backend in fast mode** (`--gogen --fast`) rather than the tree-walking interpreter, because tracing is not implemented yet — a far lighter path (tens of MB, seconds). It triggers on `riscv-guests/**` **and** the interpreter program + tooling it depends on under `arithmetization/` (the `main.zkc` program, the zkc stdlib, the keccak wrapper, and `elf_to_json_gen`), and tracks the `zkc` `main` branch by default (override with the `zkc-ref` workflow input). This is a *runnability* gate — output-correctness over the full corpus is the host reference-test suite's job above. The host-tests setup lives in [`.github/actions/setup-riscv-guests`](../.github/actions/setup-riscv-guests/action.yml): it installs the Zig pinned in `.zigversion` (via community mirrors — ziglang.org prunes dev builds), the apt crypto packages, and blst/mcl built from pinned upstream sources into `/usr/local`, with the builds and Zig package fetches cached. The interpreter-run workflow reuses that same action for the guest build (the freestanding ELF links none of the crypto) and adds Go plus a `zkc` install. @@ -104,5 +104,5 @@ These need `zkc` and `go` on `PATH`. The interpreter loads a finished ELF — `e Each guest folder is a complete package: its own dependencies (`build.zig.zon`), compile/test logic (`build.zig`), lifecycle (`Makefile`), production source (`src/`) and host-only test code (`test/`). Shared build helpers are factored into `build_common/`; the toolchain pin (`.zigversion`) is shared at this level. -- `l2-execution/`: vanilla EVM execution guest. See `l2-execution/README.md`. +- `l2-execution/`: the Rollup's extended l2-execution guest. See `l2-execution/README.md`. ``` diff --git a/riscv-guests/l2-execution/Makefile b/riscv-guests/l2-execution/Makefile index 60af6fefed3..a475c063b00 100644 --- a/riscv-guests/l2-execution/Makefile +++ b/riscv-guests/l2-execution/Makefile @@ -1,15 +1,16 @@ # l2-execution guest — self-contained lifecycle. # # This folder is its own Zig package (build.zig + build.zig.zon). The riscv-guests/ top-level -# Makefile fans `compile`/`test`/`spec-test`/`fetch`/`clean` out to this file for every guest; -# guest-specific targets (the ZKC fixture-exec/fixture-debug below) live here, not in the orchestrator. +# Makefile fans `compile`/`test`/`fetch`/`clean` out to this file for every guest; guest-specific +# targets (reference-test, the ZKC fixture-exec/fixture-debug below) live here, not in the +# orchestrator. # # Scope split: building + host-testing is proving-system-AGNOSTIC and lives here (`compile` links the # statically-linked rv64im ELF — the zkvm-standards artifact — via build_common). Only the ZKC run # itself (ELF->JSON via elf_to_json_gen, then zkc) is owned by # arithmetization/src/test/Makefile; fixture-exec/fixture-debug build that ELF and hand it over. -.PHONY: fetch test spec-test prep-execution-specs-json-fixtures run-execution-specs-ssz-fixtures clean compile exec debug require-input +.PHONY: fetch test reference-test prep-execution-specs-json-fixtures clean compile exec debug require-input MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) @@ -17,7 +18,6 @@ MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) ARITHMETIZATION_TESTING_MAKEFILE ?= $(MAKEFILE_DIR)../../arithmetization/src/test/Makefile ZIG ?= zig -GO ?= go BUILD_CACHE_DIR ?= $(MAKEFILE_DIR).zig-cache ZIG_BUILD_FLAGS ?= --cache-dir $(BUILD_CACHE_DIR) --summary all @@ -33,19 +33,16 @@ BIN := $(MAKEFILE_DIR)zig-out/bin/$(NAME) # Sample fixture input used by exec/debug INPUT ?= $(MAKEFILE_DIR)test/testdata/stateless_input.ssz -# Flags forwarded to `zkc exec` by exec. Default -q (quiet: suppress the guest's per-instruction -# printf). Override to pick the execution backend — e.g. "--quiet --gogen --fast" runs the guest via -# zkc's generated-Go backend in fast mode, the runnability path CI uses to stay cheap. Omitting -# --fast traces; "--check" adds constraint checking. Both are measured weekly by -# arithmetization-weekly-zkc-metrics.yml. -ZKC_EXEC_FLAGS ?=-q +# Flags forwarded to `zkc exec` by exec. Quiet (no per-instruction printf) is zkc's default unless +# -v/-vv/-vvv raise verbosity. Override to pick the execution backend — e.g. "--gogen --fast" runs +# the guest via zkc's generated-Go backend in fast mode, the runnability path used while the +# interpreter does not implement tracing yet. "--check" runs the guest via the interpreter with +# tracing enabled. +ZKC_EXEC_FLAGS ?= -# Parameters for execution-spec zkevm fixtures and selected temporary SSZ inputs. +# Parameters for execution-spec zkevm fixtures. EXECUTION_SPECS_JSON_FIXTURES_DIR := /tmp/execution-specs-json-fixtures -EXECUTION_SPECS_SSZ_FIXTURES_DIR := /tmp/execution-specs-ssz-fixtures EXECUTION_SPECS_FIXTURES_DIR := $(EXECUTION_SPECS_JSON_FIXTURES_DIR)/fixtures -EXECUTION_SPECS_RUN_FIXTURE_PATHS ?= blockchain_tests/for_amsterdam/amsterdam,blockchain_tests/for_amsterdam/osaka -EXECUTION_SPECS_RUN_SSZ_LIMIT ?= 100 # If true, generates an objdump of the generated ELF for debugging purposes OBJDUMP ?= false @@ -61,21 +58,20 @@ fetch: test: $(ZIG) build test $(ZIG_BUILD_FLAGS) $(ZIG_FETCH_FLAGS) -# Run the guest against the full EF zkevm stateless fixture set on the host. Fixtures come from the -# lazy execution_spec_tests_zkevm dependency. Narrow/triage with SPEC_ARGS, e.g. -# `make spec-test SPEC_ARGS="--fork Amsterdam -x"` or `SPEC_ARGS="--report-only"`. -SPEC_ARGS ?= -spec-test: - $(ZIG) build spec-tests $(ZIG_BUILD_FLAGS) $(ZIG_FETCH_FLAGS) $(if $(strip $(SPEC_ARGS)),-- $(SPEC_ARGS)) +# Permanent reference-test guard for the extended guest, run against the EF zkevm corpus on the +# host (see extended_vanilla_runner.zig): asserts the dummy-wrapped extended guest (runL2Execution, +# delegating per-block execution to execution.executeStatelessInputWithLogs) agrees with the EF +# fixture's own expected validity verdict, cheaply on the host instead of compiling to riscv64 and +# executing the real guest ELF via ZkC. Scoped to --fork Amsterdam to keep runtime bounded; narrow +# further with REFERENCE_ARGS. +REFERENCE_ARGS ?= --fork Amsterdam +reference-test: + $(ZIG) build extended-vanilla $(ZIG_BUILD_FLAGS) $(ZIG_FETCH_FLAGS) -- $(REFERENCE_ARGS) # Re-uses logic from build.zig to download execution-spec JSON fixtures under $(EXECUTION_SPECS_FIXTURES_DIR). prep-execution-specs-json-fixtures: $(ZIG) build prep-execution-specs-json-fixtures $(ZIG_BUILD_FLAGS) $(ZIG_FETCH_FLAGS) -Dexecution-specs-fixtures-link="$(EXECUTION_SPECS_FIXTURES_DIR)" -# Runs selected execution-spec fixtures through zkc; generated SSZ inputs can be inspected in $(EXECUTION_SPECS_SSZ_FIXTURES_DIR). -run-execution-specs-ssz-fixtures: prep-execution-specs-json-fixtures - GOCACHE=/tmp/go-build $(GO) run $(MAKEFILE_DIR)scripts/run_execution_specs_ssz_fixtures.go --fixtures-dir "$(EXECUTION_SPECS_FIXTURES_DIR)" --ssz-dir "$(EXECUTION_SPECS_SSZ_FIXTURES_DIR)" --fixture-paths "$(EXECUTION_SPECS_RUN_FIXTURE_PATHS)" --ssz-limit "$(EXECUTION_SPECS_RUN_SSZ_LIMIT)" - clean: rm -rf $(MAKEFILE_DIR)zig-out/ rm -rf $(BUILD_CACHE_DIR) diff --git a/riscv-guests/l2-execution/README.md b/riscv-guests/l2-execution/README.md index 19352f94d90..e7a37a89d8e 100644 --- a/riscv-guests/l2-execution/README.md +++ b/riscv-guests/l2-execution/README.md @@ -1,12 +1,12 @@ # L2 Execution Guest -This package contains the RISC-V guest program for vanilla EVM execution. The guest is a thin wrapper over Zesu's stateless executor: it decodes an SSZ-encoded `StatelessInput`, executes the block, and serializes the SSZ validation result — the same pipeline as Zesu's `runner.runStateless` / `zkevm-blockchain-test-runner`. Rollup-specific validation is intentionally out of scope for this iteration. +This package contains the RISC-V guest program for the Rollup's extended l2-execution proof: the Linea-layer logic (`l2_execution.zig`) on top of per-block stateless execution — conflation of a contiguous block range, forced transactions, the L1<->L2 message bridge, and the 16-field public-input tuple. Per-block execution itself is delegated to a log-preserving seam (`execution.zig`) over Zesu's stateless executor, which decodes an SSZ-encoded `StatelessInput` and executes the block. ## Scope -- Decodes an SSZ `SszStatelessInput` (execution payload + execution witness + chain config) with Zesu's `ssz_decode`, executes it with Zesu's stateless executor, and serializes the 105-byte `SszStatelessValidationResult` with `ssz_output`. -- The native Zig test replays a real execution-spec-tests `tests-zkevm` fixture — pulled in as a lazy `build.zig.zon` dependency, not checked in — and asserts the serialized result matches the fixture's expected output. -- Does not include blob compression, recursive proof aggregation, or Rollup-specific public-input validation. +- Decodes the extended `L2ExecutionProofPrivateInput` SSZ envelope (a contiguous run of payloads, each carrying an opaque vanilla `SszStatelessInput` plus forced-transaction witnesses), runs `l2_execution.runL2Execution`, and emits the SSZ output — `keccak256` of the public-input tuple plus the revealed hash preimages the rollup guest needs. +- The native Zig tests replay a real execution-spec-tests `tests-zkevm` fixture and hand-built fixtures against Python-oracle-computed expected values (see `Readme.md` §6.3/§6.5/§2.1); `zig build extended-vanilla` reference-tests the whole EF zkevm corpus by wrapping each block into a dummy-filled extended input and checking the extended guest's validity verdict against the fixture's own expected result — the reference-test corpus is the source of truth, not a second re-run implementation. +- Does not include blob compression or recursive proof aggregation — those are the rollup/rollup-aggregation guests' concern. - Keeps cryptographic precompile/signature acceleration behind Zesu's `accel_impl` boundary. The freestanding guest leaves the `zkvm_*` accelerator symbols **unresolved** for the proving system to supply/intercept — there is no in-guest software provider. The native host test instead links Zesu's `default.zig` backend against system crypto libraries (see [Native test dependencies](../README.md#native-test-dependencies)). ## Development diff --git a/riscv-guests/l2-execution/build.zig b/riscv-guests/l2-execution/build.zig index 00a80b3b150..a0958acf0a7 100644 --- a/riscv-guests/l2-execution/build.zig +++ b/riscv-guests/l2-execution/build.zig @@ -37,23 +37,46 @@ pub fn build(b: *std.Build) void { // rather than performing a final link. The shared entry stub + memory layout + compiler_rt/GC // plumbing live in build_common.installGuestElf; here we wire the guest's root module: // • zesu executor + SSZ modules — the execution logic; - // • zesu_zkvm_accel — zesu-zkvm's stdlibs_accel: in-guest software precompiles that + // • zesu_zkvm_stdlibs — zesu-zkvm's stdlibs_accel: in-guest software precompiles that // zkvm_provide.zig exports as the zkvm_* symbols zesu references; - // • lineth_zkvm_accel — Lineth accelerator wrappers (keccak today): zkvm_* the prover accelerates - // at execution rather than at link time, so the ELF stays fully resolved; + // • zesu_crypto_backend — zesu's own native crypto backend (the handful of its precompiles + // with no C-library dependency: modexp, RIPEMD-160), standing in for the two of those + // zesu_zkvm_stdlibs leaves as unconditional-failure stubs; + // • lineth_zkvm_accel — Lineth accelerator wrappers (keccak today): the only actually + // prover-accelerated (custom opcode / circuit) source in this file — zkvm_* the prover + // accelerates at execution rather than at link time, so the ELF stays fully resolved; // • linea_zkvm_io — zesu-zkvm's zkvm_io: satisfies the standards `read_input` by reading the // memory-mapped `_in_start` (the input slot is the proving system's detail, kept out of the // guest; `_in_start` is supplied by the linker script). const zesu_guest = b.dependency("zesu", .{ .target = target, .optimize = optimize }); const zesu_zkvm = b.dependency("zesu_zkvm", .{}); - const zesu_accel_src = zesu_zkvm.path("linea/src/runtime/stdlibs_accel.zig"); // also imported by the native accel test below - const zesu_accel_mod = b.createModule(.{ - .root_source_file = zesu_accel_src, + const zesu_zkvm_stdlibs_src = zesu_zkvm.path("linea/src/runtime/stdlibs_accel.zig"); // also imported by the native stdlibs test below + const zesu_zkvm_stdlibs_mod = b.createModule(.{ + .root_source_file = zesu_zkvm_stdlibs_src, .target = target, .optimize = optimize, }); const lineth_accel_mod = b.dependency("lineth_accelerators", .{ .target = target, .optimize = optimize }).module("lineth_accelerators"); + const modexp_impl_mod = b.createModule(.{ + .root_source_file = zesu_guest.path("src/crypto/backends/modexp_impl.zig"), + .target = target, + .optimize = optimize, + }); + modexp_impl_mod.addImport("zesu_allocator", zesu_guest.module("zesu_allocator")); + const ripemd160_impl_mod = b.createModule(.{ + .root_source_file = zesu_guest.path("src/crypto/backends/ripemd160_impl.zig"), + .target = target, + .optimize = optimize, + }); + const zesu_crypto_backend_mod = b.createModule(.{ + .root_source_file = b.path("src/zesu_crypto_backend.zig"), + .target = target, + .optimize = optimize, + }); + zesu_crypto_backend_mod.addImport("zesu_modexp_impl", modexp_impl_mod); + zesu_crypto_backend_mod.addImport("zesu_ripemd160_impl", ripemd160_impl_mod); + // Expose the precompile providers (zkvm_provide.zig) as a standalone module so other packages // can link the SAME exported zkvm_* symbols this guest uses const provide_mod = b.addModule("zkvm_provide", .{ @@ -61,8 +84,9 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); - provide_mod.addImport("zesu_zkvm_accel", zesu_accel_mod); + provide_mod.addImport("zesu_zkvm_stdlibs", zesu_zkvm_stdlibs_mod); provide_mod.addImport("lineth_zkvm_accel", lineth_accel_mod); + provide_mod.addImport("zesu_crypto_backend", zesu_crypto_backend_mod); provide_mod.addOptions("build_options", guest_options); const linea_io_mod = b.createModule(.{ @@ -73,6 +97,18 @@ pub fn build(b: *std.Build) void { // provide_mod's default (non-accelerated) write_output forwards to zesu's zkvm_io. provide_mod.addImport("linea_zkvm_io", linea_io_mod); + // The extended wire format's SSZ codec, built for the SAME riscv64/optimize pair as the guest + // itself (mirrors the native l2_execution_ssz_mod below, for the test host). `l2_execution.zig` + // is pulled into `evm_execution_guest.zig` via a plain relative import, not a separate module: + // a separate module would double-claim `execution.zig`, which both files import. The native + // `guest_mod` used by the native test never needs this wiring — Zig's lazy analysis skips it + // since `guestMain` (the only caller) isn't `@export`-ed for that target. + const l2_execution_ssz_guest_mod = b.createModule(.{ + .root_source_file = b.path("src/l2_execution_ssz.zig"), + .target = target, + .optimize = optimize, + }); + const guest_module = b.createModule(.{ .root_source_file = b.path(source), .target = target, @@ -80,20 +116,24 @@ pub fn build(b: *std.Build) void { }); guest_module.code_model = .medium; addExecutionImports(guest_module, zesuImports(zesu_guest)); - guest_module.addImport("zesu_zkvm_accel", zesu_accel_mod); + guest_module.addImport("zesu_zkvm_stdlibs", zesu_zkvm_stdlibs_mod); guest_module.addImport("lineth_zkvm_accel", lineth_accel_mod); + guest_module.addImport("zesu_crypto_backend", zesu_crypto_backend_mod); guest_module.addImport("linea_zkvm_io", linea_io_mod); + guest_module.addImport("l2_execution_ssz", l2_execution_ssz_guest_mod); guest_module.addOptions("build_options", guest_options); // keccak_accel flag, read in zkvm_provide.zig common.clearFreestandingNativeLinkage(b, guest_module); common.installGuestElf(b, guest_module, gp_name); // ── Native test ─────────────────────────────────────────────────────────── - // Runs the thin wrapper (vanilla zesu stateless execution) on the host against a real - // execution-spec-tests zkevm SSZ fixture, asserting the serialized validation result matches — - // the same end-to-end check as zesu's zkevm-blockchain-test-runner. Links zesu's full native - // crypto backend; linea adds the library search path so it links on macOS. The - // committed fixture is an empty block (only keccak), but the full backend is linked so the suite - // can grow to tx-bearing fixtures (ecrecover/curves) without further build changes. + // Runs `execution.executeStatelessInputWithLogs` (the log-preserving seam `l2_execution.zig` + // delegates per-block execution to) against a real execution-spec-tests zkevm SSZ fixture on + // the host, asserting it computes the SAME pre/post/receipts roots as zesu's own vanilla + // `executor.executeStatelessInput` — i.e. adding the log-preserving path doesn't change + // validation outcomes. Links zesu's full native crypto backend; linea adds the library search + // path so it links on macOS. The committed fixture is an empty block (only keccak), but the + // full backend is linked so the suite can grow to tx-bearing fixtures (ecrecover/curves) + // without further build changes. // // Host artifacts never build at ReleaseSmall: zig 0.16 (stable and dev.3153) -Oz miscompiles // zesu's value-semantics hot paths on aarch64 hosts — stack slots of by-value hash-map captures @@ -117,25 +157,60 @@ pub fn build(b: *std.Build) void { addExecutionImports(guest_mod, native_imports); const test_step = b.step("test", "Run native Zig unit tests for the EVM execution guest"); - const spec_step = b.step("spec-tests", "Run the guest against all EF zkevm stateless fixtures (host)"); + const extended_vanilla_step = b.step("extended-vanilla", "Reference-test guard: assert the dummy-wrapped extended guest (runL2Execution) agrees with the EF fixture's own expected validity over EF zkevm fixtures"); const prep_fixtures_step = b.step("prep-execution-specs-json-fixtures", "Expose EF zkevm stateless fixtures for external runners"); // Integration smoke test for the delegated precompiles: verifies zesu-zkvm's stdlibs_accel // imports and that its ecrecover round-trips (the in-guest precompiles delegate to it). std + // the dependency only — no fixtures, no native crypto libs. - const accel_tests = b.addTest(.{ + const stdlibs_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("test/stdlibs_accel_test.zig"), .target = native_target, .optimize = host_optimize, }), }); - accel_tests.root_module.addImport("zesu_zkvm_accel", b.createModule(.{ - .root_source_file = zesu_accel_src, + stdlibs_tests.root_module.addImport("zesu_zkvm_stdlibs", b.createModule(.{ + .root_source_file = zesu_zkvm_stdlibs_src, .target = native_target, .optimize = host_optimize, })); - test_step.dependOn(&b.addRunArtifact(accel_tests).step); + test_step.dependOn(&b.addRunArtifact(stdlibs_tests).step); + + const l2_execution_ssz_mod = b.createModule(.{ + .root_source_file = b.path("src/l2_execution_ssz.zig"), + .target = native_target, + .optimize = host_optimize, + }); + + // ── l2-execution guest logic (src/l2_execution.zig), native build ─────────────────────────────── + // Built for the native target so `extended-vanilla` below can link the SAME Linea-layer logic the + // riscv64 guest ELF runs (reached there via `evm_execution_guest.zig`'s relative import inside + // `guestMain` — see the module-wiring comment near `l2_execution_ssz_guest_mod` above). Needs the + // full zesu import set (MPT, executor types/tx-decode, primitives, accelerators for ecrecover) + // plus the sibling `l2_execution_ssz` module. + const l2_execution_mod = b.createModule(.{ + .root_source_file = b.path("src/l2_execution.zig"), + .target = native_target, + .optimize = host_optimize, + }); + addExecutionImports(l2_execution_mod, native_imports); + l2_execution_mod.addImport("l2_execution_ssz", l2_execution_ssz_mod); + + // ── Vanilla-input dummy-fill wrap (test/vanilla_wrap.zig) ─────────────────────────────────────── + // Wraps a vanilla EF `SszStatelessInput` into an extended `L2ExecutionProofPrivateInput` with + // dummy rollup fields, so the extended guest can run against the same EF corpus the vanilla guest + // runs on. Needs `zesu_ssz_decode` (to read the vanilla input's chain_id/fee_recipient) and the + // sibling `l2_execution_ssz` module (to build + encode the wrapper). Lives in `test/`, not `src/`: + // it's never reachable from the guest ELF's compile graph, only from `extended-vanilla-runner` + // below. + const vanilla_wrap_mod = b.createModule(.{ + .root_source_file = b.path("test/vanilla_wrap.zig"), + .target = native_target, + .optimize = host_optimize, + }); + vanilla_wrap_mod.addImport("zesu_ssz_decode", native_imports.ssz_decode); + vanilla_wrap_mod.addImport("l2_execution_ssz", l2_execution_ssz_mod); // The SSZ fixture comes from the execution-spec-tests zkevm dependency (lazy: only fetched when // this test is built). An empty-block vector → no transactions → no secp256k1/curve precompiles, @@ -161,32 +236,54 @@ pub fn build(b: *std.Build) void { }); tests.root_module.addImport("evm_execution_guest", guest_mod); tests.root_module.addImport("evm_execution_fixtures", fixtures_mod); + // Direct zesu imports so the test can decode the SSZ fixture and run zesu's vanilla + // executeStatelessInput itself, to assert executeStatelessInputWithLogs (src/execution.zig) + // computes the SAME roots on the same input. + tests.root_module.addImport("zesu_executor", native_imports.executor); + tests.root_module.addImport("zesu_ssz_decode", native_imports.ssz_decode); + tests.root_module.addImport("zesu_allocator", native_imports.allocator); + tests.root_module.addImport("zesu_mpt", native_imports.mpt); linkNativeZesuCrypto(tests, native_target, native_crypto); test_step.dependOn(&b.addRunArtifact(tests).step); - // ── Spec-test runner ──────────────────────────────────────────────────── - // Standalone host executable that walks the WHOLE zkevm fixture tree and runs every block - // through this guest (mirrors zesu's zkevm-blockchain-test-runner). Fixtures come from the - // same lazy dependency — no curl, no embedding; `zig build spec-tests` passes the - // blockchain_tests/ directory as --fixtures. Pass-through extra args after `--`, e.g. - // `zig build spec-tests -- --fork Amsterdam -x`. - const spec_runner_exe = b.addExecutable(.{ - .name = "evm-execution-spec-runner", + // ── extended-vs-fixture validity reference-test guard (permanent) ── + // The single reference-test runner for the extended guest: wraps the vanilla EF input into a + // dummy-filled extended input (vanilla_wrap.wrapVanillaAsExtended, single payload, empty + // FTX, zero l2_message_service_address) and asserts l2_execution.runL2Execution — the + // extended guest's actual Linea-layer logic, delegating per-block execution to + // `execution.executeStatelessInputWithLogs` — agrees with the EF fixture's OWN expected + // validity verdict (not a second, independently re-run implementation — see + // extended_vanilla_runner.zig's header comment for why). + // Once wrapped, every other Linea-layer check (conflation invariants, FTX dispatch, bridge + // reads, message extraction) is either trivially satisfied or suppressed, so this single + // comparison already exercises the delegated execution seam (including the hand-copied + // header-chain preamble and EIP-7928 BAL path) end-to-end — a separate seam-only runner + // would be redundant with it. Pass-through extra args after `--`, e.g. + // `zig build extended-vanilla -- --fork Amsterdam --limit 300`. + const extended_vanilla_runner_exe = b.addExecutable(.{ + .name = "extended-vanilla-runner", .root_module = b.createModule(.{ - .root_source_file = b.path("test/evm_spec_runner.zig"), + .root_source_file = b.path("test/extended_vanilla_runner.zig"), .target = native_target, .optimize = host_optimize, }), }); - spec_runner_exe.root_module.addImport("evm_execution_guest", guest_mod); - linkNativeZesuCrypto(spec_runner_exe, native_target, native_crypto); - - const run_spec = b.addRunArtifact(spec_runner_exe); - run_spec.addArg("--fixtures"); - run_spec.addDirectoryArg(fixtures_dep.path("blockchain_tests")); - if (b.args) |extra| run_spec.addArgs(extra); - spec_step.dependOn(&run_spec.step); + // NOT `evm_execution_guest` (guest_mod): that module relative-imports `l2_execution.zig` + // inside `guestMain`, so combining it with `l2_execution_mod` as a second, separately-rooted + // module in the same compile unit is a Zig module-graph conflict ("file exists in modules + // 'l2_execution' and 'evm_execution_guest'") — the same constraint documented above for + // `l2_execution_ssz_guest_mod`. + extended_vanilla_runner_exe.root_module.addImport("l2_execution", l2_execution_mod); + extended_vanilla_runner_exe.root_module.addImport("l2_execution_ssz", l2_execution_ssz_mod); + extended_vanilla_runner_exe.root_module.addImport("vanilla_wrap", vanilla_wrap_mod); + linkNativeZesuCrypto(extended_vanilla_runner_exe, native_target, native_crypto); + + const run_extended_vanilla = b.addRunArtifact(extended_vanilla_runner_exe); + run_extended_vanilla.addArg("--fixtures"); + run_extended_vanilla.addDirectoryArg(fixtures_dep.path("blockchain_tests")); + if (b.args) |extra| run_extended_vanilla.addArgs(extra); + extended_vanilla_step.dependOn(&run_extended_vanilla.step); const fixtures_parent = std.fs.path.dirname(execution_specs_fixtures_link) orelse "."; const mkdir_fixtures_parent = b.addSystemCommand(&.{ "mkdir", "-p", fixtures_parent }); @@ -210,6 +307,22 @@ const ZesuImports = struct { executor: *std.Build.Module, ssz_decode: *std.Build.Module, ssz_output: *std.Build.Module, + // Log-preserving seam (src/execution.zig) additions: everything executor/main.zig's + // executeStatelessInput/executeBlockStateless preamble touches that isn't already reachable + // through the `executor` module's public re-exports. `primitives` isn't exposed by name in + // zesu's build.zig comments but IS added via the same expose=true addModule() call as the rest + // of this list — needed for the SpecId/isEnabledIn/KECCAK_EMPTY the copied BAL validation uses. + primitives: *std.Build.Module, + mpt: *std.Build.Module, + db: *std.Build.Module, + context: *std.Build.Module, + input: *std.Build.Module, + hardfork: *std.Build.Module, + rlp_decode: *std.Build.Module, + // The crypto-accelerator interface tx_signing.zig uses for ecrecover/keccak256. Not re-exported + // by the `executor` module (tx_signing.zig is one of its private submodules), so + // l2_execution.zig's own sender-recovery port imports it directly. + accelerators: *std.Build.Module, }; /// Pull zesu's exposed modules by name. Which crypto backend zesu uses is selected inside zesu by @@ -220,6 +333,14 @@ fn zesuImports(zesu: *std.Build.Dependency) ZesuImports { .executor = zesu.module("executor"), .ssz_decode = zesu.module("ssz_decode"), .ssz_output = zesu.module("ssz_output"), + .primitives = zesu.module("primitives"), + .mpt = zesu.module("mpt"), + .db = zesu.module("db"), + .context = zesu.module("context"), + .input = zesu.module("input"), + .hardfork = zesu.module("hardfork"), + .rlp_decode = zesu.module("rlp_decode"), + .accelerators = zesu.module("accelerators"), }; } @@ -228,6 +349,14 @@ fn addExecutionImports(module: *std.Build.Module, imports: ZesuImports) void { module.addImport("zesu_executor", imports.executor); module.addImport("zesu_ssz_decode", imports.ssz_decode); module.addImport("zesu_ssz_output", imports.ssz_output); + module.addImport("zesu_primitives", imports.primitives); + module.addImport("zesu_mpt", imports.mpt); + module.addImport("zesu_db", imports.db); + module.addImport("zesu_context", imports.context); + module.addImport("zesu_input", imports.input); + module.addImport("zesu_hardfork", imports.hardfork); + module.addImport("zesu_rlp_decode", imports.rlp_decode); + module.addImport("zesu_accelerators", imports.accelerators); } const NativeCrypto = struct { diff --git a/riscv-guests/l2-execution/build.zig.zon b/riscv-guests/l2-execution/build.zig.zon index 42325a8d7c2..cf026838b50 100644 --- a/riscv-guests/l2-execution/build.zig.zon +++ b/riscv-guests/l2-execution/build.zig.zon @@ -4,18 +4,13 @@ .fingerprint = 0x16d3d5ae145fe30c, .minimum_zig_version = "0.16.0", .dependencies = .{ - // Upstream Consensys/zesu, pinned by commit. Includes the PR #44 work (exposed module - // graph + accel_impl include-path fix + -Dcrypto-prefix). .zesu = .{ - .url = "https://github.com/Consensys/zesu/archive/61f9a5e250977a40a39a80d9bfe9748157a9141d.tar.gz", - .hash = "zesu-0.1.0-UtqpAMqiQwCgJzGwwwfTX-az25jDgG8Yjo7N6Uq4oh9-", + .url = "https://github.com/Consensys/zesu/archive/fc9a8e289811b0d37893df05d21931369adb9fde.tar.gz", + .hash = "zesu-0.1.0-UtqpAALaQwB7MkgNQS7de6P4OSrYYw1lJ4ScupPcohgM", }, - // execution-spec-tests zkevm stateless fixtures (tests-zkevm@v0.4.1) — the same SSZ - // SszStatelessInput vectors zesu validates against. Lazy: only fetched when the native test - // (which reads a fixture from it) is built. .execution_spec_tests_zkevm = .{ - .url = "https://github.com/ethereum/execution-specs/releases/download/tests-zkevm%40v0.4.1/fixtures_zkevm.tar.gz", - .hash = "N-V-__8AAOKjNuwrs7f2UozWmLSRumAsqbHVgWvai3e75cbJ", + .url = "https://github.com/ethereum/execution-specs/releases/download/tests-zkevm%40v0.6.2/fixtures_zkevm.tar.gz", + .hash = "N-V-__8AAP____94w9jBcI9yat7v2Mt9Lb-iJ5TOtfLo6-Ab", .lazy = true, }, // Consensys/zesu-zkvm — the Linea zkVM runtime. We consume only its pure-Zig precompile diff --git a/riscv-guests/l2-execution/scripts/run_execution_specs_ssz_fixtures.go b/riscv-guests/l2-execution/scripts/run_execution_specs_ssz_fixtures.go deleted file mode 100644 index 9b23795d777..00000000000 --- a/riscv-guests/l2-execution/scripts/run_execution_specs_ssz_fixtures.go +++ /dev/null @@ -1,386 +0,0 @@ -package main - -// Examples, from the repository root: -// Run up to 100 SSZ inputs from each selected fixture path: -// make -C riscv-guests/l2-execution run-execution-specs-ssz-fixtures -// Run all inputs in each selected fixture path: -// make -C riscv-guests/l2-execution run-execution-specs-ssz-fixtures EXECUTION_SPECS_RUN_SSZ_LIMIT=0 - -import ( - "encoding/hex" - "encoding/json" - "flag" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "time" -) - -const ( - fixturePathColumnWidth = 40 - testColumnWidth = 108 -) - -type fixtureCase struct { - Blocks []fixtureBlock `json:"blocks"` -} - -type fixtureBlock struct { - StatelessInputBytes string `json:"statelessInputBytes"` - StatelessOutputBytes string `json:"statelessOutputBytes"` -} - -type statelessInput struct { - testName string - blockIndex int - input []byte - expectedValid bool -} - -type selectedInput struct { - fixturePath string - jsonFile string - testName string - blockIndex int - file string - size int - expectedValid bool -} - -// Runs selected fixtures. -func main() { - fixturesDir := flag.String("fixtures-dir", filepath.Join(os.TempDir(), "execution-specs-json-fixtures", "fixtures"), "directory containing execution-specs fixtures") - sszDir := flag.String("ssz-dir", filepath.Join(os.TempDir(), "execution-specs-ssz-fixtures"), "directory for selected temporary SSZ inputs") - fixturePathsFlag := flag.String("fixture-paths", "blockchain_tests/for_amsterdam/amsterdam,blockchain_tests/for_amsterdam/osaka", "comma-separated fixture paths under fixtures-dir") - sszLimit := flag.Int("ssz-limit", 0, "maximum SSZ inputs to run per fixture path; 0 means all") - zkcFlags := flag.String("zkc-flags", "--gogen --fast -q", "flags forwarded to zkc exec") - flag.Parse() - - if *sszLimit < 0 { - must(fmt.Errorf("ssz-limit must be non-negative")) - } - - root, err := repoRoot() - must(err) - - guestDir := filepath.Join(root, "riscv-guests", "l2-execution") - fixturePaths := splitList(*fixturePathsFlag) - if len(fixturePaths) == 0 { - must(fmt.Errorf("fixture-paths must not be empty")) - } - - must(run(os.Stderr, "make", "-C", guestDir, "compile")) - - var inputs []selectedInput - hadError := false - for _, fixturePath := range fixturePaths { - fixturePath, targetDir, err := resolveFixturePath(*fixturesDir, fixturePath) - if err != nil { - fmt.Fprintf(os.Stderr, "skip %s: %v\n", fixturePath, err) - hadError = true - continue - } - - jsonPaths, err := jsonFiles(targetDir) - if err != nil { - fmt.Fprintf(os.Stderr, "list JSON fixtures %s: %v\n", targetDir, err) - hadError = true - continue - } - if len(jsonPaths) == 0 { - fmt.Fprintf(os.Stderr, "no JSON fixtures found in %s\n", targetDir) - hadError = true - continue - } - - selected := 0 - for _, jsonPath := range jsonPaths { - remaining := 0 - if *sszLimit > 0 { - remaining = *sszLimit - selected - if remaining <= 0 { - break - } - } - - newInputs, err := writeSSZInputs(*sszDir, fixturePath, targetDir, jsonPath, remaining) - if err != nil { - fmt.Fprintf(os.Stderr, "prepare %s: %v\n", jsonPath, err) - hadError = true - continue - } - inputs = append(inputs, newInputs...) - selected += len(newInputs) - if *sszLimit > 0 && selected >= *sszLimit { - break - } - } - } - - printTableHeader() - - passed := 0 - for _, input := range inputs { - success, userTime := runGuest(guestDir, input.file, *zkcFlags) - ok := success == input.expectedValid - if ok { - passed++ - } else { - hadError = true - } - testName := fmt.Sprintf("%s:%s[%d]", filepath.ToSlash(input.jsonFile), input.testName, input.blockIndex) - printTableRow(input.fixturePath, testName, input.size, userTime, ok) - } - - fmt.Fprintf(os.Stderr, "summary: %d/%d passed\n", passed, len(inputs)) - if len(inputs) == 0 { - fmt.Fprintln(os.Stderr, "no tests ran") - os.Exit(1) - } - if hadError || passed != len(inputs) { - os.Exit(1) - } -} - -// Prints the table header. -func printTableHeader() { - fmt.Printf("| %-*s | %-*s | %8s | %8s | %-6s |\n", - fixturePathColumnWidth, "fixture path", - testColumnWidth, "test", - "size (B)", "time (s)", "result") - fmt.Printf("| %s | %s | -------- | -------- | ------ |\n", - strings.Repeat("-", fixturePathColumnWidth), - strings.Repeat("-", testColumnWidth)) -} - -// Prints one table row. -func printTableRow(fixturePath, testName string, size int, userTime time.Duration, ok bool) { - result := "fail" - if ok { - result = "pass" - } - fmt.Printf("| %-*s | %-*s | %8d | %8.3f | %-6s |\n", - fixturePathColumnWidth, - escapeCell(fixturePath), - testColumnWidth, - omitMiddle(escapeCell(testName), testColumnWidth), - size, - userTime.Seconds(), - result, - ) -} - -// Finds the repo root. -func repoRoot() (string, error) { - out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -// Splits a comma list. -func splitList(s string) []string { - var out []string - for _, item := range strings.Split(s, ",") { - item = strings.TrimSpace(item) - if item != "" { - out = append(out, item) - } - } - return out -} - -// Validates a fixture path. -func resolveFixturePath(rootDir, fixturePath string) (string, string, error) { - cleanPath := filepath.Clean(filepath.FromSlash(fixturePath)) - if cleanPath == "." || cleanPath == ".." || filepath.IsAbs(cleanPath) || strings.HasPrefix(cleanPath, ".."+string(os.PathSeparator)) { - return "", "", fmt.Errorf("invalid fixture path") - } - return filepath.ToSlash(cleanPath), filepath.Join(rootDir, cleanPath), nil -} - -// Lists JSON files. -func jsonFiles(dir string) ([]string, error) { - var files []string - err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if !d.IsDir() && strings.HasSuffix(path, ".json") { - files = append(files, path) - } - return nil - }) - if err != nil { - return nil, err - } - sort.Strings(files) - return files, nil -} - -// Writes selected SSZ inputs from one JSON file. -func writeSSZInputs(sszDir, fixturePath, targetDir, jsonPath string, limit int) ([]selectedInput, error) { - jsonRel, err := filepath.Rel(targetDir, jsonPath) - if err != nil { - return nil, err - } - if jsonRel == ".." || strings.HasPrefix(jsonRel, ".."+string(os.PathSeparator)) { - return nil, fmt.Errorf("JSON path is outside target dir: %s", jsonPath) - } - - blocks, err := statelessInputs(jsonPath) - if err != nil { - return nil, err - } - if len(blocks) == 0 { - return nil, nil - } - - jsonStem := strings.TrimSuffix(jsonRel, filepath.Ext(jsonRel)) - outDir := filepath.Join(sszDir, filepath.FromSlash(fixturePath), jsonStem) - if err := os.RemoveAll(outDir); err != nil { - return nil, err - } - if err := os.MkdirAll(outDir, 0o755); err != nil { - return nil, err - } - - var out []selectedInput - for i, block := range blocks { - if limit > 0 && len(out) >= limit { - break - } - outPath := filepath.Join(outDir, fmt.Sprintf("%04d.ssz", i)) - if err := os.WriteFile(outPath, block.input, 0o644); err != nil { - return nil, err - } - out = append(out, selectedInput{ - fixturePath: fixturePath, - jsonFile: jsonRel, - testName: block.testName, - blockIndex: block.blockIndex, - file: outPath, - size: len(block.input), - expectedValid: block.expectedValid, - }) - } - return out, nil -} - -// Extracts stateless inputs from one fixture JSON file. -func statelessInputs(path string) ([]statelessInput, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - var cases map[string]json.RawMessage - if err := json.Unmarshal(data, &cases); err != nil { - return nil, err - } - - names := make([]string, 0, len(cases)) - for name := range cases { - names = append(names, name) - } - sort.Strings(names) - - var out []statelessInput - for _, name := range names { - var testCase fixtureCase - if err := json.Unmarshal(cases[name], &testCase); err != nil { - return nil, err - } - for i, block := range testCase.Blocks { - if block.StatelessInputBytes == "" || block.StatelessOutputBytes == "" { - continue - } - input, err := hexBytes(block.StatelessInputBytes) - if err != nil { - return nil, fmt.Errorf("%s[%d]: %w", name, i, err) - } - output, err := hexBytes(block.StatelessOutputBytes) - if err != nil { - return nil, fmt.Errorf("%s[%d]: %w", name, i, err) - } - if len(output) <= 32 { - return nil, fmt.Errorf("%s[%d]: statelessOutputBytes too short", name, i) - } - out = append(out, statelessInput{ - testName: name, - blockIndex: i, - input: input, - expectedValid: output[32] == 0x01, - }) - } - } - return out, nil -} - -// Decodes 0x-prefixed hex bytes. -func hexBytes(s string) ([]byte, error) { - if len(s) >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X') { - s = s[2:] - } - if len(s)%2 != 0 { - return nil, fmt.Errorf("odd hex length") - } - return hex.DecodeString(s) -} - -// Runs a command. -func run(w io.Writer, name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Stdout = w - cmd.Stderr = w - return cmd.Run() -} - -// Runs the guest. -func runGuest(guestDir, input, zkcFlags string) (bool, time.Duration) { - cmd := exec.Command( - "make", "--no-print-directory", "-C", guestDir, "exec", - "INPUT="+input, - "ZKC_EXEC_FLAGS="+zkcFlags, - ) - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - err := cmd.Run() - if cmd.ProcessState == nil { - return err == nil, 0 - } - return err == nil, cmd.ProcessState.UserTime() -} - -// Escapes table cells. -func escapeCell(s string) string { - return strings.ReplaceAll(s, "|", "\\|") -} - -// Shortens long text. -func omitMiddle(s string, width int) string { - if len(s) <= width { - return s - } - const marker = "[...]" - if width <= len(marker) { - return s[len(s)-width:] - } - remaining := width - len(marker) - prefix := remaining / 2 - suffix := remaining - prefix - return s[:prefix] + marker + s[len(s)-suffix:] -} - -// Exits on error. -func must(err error) { - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/riscv-guests/l2-execution/src/evm_execution_guest.zig b/riscv-guests/l2-execution/src/evm_execution_guest.zig index 172dab5ae6b..879a3ee7bde 100644 --- a/riscv-guests/l2-execution/src/evm_execution_guest.zig +++ b/riscv-guests/l2-execution/src/evm_execution_guest.zig @@ -1,58 +1,36 @@ const std = @import("std"); const builtin = @import("builtin"); -const executor = @import("zesu_executor"); -const ssz_decode = @import("zesu_ssz_decode"); -const ssz_output = @import("zesu_ssz_output"); -const zesu_allocator = @import("zesu_allocator"); +/// Log-preserving stateless-execution seam (full event logs, unlike zesu's own bloom-only +/// `executor.executeStatelessInput`). `pub` for `evm_execution_guest_test.zig`'s parity check +/// against zesu's raw executor; the guest itself reaches this seam only through +/// `l2_execution.runL2Execution`. +pub const execution = @import("execution.zig"); +const l2_execution = @import("l2_execution.zig"); +const l2_execution_ssz = @import("l2_execution_ssz"); // Heap starts at the address defined by the linker script (canonical Lineth layout: `_heap_start` = 0x48800000, grows up). extern var _heap_start: u8; // Linker script does not actually constraint the heap to 256 MiB, but this is a reasonable upper bound const GUEST_HEAP_SIZE: usize = 256 * 1024 * 1024; -// This guest is a thin wrapper over zesu's vanilla stateless execution: it decodes an SSZ-encoded -// StatelessInput, executes the block, and serializes the SSZ validation result — the same pipeline -// as zesu's `runner.runStateless` / `zkevm-blockchain-test-runner`. +// This is the Rollup's extended l2-execution zkVM guest: it decodes the extended +// `L2ExecutionProofPrivateInput` SSZ envelope, runs `l2_execution.runL2Execution` (the +// Linea/Rollup-specific layer over per-block stateless execution — conflation, forced +// transactions, the L1<->L2 bridge, the public-input tuple), and emits the SSZ output. // // The crypto accelerators (zkvm_*) that zesu declares as externs are DEFINED in-guest by // zkvm_provide.zig (pulled in below for the riscv64 build), so the statically-linked guest ELF has // no unresolved zkvm_* externals. The native host build doesn't reference them — it uses zesu's // C-backed crypto instead. -/// Result of running one SSZ-encoded StatelessInput: -/// `out` — the 105-byte SSZ SszStatelessValidationResult -/// `success` — successful_validation: execution succeeded AND the computed post-state and -/// receipts roots match the values claimed in the payload. -pub const Result = struct { - out: [105]u8, - success: bool, -}; - -/// Vanilla zesu stateless block execution. Fed an explicit byte slice so it runs identically on the -/// native host (tests) and from the zkVM guest entry below. -pub fn runStateless(allocator: std.mem.Allocator, ssz_input: []const u8) !Result { - zesu_allocator.set(allocator); - - const si = try ssz_decode.decode(allocator, ssz_input); - const ep = &si.new_payload_request.execution_payload; - - const success = blk: { - const proof = executor.executeStatelessInput(allocator, si, si.chain_config.fork_name) catch break :blk false; - if (!std.mem.eql(u8, &proof.post_state_root, &ep.state_root)) break :blk false; - if (!std.mem.eql(u8, &proof.receipts_root, &ep.receipts_root)) break :blk false; - break :blk true; - }; - - const out = try ssz_output.serialize(allocator, si.new_payload_request, si.chain_config.chain_id, success); - return .{ .out = out, .success = success }; -} - -/// zkVM guest entry. Reads the SSZ StatelessInput via the zkvm-standards `read_input` — the same ABI -/// Zesu uses — then executes it and exits 0 on successful_validation, 1 otherwise. WHERE the input -/// lives is the proving system's concern, NOT the guest's: for Linea, `read_input` is satisfied by -/// zesu-zkvm's `linea/src/zkvm_io.zig` (imported as `linea_zkvm_io`), which reads the memory-mapped -/// `_in_start` (framed `[u64 LE len][SSZ]`). The guest never names a memory slot. +/// zkVM guest entry. Reads the extended `L2ExecutionProofPrivateInput` via `read_input`, runs +/// `l2_execution.runL2Execution`, and emits the SSZ output via `write_output`. Exits 0 on success, +/// 1 on any error. `read_input`/`write_output` are satisfied by zesu-zkvm's `linea_zkvm_io` — where +/// the input lives and how the output surfaces is the proving system's concern, not the guest's. +/// +/// This frozen riscv64 binary has no argv, so output format is fixed at build time (always SSZ); +/// the `--json`/`--ssz` toggle lives on the native `l2-execution-runner` tool instead. fn guestMain() callconv(.c) noreturn { const zkvm_io = @import("linea_zkvm_io"); @@ -63,16 +41,41 @@ fn guestMain() callconv(.c) noreturn { var buf_ptr: [*]const u8 = undefined; var buf_size: usize = undefined; zkvm_io.read_input(&buf_ptr, &buf_size); - const ssz_input = buf_ptr[0..buf_size]; + const raw_input = buf_ptr[0..buf_size]; - const result = runStateless(allocator, ssz_input) catch exit(1); - exit(if (result.success) 0 else 1); + const out = runL2ExecutionGuest(allocator, raw_input) catch exit(1); + zkvm_io.write_output(&out); + exit(0); } +/// Decode -> execute -> encode, factored out of `guestMain` so the whole pipeline is one +/// `catch exit(1)` away from a clean guest rejection. Returns the output BY VALUE (a small, +/// fixed-size array — see `l2_execution_ssz.encodeOutput`'s doc comment) rather than an +/// allocator-backed slice: there's nothing for an allocator to do here. +fn runL2ExecutionGuest(allocator: std.mem.Allocator, raw_input: []const u8) ![l2_execution_ssz.OUTPUT_SIZE]u8 { + const decoded = try l2_execution_ssz.decodeInput(allocator, raw_input); + const result = try l2_execution.runL2Execution(allocator, decoded); + + // Debug visibility for the plain, SSZ-encoded 16-field public-input tuple: `encodeOutput` + // below commits only its keccak256 (see `l2_execution_ssz.hashPublicInputs`), so this is the + // only place the plain tuple is still observable. `zkvm_log` (zesu's real logging ABI — see + // zesu/src/zkvm/root.zig — DEFINED as a no-op in `zkvm_provide.zig`, see its doc comment for + // why) is the standard sink for this; level 0 mirrors zesu's own `std.log`/panic usage. + const public_inputs_bytes = l2_execution_ssz.encodePublicInputsBytes(result.public_inputs); + zkvm_log(0, &public_inputs_bytes, public_inputs_bytes.len); + + return l2_execution_ssz.encodeOutput(result.public_inputs); +} + +/// zesu's own logging ABI (see zesu/src/zkvm/root.zig's doc comment) — DEFINED (as a no-op, for +/// now) in `zkvm_provide.zig` alongside every other `zkvm_*` symbol this statically-linked ELF must +/// satisfy locally. +extern fn zkvm_log(level: u8, msg_ptr: [*]const u8, msg_len: usize) void; + comptime { // Export `main` only for the freestanding RISC-V guest, which owns its entry point. Native - // builds import this as a library (the unit test and the spec runner exe) and get `main` from - // std.start — exporting it here too would be a symbol collision. + // builds import this as a library (the unit test) and get `main` from std.start — exporting it + // here too would be a symbol collision. if (builtin.cpu.arch == .riscv64) { @export(&guestMain, .{ .name = "main" }); // Pull in the precompile providers (zkvm_provide.zig): it DEFINES every zkvm_* symbol zesu diff --git a/riscv-guests/l2-execution/src/execution.zig b/riscv-guests/l2-execution/src/execution.zig new file mode 100644 index 00000000000..2a697ea421c --- /dev/null +++ b/riscv-guests/l2-execution/src/execution.zig @@ -0,0 +1,253 @@ +const std = @import("std"); + +const executor = @import("zesu_executor"); +const zesu_allocator = @import("zesu_allocator"); +const primitives = @import("zesu_primitives"); +const mpt = @import("zesu_mpt"); +const db_mod = @import("zesu_db"); +const context_mod = @import("zesu_context"); +const input = @import("zesu_input"); +const hardfork = @import("zesu_hardfork"); +const rlp_decode = @import("zesu_rlp_decode"); + +const types = executor.executor_types; +const transition_mod = executor.executor_transition; +const output_mod = executor.executor_output; +const tx_decode = executor.executor_tx_decode; +const block_validation = executor.executor_block_validation; + +/// Number of RLP header fields between `parent_hash` ([0]) and `number` ([8]): ommers_hash, +/// beneficiary, state_root, transactions_root, receipts_root, logs_bloom, difficulty. Mirrors the +/// field skip in zesu's `rlp_decode.decodeParentHeader`. +const HEADER_FIELDS_BETWEEN_PARENT_HASH_AND_NUMBER = 7; + +// Log-preserving stateless-execution seam. +// +// Zesu's own `executor.executeStatelessInput`'s `ProofOutput` keeps only a per-receipt +// `logs_bloom` and drops the actual event logs. Rollup phases need the full logs (address + +// topics + data) to derive L1 message events, so this file re-runs the SAME preamble and +// block-execution path but returns the un-projected `Receipt` slice (with `.logs` intact) +// alongside the pre/post state roots. +// +// `buildEnv`/`finalizeOutputWithLogs` below are adapted from Zesu's private `executor/main.zig` +// glue (not exposed, so copied). Block/BAL validation and the accessed-entry builder are NOT +// copied: `executor.executor_block_validation` and `executor.buildAccessedEntries` call zesu's +// real implementations directly. Withdrawals are never converted/passed through: `l2_execution.zig` +// rejects any payload with a non-empty withdrawals list before this seam ever runs (Linea is an L2 +// rollup — it has no beacon-chain withdrawals), so `buildEnv` below is always given an empty list. + +/// Log-preserving counterpart of zesu's `output.ProofOutput`: identical roots, but `receipts` +/// carries the full, un-projected `Receipt` (each with its `[]Log`) instead of the vanilla guest's +/// bloom-only `ReceiptData`. +pub const ProofOutputWithLogs = struct { + pre_state_root: primitives.Hash, + post_state_root: primitives.Hash, + receipts_root: primitives.Hash, + receipts: []const types.Receipt, + fork_name: []const u8, +}; + +// ─── Private helpers (adapted from Zesu executor/main.zig) ─────────────────────────────────────── + +fn buildEnv( + req: input.NewPayloadRequest, + block_hashes: []types.BlockHashEntry, + withdrawals: []types.Withdrawal, + parent: ?rlp_decode.ParentHeader, +) types.Env { + const ep = &req.execution_payload; + return .{ + .coinbase = ep.fee_recipient, + .gas_limit = ep.gas_limit, + .number = ep.block_number, + .timestamp = ep.timestamp, + .difficulty = 0, + .base_fee = ep.base_fee_per_gas, + .random = ep.prev_randao, + .excess_blob_gas = ep.excess_blob_gas, + .parent_beacon_block_root = req.parent_beacon_block_root, + .parent_hash = ep.parent_hash, + .block_hashes = block_hashes, + .withdrawals = withdrawals, + .slot_number = ep.slot_number, + .gas_used_header = ep.gas_used, + .blob_gas_used_header = ep.blob_gas_used, + .parent_gas_limit = if (parent) |p| p.gas_limit else null, + .parent_gas_used = if (parent) |p| p.gas_used else null, + .parent_timestamp = if (parent) |p| p.timestamp else null, + .parent_base_fee = if (parent) |p| p.base_fee_per_gas else null, + .parent_blob_gas_used = if (parent) |p| p.blob_gas_used else null, + .parent_excess_blob_gas = if (parent) |p| p.excess_blob_gas else null, + }; +} + +fn finalizeOutputWithLogs( + alloc: std.mem.Allocator, + pre_state_root: [32]u8, + result: transition_mod.TransitionResult, + node_index: *mpt.NodeIndex, + spec: primitives.SpecId, + witness_db: anytype, +) !ProofOutputWithLogs { + const post_state_root = try output_mod.computeStateRootDelta(alloc, pre_state_root, result.alloc, result.deleted_accounts, node_index, witness_db); + const receipts_root = try output_mod.computeReceiptsRoot(alloc, result.receipts); + return .{ + .pre_state_root = pre_state_root, + .post_state_root = post_state_root, + .receipts_root = receipts_root, + .receipts = result.receipts, + .fork_name = hardfork.specName(spec), + }; +} + +// ─── Public API ──────────────────────────────────────────────────────────────────────────────── + +/// High-level, log-preserving stateless execution from a fully-decoded StatelessInput. Mirrors +/// zesu's `executor.executeStatelessInput` (same preamble: derives `pre_state_root`, builds the +/// block-hash table, validates the header chain) and its `executeBlockStateless` body (same +/// WitnessDatabase + Context wiring, same BAL validation), but returns `ProofOutputWithLogs` — full +/// receipts (with `.logs`) instead of the vanilla guest's bloom-only projection. +/// +/// `node_index` is caller-built and caller-owned (NOT built here, unlike zesu's own +/// `executeStatelessInput`): `l2_execution.runL2Execution` already builds ONE `NodeIndex` combining +/// every payload's witness nodes (it needs that same combined index for its own Linea-specific +/// reads), and building a second, per-payload index here from `si.witness.nodes` alone — a strict +/// subset of what the caller already indexed — would just be duplicate work the guest can't afford +/// to pay for twice. `computeStateRootDelta` (via `finalizeOutputWithLogs` below) mutates +/// `node_index` in place (inserting the post-execution trie's updated nodes under their own, +/// distinct hashes); sharing one evolving index across the whole payload range is correct, not just +/// cheaper, since it's the same content-addressed trie throughout. +pub fn executeStatelessInputWithLogs( + alloc: std.mem.Allocator, + si: input.StatelessInput, + fork_name: []const u8, + node_index: *mpt.NodeIndex, +) !ProofOutputWithLogs { + zesu_allocator.set(alloc); + + const ep = &si.new_payload_request.execution_payload; + + const pre_state_root_raw = rlp_decode.findPreStateRoot(si.witness.headers, ep.block_number); + const pre_state_root = pre_state_root_raw orelse ep.state_root; + + const HeaderInfo = struct { number: u64, parent_hash: [32]u8, hash: [32]u8 }; + var header_infos = std.ArrayListUnmanaged(HeaderInfo).empty; + defer header_infos.deinit(alloc); + var block_hashes = std.ArrayListUnmanaged(types.BlockHashEntry).empty; + defer block_hashes.deinit(alloc); + for (si.witness.headers) |hdr_rlp| { + const hash = mpt.keccak256(hdr_rlp); + const outer = mpt.rlp.decodeItem(hdr_rlp) catch return error.InvalidWitness; + var rest = switch (outer.item) { + .list => |p| p, + .bytes => return error.InvalidWitness, + }; + const parent_hash_result = mpt.rlp.decodeItem(rest) catch return error.InvalidWitness; + const parent_hash_bytes = switch (parent_hash_result.item) { + .bytes => |b| b, + .list => return error.InvalidWitness, + }; + if (parent_hash_bytes.len != 32) return error.InvalidWitness; + var parent_hash: [32]u8 = undefined; + @memcpy(&parent_hash, parent_hash_bytes); + rest = rest[parent_hash_result.consumed..]; + var skip: usize = 0; + while (skip < HEADER_FIELDS_BETWEEN_PARENT_HASH_AND_NUMBER and rest.len > 0) : (skip += 1) { + const field_result = mpt.rlp.decodeItem(rest) catch return error.InvalidWitness; + rest = rest[field_result.consumed..]; + } + if (rest.len == 0) return error.InvalidWitness; + const block_number_result = mpt.rlp.decodeItem(rest) catch return error.InvalidWitness; + const block_number_bytes = switch (block_number_result.item) { + .bytes => |b| b, + .list => return error.InvalidWitness, + }; + if (block_number_bytes.len > 8) return error.InvalidWitness; + var number: u64 = 0; + for (block_number_bytes) |b| number = (number << 8) | b; + try block_hashes.append(alloc, .{ .number = number, .hash = hash }); + try header_infos.append(alloc, .{ .number = number, .parent_hash = parent_hash, .hash = hash }); + } + + var parent_header: ?rlp_decode.ParentHeader = null; + if (header_infos.items.len > 0) { + for (0..header_infos.items.len - 1) |k| { + if (!std.mem.eql(u8, &header_infos.items[k].hash, &header_infos.items[k + 1].parent_hash)) { + return error.InvalidWitness; + } + } + const last = header_infos.items[header_infos.items.len - 1]; + if (!std.mem.eql(u8, &last.hash, &ep.parent_hash)) { + return error.InvalidWitness; + } + parent_header = rlp_decode.decodeParentHeader(si.witness.headers[si.witness.headers.len - 1]) catch + return error.InvalidWitness; + } + + return executeBlockStatelessWithLogs( + alloc, + pre_state_root, + node_index, + si.new_payload_request, + si.witness.codes, + block_hashes.items, + parent_header, + fork_name, + si.chain_config.chain_id, + si.public_keys, + ); +} + +fn executeBlockStatelessWithLogs( + alloc: std.mem.Allocator, + pre_state_root: [32]u8, + node_index: *mpt.NodeIndex, + req: input.NewPayloadRequest, + witness_codes: []const []const u8, + block_hashes: []types.BlockHashEntry, + parent_header: ?rlp_decode.ParentHeader, + fork_name: []const u8, + chain_id: u64, + public_keys: []const []const u8, +) !ProofOutputWithLogs { + const ep = &req.execution_payload; + + const spec = hardfork.specForBlock(fork_name, ep.timestamp) orelse return error.UnsupportedFork; + + const env = buildEnv(req, block_hashes, &.{}, parent_header); + try block_validation.validateBlock(env, spec); + const txs = try tx_decode.decodeTxsFromInput(alloc, ep.transactions); + + var ctx = context_mod.Context(db_mod.WitnessDatabase).new( + try db_mod.WitnessDatabase.init(alloc, node_index, pre_state_root, witness_codes, block_hashes), + spec, + ); + ctx.block = transition_mod.buildBlockEnv(env, spec); + ctx.cfg.chain_id = chain_id; + ctx.cfg.disable_base_fee = (env.base_fee == null); + + const empty_pre_alloc = std.AutoHashMapUnmanaged(types.Address, types.AllocAccount).empty; + const result = try transition_mod.transitionWithContext( + alloc, + &ctx, + empty_pre_alloc, + env, + txs, + spec, + chain_id, + hardfork.blockReward(spec), + public_keys, + ); + if (ctx.ctx_error != .ok) return error.InvalidWitness; + var access_log = ctx.journaled_state.takeAccessLog(); + defer access_log.deinit(); + const accessed = try executor.buildAccessedEntries(alloc, access_log, result.alloc, result.deleted_accounts, result.system_address_user_touched); + const proof = try finalizeOutputWithLogs(alloc, pre_state_root, result, node_index, spec, ctx.getDb()); + try block_validation.validatePostExecution(alloc, env, spec, result.cumulative_gas, result.blob_gas_used, ep.block_access_list, accessed, .{ + .computed_state_root = proof.post_state_root, + .expected_state_root = ep.state_root, + .computed_receipts_root = proof.receipts_root, + .expected_receipts_root = ep.receipts_root, + }); + return proof; +} diff --git a/riscv-guests/l2-execution/src/l2_execution.zig b/riscv-guests/l2-execution/src/l2_execution.zig new file mode 100644 index 00000000000..d6ccc991102 --- /dev/null +++ b/riscv-guests/l2-execution/src/l2_execution.zig @@ -0,0 +1,467 @@ +//! l2-execution guest logic: the Linea-specific layer on top of per-block stateless execution. +//! +//! Faithful translation of the Python reference implementation (`rollup_spec.l2_execution.run_l2_execution_guest` and +//! its helpers) to Zig, wired against zesu's exposed modules: +//! - per-block execution + full logs: `execution.executeStatelessInputWithLogs`; +//! - vanilla stateless-input decode: `zesu_ssz_decode.decode`; +//! - witness-backed MPT account/storage reads: `zesu_mpt.verifyAccountIndexed` / +//! `verifyStorageIndexed` over a NodeIndex built ONCE from ALL payloads' witness state nodes +//! combined (mirrors the Python reference's `_build_node_index` over `all_witnesses`) — the +//! SAME index is then passed into `execution.executeStatelessInputWithLogs` for every payload, +//! so the guest never pays for `mpt.buildNodeIndex` more than this one time. +//! +//! Transaction-sender recovery for forced transactions NOT in the block (the Invalid/Refused §6.5 +//! sub-cases) needs the same ECDSA recovery zesu's block executor performs internally +//! (`executor.executor_tx_signing.recoverSender`). For transactions that ARE in the block (the +//! INCLUDED case), the sender is read directly off the block's own receipts instead +//! (`Receipt.from`, populated by the SAME `recoverSender` call inside zesu's `transition()`, in the +//! block's transaction order) — no second recovery needed. + +const std = @import("std"); + +const executor = @import("zesu_executor"); +const mpt = @import("zesu_mpt"); +const input = @import("zesu_input"); +const primitives = @import("zesu_primitives"); +const ssz_decode = @import("zesu_ssz_decode"); +const zesu_allocator = @import("zesu_allocator"); +const rlp_decode = @import("zesu_rlp_decode"); +const l2_execution_ssz = @import("l2_execution_ssz"); + +const execution = @import("execution.zig"); + +const types = executor.executor_types; +const tx_decode = executor.executor_tx_decode; +const tx_signing = executor.executor_tx_signing; + +// ─── Constants (Readme.md §6.3 / §2.1 / §2.6) ───────────────────────────────────────────────────── + +/// The fork this guest binary is compiled for. Per Readme.md §2.6, the fork is hardcoded into the +/// l2-execution guest binary — one conflation = one fork = one exec programVk — so it is NEVER read +/// from the (prover-controlled) input; see the fork check in `runL2Execution`. +const GUEST_FORK: []const u8 = "Amsterdam"; + +/// L2MessageService's `MessageSent` event topic0 (the L2->L1 bridge message signature). +const BRIDGE_L2L1_MESSAGE_SENT_TOPIC_0: [32]u8 = .{ + 0xe8, 0x56, 0xc2, 0xb8, 0xbd, 0x4e, 0xb0, 0x02, + 0x7c, 0xe3, 0x2e, 0xea, 0xf5, 0x95, 0xc2, 0x1b, + 0x0b, 0x6b, 0x46, 0x44, 0xb3, 0x26, 0xe5, 0xb7, + 0xbd, 0x80, 0xa1, 0xcf, 0x8d, 0xb7, 0x2e, 0x6c, +}; + +/// Storage layout of L2MessageService (see the Python reference implementation's docstring for provenance). +const LAST_ANCHORED_L1_MESSAGE_NUMBER_SLOT: u64 = 280; +const L1_ROLLING_HASHES_MAPPING_BASE_SLOT: u64 = 281; + +const ForcedTransactionAcceptance = struct { + pub const INCLUDED: u8 = 0; + pub const BAD_NONCE: u8 = 1; + pub const BAD_BALANCE: u8 = 2; + pub const FILTERED_ADDRESS_FROM: u8 = 3; + pub const FILTERED_ADDRESS_TO: u8 = 4; +}; + +// ─── Small hashing/encoding helpers (§2.1 / §6.3) ───────────────────────────────────────────────── + +fn u64ToSlot32(n: u64) [32]u8 { + var out: [32]u8 = @splat(0); + std.mem.writeInt(u64, out[24..32], n, .big); + return out; +} + +fn u256ToBytes32(n: u256) [32]u8 { + var out: [32]u8 = undefined; + std.mem.writeInt(u256, &out, n, .big); + return out; +} + +/// Solidity mapping slot: keccak256(key_padded32 || base_slot). +fn mappingSlot(base_slot: [32]u8, key: [32]u8) [32]u8 { + var buf: [64]u8 = undefined; + @memcpy(buf[0..32], &key); + @memcpy(buf[32..64], &base_slot); + return mpt.keccak256(&buf); +} + +/// `ChainConfig.hash(base_fee)`: keccak256(chainID_be32 || coinbase || l2MessageServiceAddress || +/// baseFee_be32). +fn chainConfigHash(chain_config: l2_execution_ssz.ChainConfig, base_fee: u64) [32]u8 { + var buf: [104]u8 = undefined; + @memcpy(buf[0..32], &u64ToSlot32(chain_config.chain_id)); + @memcpy(buf[32..52], &chain_config.coinbase); + @memcpy(buf[52..72], &chain_config.l2_message_service_address); + @memcpy(buf[72..104], &u64ToSlot32(base_fee)); + return mpt.keccak256(&buf); +} + +/// keccak256(prev || txHash || deadline_be32 || from) — §6.3's forced-tx rolling-hash update. +fn addToForcedTxRollingHash(prev: [32]u8, tx_hash: [32]u8, deadline: u64, from_address: [20]u8) [32]u8 { + var buf: [116]u8 = undefined; + @memcpy(buf[0..32], &prev); + @memcpy(buf[32..64], &tx_hash); + @memcpy(buf[64..96], &u64ToSlot32(deadline)); + @memcpy(buf[96..116], &from_address); + return mpt.keccak256(&buf); +} + +/// Hash of a list of 32-byte digests (e.g. `l2_l1_messages_hash`'s message-hash preimages). Named +/// `hashDigestList`, matching the Python reference implementation's `hash_digest_list` (renamed from `hash_hash_list` +/// for the same reason): "hash a HashList" reads as a typo, not a type name; `Digest` avoids the +/// verb/noun clash. +fn hashDigestList(alloc: std.mem.Allocator, values: []const [32]u8) ![32]u8 { + const buf = try alloc.alloc(u8, values.len * 32); + defer alloc.free(buf); + for (values, 0..) |v, i| @memcpy(buf[i * 32 ..][0..32], &v); + return mpt.keccak256(buf); +} + +fn hashAddressList(alloc: std.mem.Allocator, values: []const [20]u8) ![32]u8 { + const buf = try alloc.alloc(u8, values.len * 20); + defer alloc.free(buf); + for (values, 0..) |v, i| @memcpy(buf[i * 20 ..][0..20], &v); + return mpt.keccak256(buf); +} + +// ─── Witness-backed MPT state reads (mirrors state_transition.py's L2State) ─────────────────────── +// +// Semantics (must match the Python reference implementation exactly — see Readme.md's state_transition.py docstrings): +// - account/slot proven absent from the trie -> `null` / `0` (NOT an error); +// - a witness node needed to resolve the path is missing from the pool -> `error.InvalidProof` +// propagates (guest rejection). `verifyAccountIndexed`/`verifyStorageIndexed` already draw this +// exact line: they return `null`/`0` for a proof of absence (empty branch slot / mismatched leaf +// suffix / empty trie root) and `error.InvalidProof` only when `NodeIndex` lookup itself misses. +// This is DELIBERATELY NOT `zesu_db.WitnessDatabase`: its `basic()`/`storage()` catch +// `error.InvalidProof` and silently treat it as absence (a leniency WitnessDatabase needs for +// precompile addresses that have no witness proof during live EVM execution) — that would mask a +// genuinely incomplete witness here, where the Python spec's `_mpt_lookup` raises instead. + +/// Account at `address` proven against `state_root`, or `null` if proven absent. +fn readAccount(state_root: [32]u8, address: [20]u8, node_index: *const mpt.NodeIndex) !?mpt.AccountState { + return mpt.verifyAccountIndexed(state_root, address, node_index); +} + +/// Storage value at (`address`, `slot`) proven against `state_root`; `0` if the account or slot is +/// absent. Mirrors `L2State.storage`. +fn readStorage(state_root: [32]u8, address: [20]u8, slot: [32]u8, node_index: *const mpt.NodeIndex) !u256 { + const account = try readAccount(state_root, address, node_index) orelse return 0; + return mpt.verifyStorageIndexed(account.storage_root, slot, node_index); +} + +/// L2->L1 message extraction: collects `topics[3]` from each receipt log matching the +/// L2MessageService's `MessageSent` signature. +fn extractL2L1Messages( + alloc: std.mem.Allocator, + out: *std.ArrayListUnmanaged([32]u8), + receipts: []const types.Receipt, + l2_ms_address: [20]u8, +) !void { + for (receipts) |receipt| { + for (receipt.logs) |log| { + if (!std.mem.eql(u8, &log.address, &l2_ms_address)) continue; + if (log.topics.len == 0 or !std.mem.eql(u8, &log.topics[0], &BRIDGE_L2L1_MESSAGE_SENT_TOPIC_0)) continue; + if (log.topics.len < 4) return error.InvalidBridgeMessageLog; + try out.append(alloc, log.topics[3]); + } + } +} + +const BridgeState = struct { hash: [32]u8, number: u64 }; + +/// True when `address` is the all-zero (20-byte) address. Used to detect the "no L2MessageService +/// configured" mode below. +fn isZeroAddress(address: [20]u8) bool { + return std.mem.allEqual(u8, &address, 0); +} + +/// `read_l1l2_bridge_state`: the L1->L2 bridge rolling hash and its message number, read from +/// L2MessageService storage at `state_root`. +fn readL1L2BridgeState(state_root: [32]u8, l2_ms_address: [20]u8, node_index: *const mpt.NodeIndex) !BridgeState { + const number_val = try readStorage(state_root, l2_ms_address, u64ToSlot32(LAST_ANCHORED_L1_MESSAGE_NUMBER_SLOT), node_index); + if (number_val > std.math.maxInt(u64)) return error.RollingHashNumberOverflow; + const number: u64 = @intCast(number_val); + + const rolling_hash_slot = mappingSlot(u64ToSlot32(L1_ROLLING_HASHES_MAPPING_BASE_SLOT), u64ToSlot32(number)); + const hash_val = try readStorage(state_root, l2_ms_address, rolling_hash_slot, node_index); + return .{ .hash = u256ToBytes32(hash_val), .number = number }; +} + +// ─── Forced transactions (§6.5, mirrors validate_forced_transactions) ───────────────────────────── + +/// Max gas fee mirroring `is_valid_forced_transaction`'s BAD_BALANCE arithmetic: gas * price for +/// Legacy/EIP-2930 (types 0/1); gas * maxFeePerGas (+ blob gas * maxFeePerBlobGas for type 3) for +/// EIP-1559/4844/7702 (types 2/3/4). +fn maxGasFee(tx: *const types.TxInput) u256 { + if (tx.type == 2 or tx.type == 3 or tx.type == 4) { + var fee: u256 = @as(u256, tx.gas) * @as(u256, tx.max_fee_per_gas orelse 0); + if (tx.type == 3) { + const blob_gas: u256 = @as(u256, tx.blob_versioned_hashes.len) * @as(u256, primitives.GAS_PER_BLOB); + fee += blob_gas * @as(u256, tx.max_fee_per_blob_gas orelse 0); + } + return fee; + } + return @as(u256, tx.gas) * @as(u256, tx.gas_price orelse 0); +} + +/// `validate_forced_transactions`: scans one payload's declared forced transactions, updates the +/// FTX rolling hash for every one of them, and asserts each has the declared outcome. Returns the +/// addresses bubbled up for L1-side sanction-list checking (Refused sub-cases). +fn validateForcedTransactions( + alloc: std.mem.Allocator, + curr_rolling_hash: *[32]u8, + last_processed_ftx_number: *u64, + chain_id: u64, + payload: input.ExecutionPayload, + block_pre_state_root: [32]u8, + node_index: *const mpt.NodeIndex, + forced_transactions: []const l2_execution_ssz.ForcedTransactionWitness, +) ![]const [20]u8 { + var rejected = std.ArrayListUnmanaged([20]u8).empty; + + const payload_tx_hashes = try alloc.alloc([32]u8, payload.raw_transactions.len); + defer alloc.free(payload_tx_hashes); + for (payload.raw_transactions, 0..) |raw, i| payload_tx_hashes[i] = mpt.keccak256(raw); + + for (forced_transactions) |ftx| { + if (ftx.number != last_processed_ftx_number.* + 1) return error.ForcedTxOutOfOrder; + if (ftx.deadline < payload.block_number) return error.ForcedTxDeadlineExceeded; + + const decoded = try tx_decode.decodeTxs(alloc, &.{ftx.signed_tx_rlp}); + const tx = &decoded[0]; + const from_address = try tx_signing.recoverSender(alloc, tx, chain_id) orelse return error.ForcedTxSenderRecoveryFailed; + const tx_hash = mpt.keccak256(ftx.signed_tx_rlp); + + // Rolling hash update for EVERY FTX in the range, regardless of outcome. + curr_rolling_hash.* = addToForcedTxRollingHash(curr_rolling_hash.*, tx_hash, ftx.deadline, from_address); + last_processed_ftx_number.* = ftx.number; + + if (ftx.acceptance == ForcedTransactionAcceptance.FILTERED_ADDRESS_FROM) { + try rejected.append(alloc, from_address); + continue; + } + if (ftx.acceptance == ForcedTransactionAcceptance.FILTERED_ADDRESS_TO) { + const to = tx.to orelse return error.FilteredAddressToOnContractCreation; + try rejected.append(alloc, to); + continue; + } + + var tx_in_block = false; + for (payload_tx_hashes) |h| { + if (std.mem.eql(u8, &h, &tx_hash)) { + tx_in_block = true; + break; + } + } + + if (ftx.acceptance == ForcedTransactionAcceptance.INCLUDED) { + if (!tx_in_block) return error.IncludedForcedTxNotInBlock; + continue; + } + if (ftx.acceptance != ForcedTransactionAcceptance.BAD_NONCE and + ftx.acceptance != ForcedTransactionAcceptance.BAD_BALANCE) + { + return error.UnknownForcedTxAcceptance; + } + if (tx_in_block) return error.InvalidForcedTxFoundInBlock; + + const sender_account = try readAccount(block_pre_state_root, from_address, node_index) orelse + return error.ForcedTxSenderAbsent; + + if (ftx.acceptance == ForcedTransactionAcceptance.BAD_NONCE) { + if (sender_account.nonce == (tx.nonce orelse 0)) return error.BadNonceMismatch; + continue; + } + + // BAD_BALANCE. + const max_fee = maxGasFee(tx); + if (sender_account.balance >= max_fee + tx.value) return error.BadBalanceMismatch; + } + + return rejected.toOwnedSlice(alloc); +} + +// ─── Top-level guest logic (mirrors run_l2_execution_guest) ─────────────────────────────────────── + +/// l2-execution: emits the 16-field l2-execution PI for a contiguous block range, translating +/// `rollup_spec.l2_execution.run_l2_execution_guest` step by step. Per-block execution is delegated +/// to `execution.executeStatelessInputWithLogs`; this function adds only the Linea logic on top — +/// conflation-level linking, the empty-`executionRequests` policy, forced transactions, L2->L1 +/// messages, and the L1->L2 bridge rolling-hash reads. +pub fn runL2Execution(alloc: std.mem.Allocator, in: l2_execution_ssz.L2ExecutionProofPrivateInput) !l2_execution_ssz.L2ExecutionProofOutput { + zesu_allocator.set(alloc); + + if (in.payloads.len == 0) return error.EmptyPayloads; + + // Decode each payload's vanilla stateless input ONCE; parsed objects are shared between + // execution and the Linea logic below. + const stateless_inputs = try alloc.alloc(input.StatelessInput, in.payloads.len); + for (in.payloads, 0..) |payload, i| { + stateless_inputs[i] = ssz_decode.decode(alloc, payload.stateless_input_ssz) catch return error.InvalidStatelessInput; + } + + // Combined NodeIndex over ALL payloads' witness state nodes (mirrors `_build_node_index` over + // Python's `all_witnesses`), used for every Linea-extra MPT read below (FTX-sender accounts, + // the L1->L2 bridge rolling-hash slots). + var combined_nodes = std.ArrayListUnmanaged([]const u8).empty; + for (stateless_inputs) |si| try combined_nodes.appendSlice(alloc, si.witness.nodes); + var node_index = try mpt.buildNodeIndex(alloc, combined_nodes.items); + defer node_index.deinit(); + + const first_payload = stateless_inputs[0].new_payload_request.execution_payload; + // The engine validates each payload's parentHash against its witness parent header, so the + // range's parent block hash is the first payload's parentHash and the start block number is the + // first payload's block number. + const parent_block_hash = first_payload.parent_hash; + const start_block_number = first_payload.block_number; + const base_fee = first_payload.base_fee_per_gas; // asserted constant across the range (§2.1) + const l2_ms_address = in.chain_config.l2_message_service_address; + // "No L2MessageService configured" mode: a zero l2MessageServiceAddress means this range's + // chain has no bridge contract, so there is nothing to read or scan. Both the L1->L2 bridge + // rolling-hash boundary reads and the L2->L1 message-log extraction are suppressed and the four + // bridge PI fields are pinned to zero (mirrors the Python reference implementation's read_l1l2_bridge_state + // zero-address branch). This is a real semantic, not test scaffolding — but it is also what lets + // a vanilla EF stateless input (which has no L2MessageService account, and whose witness only + // covers nodes execution touched) be dummy-wrapped and run through this guest unchanged. + const bridge_suppressed = isZeroAddress(l2_ms_address); + + var current_parent_hash = parent_block_hash; + var current_ftx_rolling_hash = in.parent_ftx_rolling_hash; + var current_last_processed_ftx_number = in.parent_last_processed_ftx_number; + + var l2_l1_messages = std.ArrayListUnmanaged([32]u8).empty; + var tx_froms = std.ArrayListUnmanaged([20]u8).empty; + var filtered_addresses = std.ArrayListUnmanaged([20]u8).empty; + + var range_pre_state_root: [32]u8 = undefined; + var range_post_state_root: [32]u8 = undefined; + var last_payload: input.ExecutionPayload = undefined; + + for (in.payloads, stateless_inputs, 0..) |linea_payload, si, idx| { + const payload = si.new_payload_request.execution_payload; + + // ── Conflation-level invariants the engine cannot know (it validates each block in + // isolation against its own witness parent) ── + if (si.chain_config.chain_id != in.chain_config.chain_id) return error.ChainIdMismatch; + if (!std.mem.eql(u8, &payload.parent_hash, ¤t_parent_hash)) return error.ParentHashChainMismatch; + if (payload.base_fee_per_gas != base_fee) return error.BaseFeeNotConstant; + if (!std.mem.eql(u8, &payload.fee_recipient, &in.chain_config.coinbase)) return error.FeeRecipientMismatch; + // A real, hash-verified parent header MUST be resolvable from this payload's own witness, + // UNLESS this genuinely is genesis (block 0), which has no parent to prove — theoretically + // supported (some Lineth deployment could start a range there), but constrained to the + // standard Ethereum convention (parent_hash == zero) so the exemption can't be (ab)used to + // skip the header check for anything other than a real genesis block. This guards against a + // real gap: `execution.zig`'s `pre_state_root` derivation (`rlp_decode.findPreStateRoot(...) + // orelse ep.state_root`) falls back to the payload's OWN claimed (post-execution) state_root + // as its pre-state root whenever no witness header matches — self-referential, and + // completely disconnected from the real state behind `payload.parent_hash`. Combined with a + // no-op block, that lets a forged witness pick an arbitrary starting trie and forge whatever + // it reads from it (e.g. the first payload's `readL1L2BridgeState` reads, below, which land + // straight in the public output). Requiring this to resolve forces `execution.zig`'s own + // header-chain verification to run for real (never silently skipped) and ties + // `payload.block_number` to the real parent's real number — closing the + // block-number-contiguity gap noted below as a side effect, since `findPreStateRoot` only + // matches a header that's part of the hash-chain verified back to `payload.parent_hash`. + if (payload.block_number == 0) { + if (!std.mem.allEqual(u8, &payload.parent_hash, 0)) return error.InvalidGenesisParentHash; + } else if (rlp_decode.findPreStateRoot(si.witness.headers, payload.block_number) == null) { + return error.MissingParentHeaderWitness; + } + // Monotonic timestamps follow from the engine's per-block check (zesu's + // block_validation.validateBlock: `env.timestamp <= env.parent_timestamp` -> + // error.InvalidBlockTimestampOlderThanParent), fed the witness-verified parent header's own + // timestamp — guaranteed reachable now that a real parent header is required above. + + // ── Lineth policy: this rollup does not support EIP-7685 requests ── + const requests = si.new_payload_request.execution_requests; + if (requests.deposits.len != 0 or requests.withdrawals.len != 0 or requests.consolidations.len != 0) { + return error.ExecutionRequestsNotSupported; + } + + // ── Lineth policy: no beacon-chain withdrawals — this is an L2 rollup, not L1. Rejected here + // (cheap length check) rather than processed, so no proving cycles are ever spent crediting + // a withdrawal that can't legitimately exist on this chain. + if (payload.withdrawals.len != 0) { + return error.WithdrawalsNotSupported; + } + + // ── Fork is hardcoded (§2.6), never taken from input: reject any payload that doesn't + // declare the guest's own fork, then execute against GUEST_FORK regardless — so a mismatched + // claim fails cleanly instead of silently executing under a different fork's rules. + if (si.chain_config.fork_name == null or !std.mem.eql(u8, si.chain_config.fork_name.?, GUEST_FORK)) { + return error.UnsupportedFork; + } + + // ── State transition (delegated) ── + // Reuses the SAME combined `node_index` built above (not a fresh per-payload one — see + // `executeStatelessInputWithLogs`'s doc comment): it's a superset of `si.witness.nodes` + // alone, so every proof this payload's execution needs is already indexed. + const result = try execution.executeStatelessInputWithLogs(alloc, si, GUEST_FORK, &node_index); + if (idx == 0) range_pre_state_root = result.pre_state_root; + range_post_state_root = result.post_state_root; + last_payload = payload; + + // Linea PI: each receipt's `.from` is the sender zesu's own transition() already recovered + // (same recoverSender + chain_id, in block transaction order) — reused rather than + // re-derived. + for (result.receipts) |receipt| try tx_froms.append(alloc, receipt.from); + + // Forced transactions (§6.5): the Invalid sub-cases read the sender account at this block's + // PARENT state root by walking the combined witness MPT. + const block_filtered = try validateForcedTransactions( + alloc, + ¤t_ftx_rolling_hash, + ¤t_last_processed_ftx_number, + in.chain_config.chain_id, + payload, + result.pre_state_root, + &node_index, + linea_payload.forced_transactions, + ); + try filtered_addresses.appendSlice(alloc, block_filtered); + + // L2->L1 messages from the block's logs (skipped entirely when no L2MessageService is + // configured — see `bridge_suppressed`). + if (!bridge_suppressed) try extractL2L1Messages(alloc, &l2_l1_messages, result.receipts, l2_ms_address); + + current_parent_hash = payload.block_hash; + } + + // L1->L2 bridge rolling-hash boundary reads, at the range's parent (pre) and end (post) state + // roots, by walking the combined witness MPT. Suppressed to zeros when no L2MessageService is + // configured (see `bridge_suppressed`); the end>=parent check then trivially holds (0>=0). + const parent_bridge: BridgeState = if (bridge_suppressed) + .{ .hash = @splat(0), .number = 0 } + else + try readL1L2BridgeState(range_pre_state_root, l2_ms_address, &node_index); + const end_bridge: BridgeState = if (bridge_suppressed) + .{ .hash = @splat(0), .number = 0 } + else + try readL1L2BridgeState(range_post_state_root, l2_ms_address, &node_index); + if (end_bridge.number < parent_bridge.number) return error.RollingHashNumberDecreased; + + const public_inputs = l2_execution_ssz.L2ExecutionProofPublicInput{ + .parent_block_hash = parent_block_hash, + .end_block_hash = last_payload.block_hash, + .end_block_number = last_payload.block_number, + .end_block_timestamp = last_payload.timestamp, + .l2_l1_messages_hash = try hashDigestList(alloc, l2_l1_messages.items), + .parent_l1_l2_bridge_rolling_hash = parent_bridge.hash, + .parent_l1_l2_bridge_rolling_hash_message_number = parent_bridge.number, + .end_l1_l2_bridge_rolling_hash = end_bridge.hash, + .end_l1_l2_bridge_rolling_hash_message_number = end_bridge.number, + .dynamic_chain_config_hash = chainConfigHash(in.chain_config, base_fee), + .parent_ftx_rolling_hash = in.parent_ftx_rolling_hash, + .parent_processed_ftx_number = in.parent_last_processed_ftx_number, + .end_ftx_rolling_hash = current_ftx_rolling_hash, + .end_processed_ftx_number = current_last_processed_ftx_number, + .filtered_addresses_hash = try hashAddressList(alloc, filtered_addresses.items), + .tx_froms_hash = try hashAddressList(alloc, tx_froms.items), + }; + + return .{ + .public_inputs = public_inputs, + .start_block_number = start_block_number, + .l2_l1_messages = try l2_l1_messages.toOwnedSlice(alloc), + .tx_froms = try tx_froms.toOwnedSlice(alloc), + .filtered_addresses = try filtered_addresses.toOwnedSlice(alloc), + }; +} diff --git a/riscv-guests/l2-execution/src/l2_execution_ssz.zig b/riscv-guests/l2-execution/src/l2_execution_ssz.zig new file mode 100644 index 00000000000..0ae25b903e4 --- /dev/null +++ b/riscv-guests/l2-execution/src/l2_execution_ssz.zig @@ -0,0 +1,397 @@ +//! Manual SSZ codec for the extended l2-execution guest wire format. +//! +//! Mirrors the Python reference codec's wire format byte-for-byte: a 2-byte +//! big-endian schema id (0x0002 for the input, 0x0003 for the output) +//! followed by the SSZ encoding of the containers below. This codec's +//! `encode`/`decode` byte layout must match the Python reference's +//! `encode_bytes` exactly (verified by the golden-vector test). +//! +//! Each payload's `stateless_input_ssz` is carried opaquely — a zero-copy +//! slice into the input buffer — and never decoded here; that stays the +//! vanilla stateless-input SSZ decoder's job (e.g. zesu's `ssz_decode.decode`), +//! invoked one level up once this codec has split the extended envelope apart. +//! +//! Container layouts (fixed-head byte sizes — see each *_FIXED_SIZE constant below for the field +//! breakdown): +//! SszL2ExecutionProofPrivateInput: 92 bytes +//! SszLineaPayloadInput: 8 bytes +//! SszForcedTransactionWitness: 21 bytes +//! SszL2ExecutionProofOutput: 32 bytes (ONLY `keccak256(public_inputs)` — see +//! `hashPublicInputs`/`encodeOutput`; `L2ExecutionProofOutput`'s other fields — +//! `start_block_number`, `l2_l1_messages`, `tx_froms`, `filtered_addresses` — are off-chain/ +//! native-tooling data, never part of this wire format) +//! SszL2ExecutionProofPublicInput: 368 bytes (16 fields, all fixed-size) — never written to the +//! wire itself; only its hash is (`encodePublicInputsBytes` exists purely for logging/off-chain +//! visibility, e.g. the guest's `zkvm_log` call). + +const std = @import("std"); + +pub const INPUT_SCHEMA_ID: u16 = 0x0002; +pub const OUTPUT_SCHEMA_ID: u16 = 0x0003; +const SCHEMA_ID_SIZE: usize = 2; + +// ── SSZ list/vector bounds ─────────────────────────────────────────────────── +// These bound only merkleization, never `encode`/`decode` — the wire bytes +// this codec produces/consumes do not depend on them. Values are chosen +// generously and MUST match the Python reference codec's bounds exactly (kept +// here only so a decoder can reject a maliciously huge list length early). +pub const MAX_PAYLOADS: usize = 1 << 16; +pub const MAX_FTX_PER_PAYLOAD: usize = 1 << 16; +pub const MAX_MESSAGES: usize = 1 << 16; +pub const MAX_TX_FROMS: usize = 1 << 16; +pub const MAX_FILTERED: usize = 1 << 16; +pub const MAX_STATELESS_INPUT_BYTES: usize = 1 << 30; +pub const MAX_TX_BYTES: usize = 1 << 30; // matches the consensus-layer Transaction ByteList limit + +// ── Logical values ──────────────────────────────────────────────────────────── + +pub const ChainConfig = struct { + l2_message_service_address: [20]u8, + coinbase: [20]u8, + chain_id: u64, +}; + +pub const ForcedTransactionWitness = struct { + number: u64, + /// Zero-copy slice into the decoded buffer. + signed_tx_rlp: []const u8, + /// The `ForcedTransactionAcceptance` enum value (0..4). + acceptance: u8, + deadline: u64, +}; + +pub const LineaPayloadInput = struct { + /// Zero-copy slice into the decoded buffer: the opaque, already + /// 0x0001-framed vanilla stateless-input SSZ bytes. + stateless_input_ssz: []const u8, + forced_transactions: []const ForcedTransactionWitness, +}; + +pub const L2ExecutionProofPrivateInput = struct { + parent_ftx_rolling_hash: [32]u8, + parent_last_processed_ftx_number: u64, + chain_config: ChainConfig, + payloads: []const LineaPayloadInput, +}; + +/// The 16-field l2-execution public input tuple, in wire order. +pub const L2ExecutionProofPublicInput = struct { + parent_block_hash: [32]u8, + end_block_hash: [32]u8, + end_block_number: u64, + end_block_timestamp: u64, + l2_l1_messages_hash: [32]u8, + parent_l1_l2_bridge_rolling_hash: [32]u8, + parent_l1_l2_bridge_rolling_hash_message_number: u64, + end_l1_l2_bridge_rolling_hash: [32]u8, + end_l1_l2_bridge_rolling_hash_message_number: u64, + dynamic_chain_config_hash: [32]u8, + parent_ftx_rolling_hash: [32]u8, + parent_processed_ftx_number: u64, + end_ftx_rolling_hash: [32]u8, + end_processed_ftx_number: u64, + filtered_addresses_hash: [32]u8, + tx_froms_hash: [32]u8, +}; + +/// The guest's output: the public-input tuple plus the revealed hash +/// preimages the rollup guest needs (`proof` is attached by the prover layer +/// above the guest, so it has no place in this wire format). +pub const L2ExecutionProofOutput = struct { + public_inputs: L2ExecutionProofPublicInput, + start_block_number: u64, + l2_l1_messages: []const [32]u8, + tx_froms: []const [20]u8, + filtered_addresses: []const [20]u8, +}; + +// ── Primitive reads/writes (little-endian, matching SSZ) ──────────────────── + +inline fn readU32(data: []const u8, off: usize) u32 { + return std.mem.readInt(u32, data[off..][0..4], .little); +} + +inline fn readU64(data: []const u8, off: usize) u64 { + return std.mem.readInt(u64, data[off..][0..8], .little); +} + +inline fn writeU32(out: []u8, off: usize, value: u32) void { + std.mem.writeInt(u32, out[off..][0..4], value, .little); +} + +inline fn writeU64(out: []u8, off: usize, value: u64) void { + std.mem.writeInt(u64, out[off..][0..8], value, .little); +} + +// ── Generic "List[VariableSizeType, N]" codec ──────────────────────────────── +// +// SSZ encodes a list of variable-size elements exactly like a container's +// variable-field region: an offset table (4 bytes per element, each an +// absolute offset from the start of this region) followed by the +// concatenated element bytes, in order. + +fn decodeVariableList(alloc: std.mem.Allocator, data: []const u8, max_len: usize) ![]const []const u8 { + if (data.len == 0) return &.{}; + if (data.len < 4) return error.InvalidSsz; + + const first_off = readU32(data, 0); + if (first_off == 0 or first_off % 4 != 0) return error.InvalidSsz; + if (first_off > data.len) return error.InvalidSsz; + const n = first_off / 4; + if (n > max_len) return error.InvalidSsz; + + const result = try alloc.alloc([]const u8, n); + for (0..n) |i| { + const off_i = readU32(data, i * 4); + const end_i: u32 = if (i + 1 < n) readU32(data, (i + 1) * 4) else blk: { + if (data.len > std.math.maxInt(u32)) return error.InvalidSsz; + break :blk @intCast(data.len); + }; + if (off_i > data.len or end_i > data.len or off_i > end_i) return error.InvalidSsz; + result[i] = data[off_i..end_i]; + } + return result; +} + +fn encodeVariableList(alloc: std.mem.Allocator, items: []const []const u8) ![]u8 { + const n = items.len; + var total: usize = n * 4; + for (items) |item| total += item.len; + + const out = try alloc.alloc(u8, total); + var offset: u32 = @intCast(n * 4); + for (items, 0..) |item, i| { + writeU32(out, i * 4, offset); + offset += @intCast(item.len); + } + var pos: usize = n * 4; + for (items) |item| { + @memcpy(out[pos..][0..item.len], item); + pos += item.len; + } + return out; +} + +// ── ForcedTransactionWitness ────────────────────────────────────────────────── +// Fixed head: number(8) + signed_tx_rlp offset(4) + acceptance(1) + deadline(8) = 21. +const FTW_FIXED_SIZE: usize = 21; + +fn decodeForcedTransactionWitness(bytes: []const u8) !ForcedTransactionWitness { + if (bytes.len < FTW_FIXED_SIZE) return error.InvalidSsz; + const number = readU64(bytes, 0); + const off_tx = readU32(bytes, 8); + // The only variable field is last, so its offset must exactly equal the + // fixed-head size — anything else is not the canonical encoding. + if (off_tx != FTW_FIXED_SIZE or off_tx > bytes.len) return error.InvalidSsz; + const acceptance = bytes[12]; + const deadline = readU64(bytes, 13); + return .{ + .number = number, + .signed_tx_rlp = bytes[off_tx..], + .acceptance = acceptance, + .deadline = deadline, + }; +} + +fn encodeForcedTransactionWitness(alloc: std.mem.Allocator, v: ForcedTransactionWitness) ![]u8 { + const out = try alloc.alloc(u8, FTW_FIXED_SIZE + v.signed_tx_rlp.len); + writeU64(out, 0, v.number); + writeU32(out, 8, @intCast(FTW_FIXED_SIZE)); + out[12] = v.acceptance; + writeU64(out, 13, v.deadline); + @memcpy(out[FTW_FIXED_SIZE..], v.signed_tx_rlp); + return out; +} + +// ── LineaPayloadInput ───────────────────────────────────────────────────────── +// Fixed head: stateless_input_ssz offset(4) + forced_transactions offset(4) = 8. +const LPI_FIXED_SIZE: usize = 8; + +fn decodeLineaPayloadInput(alloc: std.mem.Allocator, bytes: []const u8) !LineaPayloadInput { + if (bytes.len < LPI_FIXED_SIZE) return error.InvalidSsz; + const off_ssz = readU32(bytes, 0); + const off_ftx = readU32(bytes, 4); + if (off_ssz != LPI_FIXED_SIZE or off_ssz > off_ftx or off_ftx > bytes.len) return error.InvalidSsz; + + const stateless_input_ssz = bytes[off_ssz..off_ftx]; + if (stateless_input_ssz.len > MAX_STATELESS_INPUT_BYTES) return error.InvalidSsz; + + const ftx_slices = try decodeVariableList(alloc, bytes[off_ftx..], MAX_FTX_PER_PAYLOAD); + const forced_transactions = try alloc.alloc(ForcedTransactionWitness, ftx_slices.len); + for (ftx_slices, 0..) |slice, i| { + forced_transactions[i] = try decodeForcedTransactionWitness(slice); + if (forced_transactions[i].signed_tx_rlp.len > MAX_TX_BYTES) return error.InvalidSsz; + } + + return .{ + .stateless_input_ssz = stateless_input_ssz, + .forced_transactions = forced_transactions, + }; +} + +fn encodeLineaPayloadInput(alloc: std.mem.Allocator, v: LineaPayloadInput) ![]u8 { + const ftx_bufs = try alloc.alloc([]const u8, v.forced_transactions.len); + for (v.forced_transactions, 0..) |ftx, i| { + ftx_bufs[i] = try encodeForcedTransactionWitness(alloc, ftx); + } + const ftx_list_bytes = try encodeVariableList(alloc, ftx_bufs); + + const out = try alloc.alloc(u8, LPI_FIXED_SIZE + v.stateless_input_ssz.len + ftx_list_bytes.len); + writeU32(out, 0, @intCast(LPI_FIXED_SIZE)); + writeU32(out, 4, @intCast(LPI_FIXED_SIZE + v.stateless_input_ssz.len)); + @memcpy(out[LPI_FIXED_SIZE..][0..v.stateless_input_ssz.len], v.stateless_input_ssz); + @memcpy(out[LPI_FIXED_SIZE + v.stateless_input_ssz.len ..], ftx_list_bytes); + return out; +} + +// ── L2ExecutionProofPrivateInput (the extended guest INPUT) ────────────────── +// Fixed head: hash(32) + u64(8) + chain_config(20+20+8=48) + payloads offset(4) = 92. +const INPUT_FIXED_SIZE: usize = 92; + +/// Decode the extended l2-execution guest input: the 0x0002 schema id +/// followed by the SSZ `SszL2ExecutionProofPrivateInput`. +pub fn decodeInput(alloc: std.mem.Allocator, data: []const u8) !L2ExecutionProofPrivateInput { + if (data.len < SCHEMA_ID_SIZE) return error.InvalidSsz; + if (std.mem.readInt(u16, data[0..2], .big) != INPUT_SCHEMA_ID) return error.InvalidSsz; + + const body = data[SCHEMA_ID_SIZE..]; + if (body.len < INPUT_FIXED_SIZE) return error.InvalidSsz; + + var parent_ftx_rolling_hash: [32]u8 = undefined; + @memcpy(&parent_ftx_rolling_hash, body[0..32]); + const parent_last_processed_ftx_number = readU64(body, 32); + + var l2_message_service_address: [20]u8 = undefined; + @memcpy(&l2_message_service_address, body[40..60]); + var coinbase: [20]u8 = undefined; + @memcpy(&coinbase, body[60..80]); + const chain_id = readU64(body, 80); + + const off_payloads = readU32(body, 88); + if (off_payloads != INPUT_FIXED_SIZE or off_payloads > body.len) return error.InvalidSsz; + + const payload_slices = try decodeVariableList(alloc, body[off_payloads..], MAX_PAYLOADS); + const payloads = try alloc.alloc(LineaPayloadInput, payload_slices.len); + for (payload_slices, 0..) |slice, i| { + payloads[i] = try decodeLineaPayloadInput(alloc, slice); + } + + return .{ + .parent_ftx_rolling_hash = parent_ftx_rolling_hash, + .parent_last_processed_ftx_number = parent_last_processed_ftx_number, + .chain_config = .{ + .l2_message_service_address = l2_message_service_address, + .coinbase = coinbase, + .chain_id = chain_id, + }, + .payloads = payloads, + }; +} + +/// Encode the extended l2-execution guest input. Inverse of `decodeInput`. +/// Not used by the guest at runtime (the guest only ever decodes its input) — +/// kept so the codec's byte-exact round-trip can be asserted against the +/// golden vector, the same gate the Python reference codec is held to. +pub fn encodeInput(alloc: std.mem.Allocator, v: L2ExecutionProofPrivateInput) ![]u8 { + const payload_bufs = try alloc.alloc([]const u8, v.payloads.len); + for (v.payloads, 0..) |p, i| payload_bufs[i] = try encodeLineaPayloadInput(alloc, p); + const payloads_bytes = try encodeVariableList(alloc, payload_bufs); + + const out = try alloc.alloc(u8, SCHEMA_ID_SIZE + INPUT_FIXED_SIZE + payloads_bytes.len); + std.mem.writeInt(u16, out[0..2], INPUT_SCHEMA_ID, .big); + const body = out[SCHEMA_ID_SIZE..]; + + @memcpy(body[0..32], &v.parent_ftx_rolling_hash); + writeU64(body, 32, v.parent_last_processed_ftx_number); + @memcpy(body[40..60], &v.chain_config.l2_message_service_address); + @memcpy(body[60..80], &v.chain_config.coinbase); + writeU64(body, 80, v.chain_config.chain_id); + writeU32(body, 88, @intCast(INPUT_FIXED_SIZE)); + @memcpy(body[INPUT_FIXED_SIZE..], payloads_bytes); + + return out; +} + +// ── L2ExecutionProofOutput (the extended guest OUTPUT) ──────────────────────── +// The plain public-input tuple, SSZ-encoded, has no variable fields (368 bytes). +const PI_FIXED_SIZE: usize = 368; +// The wire output is ONLY keccak256(public_inputs) — nothing else. +const OUTPUT_BODY_SIZE: usize = 32; +/// Total wire-output size: the 0x0003 schema id (2 bytes) + keccak256(public_inputs) (32 bytes). +pub const OUTPUT_SIZE: usize = SCHEMA_ID_SIZE + OUTPUT_BODY_SIZE; + +/// Write a 32-byte hash at the cursor and advance it. +inline fn putHash(out: []u8, pos: *usize, value: [32]u8) void { + @memcpy(out[pos.*..][0..32], &value); + pos.* += 32; +} + +/// Write a little-endian u64 at the cursor and advance it. +inline fn putU64(out: []u8, pos: *usize, value: u64) void { + writeU64(out, pos.*, value); + pos.* += 8; +} + +fn encodePublicInputs(out: []u8, pi: L2ExecutionProofPublicInput) void { + var pos: usize = 0; + putHash(out, &pos, pi.parent_block_hash); + putHash(out, &pos, pi.end_block_hash); + putU64(out, &pos, pi.end_block_number); + putU64(out, &pos, pi.end_block_timestamp); + putHash(out, &pos, pi.l2_l1_messages_hash); + putHash(out, &pos, pi.parent_l1_l2_bridge_rolling_hash); + putU64(out, &pos, pi.parent_l1_l2_bridge_rolling_hash_message_number); + putHash(out, &pos, pi.end_l1_l2_bridge_rolling_hash); + putU64(out, &pos, pi.end_l1_l2_bridge_rolling_hash_message_number); + putHash(out, &pos, pi.dynamic_chain_config_hash); + putHash(out, &pos, pi.parent_ftx_rolling_hash); + putU64(out, &pos, pi.parent_processed_ftx_number); + putHash(out, &pos, pi.end_ftx_rolling_hash); + putU64(out, &pos, pi.end_processed_ftx_number); + putHash(out, &pos, pi.filtered_addresses_hash); + putHash(out, &pos, pi.tx_froms_hash); + std.debug.assert(pos == PI_FIXED_SIZE); +} + +/// SSZ-encode the plain public-input tuple to its fixed 368-byte wire representation. Exposed for +/// callers that need the plain tuple outside `encodeOutput`'s hash-only wire output — namely +/// `hashPublicInputs` below and the guest's pre-hash debug log (`zkvm_log`, see +/// `evm_execution_guest.zig`). +pub fn encodePublicInputsBytes(pi: L2ExecutionProofPublicInput) [PI_FIXED_SIZE]u8 { + var out: [PI_FIXED_SIZE]u8 = undefined; + encodePublicInputs(&out, pi); + return out; +} + +/// keccak256 of the SSZ-encoded plain public-input tuple — the single field `encodeOutput` commits +/// in place of the 16-field tuple itself. +pub fn hashPublicInputs(pi: L2ExecutionProofPublicInput) [32]u8 { + const encoded = encodePublicInputsBytes(pi); + var out: [32]u8 = undefined; + std.crypto.hash.sha3.Keccak256.hash(&encoded, &out, .{}); + return out; +} + +/// Encode the extended l2-execution guest's ACTUAL wire output: the 0x0003 schema id followed by +/// ONLY `keccak256(public_inputs)` (see `hashPublicInputs`) — 32 bytes, nothing else. Returns a +/// fixed-size stack array (no allocator, no error union) since this is the only output shape. +/// +/// Deliberately NOT zesu's vanilla `Result{out, len, success}` convention (`run.zig`): zesu commits +/// on failure too, but what it commits is the SSZ hash_tree_root of the WHOLE (untrusted, always +/// available pre-execution) `NewPayloadRequest` paired with `success=0x00` — a binding commitment +/// to which specific input was rejected, not to anything execution produced. There is no +/// input-derived equivalent here that's worth committing on failure: any invalidity is a hard +/// Zig-error guest rejection (`exit(1)`, nothing written to `write_output`), so `encodeOutput` is +/// only ever reached after a full, successful `L2ExecutionProofPublicInput` already exists — a +/// `success` field on this type would be permanently `true` and couldn't mean anything. +/// `start_block_number` and the `l2_l1_messages`/`tx_froms`/`filtered_addresses` preimages on +/// `L2ExecutionProofOutput` are NOT part of this wire format; they exist for off-chain/native +/// tooling only. The plain 16-field +/// public-input tuple is never written to the wire either; it is only available via +/// `encodePublicInputsBytes`/`hashPublicInputs`, for logging or off-chain inspection. +pub fn encodeOutput(pi: L2ExecutionProofPublicInput) [OUTPUT_SIZE]u8 { + var out: [OUTPUT_SIZE]u8 = undefined; + std.mem.writeInt(u16, out[0..2], OUTPUT_SCHEMA_ID, .big); + @memcpy(out[SCHEMA_ID_SIZE..], &hashPublicInputs(pi)); + return out; +} diff --git a/riscv-guests/l2-execution/src/zesu_crypto_backend.zig b/riscv-guests/l2-execution/src/zesu_crypto_backend.zig new file mode 100644 index 00000000000..8efe07775b6 --- /dev/null +++ b/riscv-guests/l2-execution/src/zesu_crypto_backend.zig @@ -0,0 +1,11 @@ +//! Re-exports the functions this guest borrows directly from zesu's own native crypto backend +//! (`crypto/backends/*.zig`) — the ones with no C-library dependency, so they cross-compile to +//! riscv64 freestanding just like `zesu_zkvm_stdlibs`. Used by zkvm_provide.zig as the software +//! implementation for precompiles `zesu_zkvm_stdlibs` leaves stubbed (modexp, RIPEMD-160), until a +//! real Lineth accelerator wrapper exists for them. + +const modexp_impl = @import("zesu_modexp_impl"); +const ripemd160_impl = @import("zesu_ripemd160_impl"); + +pub const modexp = modexp_impl.modexp; +pub const ripemd160 = ripemd160_impl.ripemd160; diff --git a/riscv-guests/l2-execution/src/zkvm_provide.zig b/riscv-guests/l2-execution/src/zkvm_provide.zig index 5aea589203d..77a42762f2d 100644 --- a/riscv-guests/l2-execution/src/zkvm_provide.zig +++ b/riscv-guests/l2-execution/src/zkvm_provide.zig @@ -9,21 +9,29 @@ //! (keccak today). We re-export each wrapper under the C name zesu references; HOW a wrapper //! accelerates is the wrapper module's own concern. The *set of wrappers that exist* is what is //! accelerated, and grows as the prover implements more. -//! • zesu-zkvm `stdlibs_accel` (`zesu_zkvm_accel`) — every precompile without a wrapper yet, via a +//! • zesu-zkvm `stdlibs_accel` (`zesu_zkvm_stdlibs`) — every precompile without a wrapper yet, via a //! thin C-ABI shim (ptr+len → slice/array). Pure rv64im code; we don't maintain our own crypto. //! When a precompile gains a wrapper, move its line to the wrapper export below and delete its shim. +//! • zesu's own native crypto backend (`zesu_crypto_backend`) — for modexp/RIPEMD-160, whose +//! `zesu_zkvm_stdlibs` implementations are unconditional-failure stubs (see that module's doc +//! comment). These two have no C-library dependency, so — unlike the rest of zesu's native +//! backend — they cross-compile straight to riscv64 and give a functionally correct (if +//! unaccelerated) result instead of a guaranteed rejection. Swap for a real wrapper if/when one +//! lands, same as any other precompile above. //! //! Only the freestanding RISC-V guest references these (pulled in by evm_execution_guest.zig for //! `builtin.cpu.arch == .riscv64`); the native host build uses Zesu's C-backed crypto instead. -const zesu_accel = @import("zesu_zkvm_accel"); // zesu-zkvm's pure-Zig precompile backend (stdlibs_accel) +const zesu_zkvm_stdlibs = @import("zesu_zkvm_stdlibs"); // zesu-zkvm's pure-Zig precompile backend (stdlibs_accel) const lineth_accel = @import("lineth_zkvm_accel"); // Lineth accelerator wrappers (source paths wired in build.zig) const linea_io = @import("linea_zkvm_io"); // zesu-zkvm's zkvm_io: default (stdout ecall) write_output +const zesu_crypto_backend = @import("zesu_crypto_backend"); // zesu's own native crypto backend (modexp, RIPEMD-160 — see src/zesu_crypto_backend.zig) const build_options = @import("build_options"); // keccak_accel: standard zig keccak vs Lineth wrapper // The manifest: every `zkvm_*` symbol zesu references, and where each comes from — keccak is either // the Lineth wrapper (prover-accelerated) or the standard stdlibs_accel shim, selected at build time -// by -Dkeccak-accel; the rest come from the stdlibs_accel shims defined below. +// by -Dkeccak-accel; modexp/ripemd160 come from zesu_crypto_backend; the rest come from the +// stdlibs_accel shims defined below. comptime { if (build_options.keccak_accel) { @export(&lineth_accel.zkvm_keccak256, .{ .name = "zkvm_keccak256" }); @@ -48,6 +56,7 @@ comptime { @export(&bls12_map_fp_to_g1, .{ .name = "zkvm_bls12_map_fp_to_g1" }); @export(&bls12_map_fp2_to_g2, .{ .name = "zkvm_bls12_map_fp2_to_g2" }); @export(&secp256r1_verify, .{ .name = "zkvm_secp256r1_verify" }); + @export(&log, .{ .name = "zkvm_log" }); // write_output (zkvm-standards io-interface): the Lineth custom-opcode accelerator // when -Dwrite-output-accel is set, otherwise zesu's default stdout `write` ecall. // Both are the extern symbol `write_output` that zesu-zkvm's extern_io.zig resolves. @@ -82,64 +91,81 @@ const Bls12PairingPair = extern struct { g1: [96]u8, g2: [192]u8 }; // Standard zig keccak (std.crypto via stdlibs_accel); used unless -Dkeccak-accel selects the wrapper. fn keccak256(data: [*]const u8, len: usize, output: *[32]u8) callconv(.c) i32 { - zesu_accel.keccak256(data[0..len], output); + zesu_zkvm_stdlibs.keccak256(data[0..len], output); return OK; } fn sha256(data: [*]const u8, len: usize, output: *[32]u8) callconv(.c) i32 { - zesu_accel.sha256(data[0..len], output); + zesu_zkvm_stdlibs.sha256(data[0..len], output); return OK; } fn ripemd160(data: [*]const u8, len: usize, output: *[32]u8) callconv(.c) i32 { - zesu_accel.ripemd160(data[0..len], output); + const hash = zesu_crypto_backend.ripemd160(data[0..len]); + output.* = [_]u8{0} ** 32; + @memcpy(output[12..32], &hash); return OK; } fn secp256k1_ecrecover(msg: *const [32]u8, sig: *const [64]u8, recid: u8, output: *[64]u8) callconv(.c) i32 { - return if (zesu_accel.ecrecover(msg, sig, recid, output)) OK else ERR; + return if (zesu_zkvm_stdlibs.ecrecover(msg, sig, recid, output)) OK else ERR; } fn secp256k1_verify(msg: *const [32]u8, sig: *const [64]u8, pubkey: *const [64]u8, verified: *bool) callconv(.c) i32 { - zesu_accel.secp256k1_verify(msg, sig, pubkey, verified); + zesu_zkvm_stdlibs.secp256k1_verify(msg, sig, pubkey, verified); return OK; } fn secp256r1_verify(msg: *const [32]u8, sig: *const [64]u8, pubkey: *const [64]u8, verified: *bool) callconv(.c) i32 { - zesu_accel.secp256r1_verify(msg, sig, pubkey, verified); + zesu_zkvm_stdlibs.secp256r1_verify(msg, sig, pubkey, verified); return OK; } fn modexp(base: [*]const u8, base_len: usize, exp: [*]const u8, exp_len: usize, modulus: [*]const u8, mod_len: usize, output: [*]u8) callconv(.c) i32 { - return if (zesu_accel.modexp(base[0..base_len], exp[0..exp_len], modulus[0..mod_len], output[0..mod_len])) OK else ERR; + return if (zesu_crypto_backend.modexp(base[0..base_len], exp[0..exp_len], modulus[0..mod_len], output[0..mod_len])) OK else ERR; } fn bn254_g1_add(p1: *const [64]u8, p2: *const [64]u8, result: *[64]u8) callconv(.c) i32 { - return if (zesu_accel.bn254_g1_add(p1, p2, result)) OK else ERR; + return if (zesu_zkvm_stdlibs.bn254_g1_add(p1, p2, result)) OK else ERR; } fn bn254_g1_mul(point: *const [64]u8, scalar: *const [32]u8, result: *[64]u8) callconv(.c) i32 { - return if (zesu_accel.bn254_g1_mul(point, scalar, result)) OK else ERR; + return if (zesu_zkvm_stdlibs.bn254_g1_mul(point, scalar, result)) OK else ERR; } fn bn254_pairing(pairs: [*]const Bn254PairingPair, num_pairs: usize, verified: *bool) callconv(.c) i32 { - return if (zesu_accel.bn254_pairing(pairs[0..num_pairs], verified)) OK else ERR; + return if (zesu_zkvm_stdlibs.bn254_pairing(pairs[0..num_pairs], verified)) OK else ERR; } fn blake2f(rounds: u32, h: *[64]u8, m: *const [128]u8, t: *const [16]u8, f: u8) callconv(.c) i32 { - return if (zesu_accel.blake2f(rounds, h, m, t, f)) OK else ERR; + return if (zesu_zkvm_stdlibs.blake2f(rounds, h, m, t, f)) OK else ERR; } fn kzg_point_eval(commitment: *const [48]u8, z: *const [32]u8, y: *const [32]u8, proof: *const [48]u8, verified: *bool) callconv(.c) i32 { - return if (zesu_accel.kzg_point_eval(commitment, z, y, proof, verified)) OK else ERR; + return if (zesu_zkvm_stdlibs.kzg_point_eval(commitment, z, y, proof, verified)) OK else ERR; } fn bls12_g1_add(p1: *const [96]u8, p2: *const [96]u8, result: *[96]u8) callconv(.c) i32 { - return if (zesu_accel.bls12_g1_add(p1, p2, result)) OK else ERR; + return if (zesu_zkvm_stdlibs.bls12_g1_add(p1, p2, result)) OK else ERR; } fn bls12_g1_msm(pairs: [*]const Bls12G1MsmPair, num_pairs: usize, result: *[96]u8) callconv(.c) i32 { - return if (zesu_accel.bls12_g1_msm(pairs[0..num_pairs], result)) OK else ERR; + return if (zesu_zkvm_stdlibs.bls12_g1_msm(pairs[0..num_pairs], result)) OK else ERR; } fn bls12_g2_add(p1: *const [192]u8, p2: *const [192]u8, result: *[192]u8) callconv(.c) i32 { - return if (zesu_accel.bls12_g2_add(p1, p2, result)) OK else ERR; + return if (zesu_zkvm_stdlibs.bls12_g2_add(p1, p2, result)) OK else ERR; } fn bls12_g2_msm(pairs: [*]const Bls12G2MsmPair, num_pairs: usize, result: *[192]u8) callconv(.c) i32 { - return if (zesu_accel.bls12_g2_msm(pairs[0..num_pairs], result)) OK else ERR; + return if (zesu_zkvm_stdlibs.bls12_g2_msm(pairs[0..num_pairs], result)) OK else ERR; } fn bls12_pairing(pairs: [*]const Bls12PairingPair, num_pairs: usize, verified: *bool) callconv(.c) i32 { - return if (zesu_accel.bls12_pairing(pairs[0..num_pairs], verified)) OK else ERR; + return if (zesu_zkvm_stdlibs.bls12_pairing(pairs[0..num_pairs], verified)) OK else ERR; } fn bls12_map_fp_to_g1(field_element: *const [48]u8, result: *[96]u8) callconv(.c) i32 { - return if (zesu_accel.bls12_map_fp_to_g1(field_element, result)) OK else ERR; + return if (zesu_zkvm_stdlibs.bls12_map_fp_to_g1(field_element, result)) OK else ERR; } fn bls12_map_fp2_to_g2(field_element: *const [96]u8, result: *[192]u8) callconv(.c) i32 { - return if (zesu_accel.bls12_map_fp2_to_g2(field_element, result)) OK else ERR; + return if (zesu_zkvm_stdlibs.bls12_map_fp2_to_g2(field_element, result)) OK else ERR; +} + +// ── Runtime: zkvm_log ──────────────────────────────────────────────────────────────────────────── +// Not a precompile, but the same "statically-linked, define every extern locally" situation applies: +// zesu's own root module (zesu/src/zkvm/root.zig) declares `extern fn zkvm_log(level, msg_ptr, +// msg_len)`, and zesu-zkvm's reference implementation for THIS exact backend (linea_host.zig) +// forwards it to `io.printStr(msg)` — the same Linux write ecall to fd=1 that `linea_zkvm_io`'s +// `write_output` uses (see evm_execution_guest.zig's `guestMain`). The Linea zkVM captures ALL +// stdout bytes as the program's single observable output, so a real call here would interleave with +// (and corrupt) the guest's actual `write_output` commit. NO-OP for now; re-enable once ZkC exposes +// logging that doesn't alias the output commitment. +fn log(level: u8, msg_ptr: [*]const u8, msg_len: usize) callconv(.c) void { + _ = level; + _ = msg_ptr; + _ = msg_len; } diff --git a/riscv-guests/l2-execution/test/evm_execution_guest_test.zig b/riscv-guests/l2-execution/test/evm_execution_guest_test.zig index e0b1bcf345f..dc94a8a6ad0 100644 --- a/riscv-guests/l2-execution/test/evm_execution_guest_test.zig +++ b/riscv-guests/l2-execution/test/evm_execution_guest_test.zig @@ -2,21 +2,38 @@ const std = @import("std"); const fixtures = @import("evm_execution_fixtures"); const guest = @import("evm_execution_guest"); +const executor = @import("zesu_executor"); +const ssz_decode = @import("zesu_ssz_decode"); +const zesu_allocator = @import("zesu_allocator"); +const mpt = @import("zesu_mpt"); -// Runs the thin wrapper (vanilla zesu stateless execution) on a real execution-spec-tests zkevm SSZ -// fixture and asserts the serialized validation result matches the fixture's expected output — -// exactly what zesu's own zkevm-blockchain-test-runner checks, end to end, on the native host. -test "guest runs a vanilla zesu stateless block (SSZ) and matches the expected validation result" { +// Proves the log-preserving seam (guest.execution.executeStatelessInputWithLogs, src/execution.zig) +// computes the SAME pre/post/receipts roots as zesu's vanilla executor.executeStatelessInput on the +// same fixture — i.e. adding the log-preserving path does not change validation outcomes. The +// committed fixture is an empty block, so there are no logs to preserve; this only proves parity of +// the roots and that the logs slice is (trivially) empty. +test "executeStatelessInputWithLogs matches vanilla executeStatelessInput's roots" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); const fixture = try fixtures.loadStatelessBlock(allocator, fixtures.embedded.zkevm_stateless_block); - try std.testing.expectEqualStrings("Amsterdam", fixture.network); - try std.testing.expectEqual(@as(usize, 105), fixture.expected_output.len); + const si = try ssz_decode.decode(allocator, fixture.input); - const result = try guest.runStateless(allocator, fixture.input); + // executeStatelessInput (unlike executeStatelessInputWithLogs) relies on the zesu_allocator + // singleton being set by the caller. + zesu_allocator.set(allocator); + const vanilla = try executor.executeStatelessInput(allocator, si, si.chain_config.fork_name); - try std.testing.expect(result.success); - try std.testing.expectEqualSlices(u8, fixture.expected_output, &result.out); + var node_index = try mpt.buildNodeIndex(allocator, si.witness.nodes); + defer node_index.deinit(); + const with_logs = try guest.execution.executeStatelessInputWithLogs(allocator, si, si.chain_config.fork_name.?, &node_index); + + try std.testing.expectEqualSlices(u8, &vanilla.pre_state_root, &with_logs.pre_state_root); + try std.testing.expectEqualSlices(u8, &vanilla.post_state_root, &with_logs.post_state_root); + try std.testing.expectEqualSlices(u8, &vanilla.receipts_root, &with_logs.receipts_root); + try std.testing.expectEqual(vanilla.receipts.len, with_logs.receipts.len); + for (with_logs.receipts) |receipt| { + try std.testing.expectEqual(@as(usize, 0), receipt.logs.len); + } } diff --git a/riscv-guests/l2-execution/test/evm_spec_runner.zig b/riscv-guests/l2-execution/test/evm_spec_runner.zig deleted file mode 100644 index 458e8a6a644..00000000000 --- a/riscv-guests/l2-execution/test/evm_spec_runner.zig +++ /dev/null @@ -1,145 +0,0 @@ -//! `evm-execution-spec-runner` — runs the **vanilla** l2-execution guest against the EF -//! execution-spec-tests zkevm stateless fixtures. -//! -//! Wired by build.zig's `spec-tests` step, which passes the lazy `execution_spec_tests_zkevm` -//! dependency's `blockchain_tests/` directory as `--fixtures`. All the corpus walking / parsing / -//! reporting lives in the guest-agnostic `spec_runner.zig`; this file only supplies the vanilla -//! `Adapter` and the CLI. A future extended guest adds its own adapter + entry and reuses -//! `spec_runner.zig` unchanged. - -const std = @import("std"); -const spec_runner = @import("spec_runner.zig"); -const guest = @import("evm_execution_guest"); - -/// Vanilla adapter: the guest consumes the EF `SszStatelessInput` verbatim (identity), and the -/// expected result is the fixture's 105-byte `SszStatelessValidationResult`. An extended guest -/// would instead decode the StatelessInput, wrap it with its extra fields, and re-encode in -/// `adaptInput` — and may compute its own expectation in `runAndCheck`. -const VanillaAdapter = struct { - pub const label = "vanilla l2-execution"; - - pub fn adaptInput( - alloc: std.mem.Allocator, - ssz_stateless_input: []const u8, - ctx: spec_runner.BlockContext, - ) ![]const u8 { - _ = alloc; - _ = ctx; - return ssz_stateless_input; // identity — the vanilla guest's input IS the EF StatelessInput - } - - pub fn runAndCheck( - alloc: std.mem.Allocator, - guest_input: []const u8, - expected_output: []const u8, - ctx: spec_runner.BlockContext, - ) !bool { - const result = guest.runStateless(alloc, guest_input) catch |err| { - std.debug.print("FAIL {s}[{}] guest error: {s}\n", .{ ctx.test_name, ctx.block_index, @errorName(err) }); - return false; - }; - - if (expected_output.len != result.out.len) { - std.debug.print( - "FAIL {s}[{}] expected {} output bytes, guest produced {}\n", - .{ ctx.test_name, ctx.block_index, expected_output.len, result.out.len }, - ); - return false; - } - if (std.mem.eql(u8, &result.out, expected_output)) return true; - - // Byte 32 of the SSZ result is the successful_validation flag — surface valid/invalid - // disagreements specially, since those are the most common and most informative. - const got_valid = result.out[32] == 0x01; - const exp_valid = expected_output[32] == 0x01; - if (got_valid != exp_valid) { - std.debug.print("FAIL {s}[{}] expected {s}, guest said {s}\n", .{ - ctx.test_name, - ctx.block_index, - if (exp_valid) "valid" else "invalid", - if (got_valid) "valid" else "invalid", - }); - } else { - var exp_arr: @TypeOf(result.out) = undefined; // same fixed size as the guest result - @memcpy(&exp_arr, expected_output); - const got_hex = std.fmt.bytesToHex(result.out, .lower); - const exp_hex = std.fmt.bytesToHex(exp_arr, .lower); - std.debug.print( - "FAIL {s}[{}] output mismatch\n got: 0x{s}\n expected: 0x{s}\n", - .{ ctx.test_name, ctx.block_index, &got_hex, &exp_hex }, - ); - } - return false; - } -}; - -const usage = - \\evm-execution-spec-runner — run the vanilla l2-execution guest against EF zkevm stateless fixtures. - \\ - \\usage: evm-execution-spec-runner [--fixtures DIR] [--file FILE] [--fork NAME] [--limit N] [-x] [--report-only] - \\ --fixtures DIR directory of blockchain_tests JSON fixtures (passed by `zig build spec-tests`) - \\ --file FILE run a single fixture file instead of the whole directory - \\ --fork NAME only run test cases whose network == NAME (case-insensitive), e.g. Amsterdam - \\ --match SUBSTR only run fixture files whose path contains SUBSTR, e.g. block_access_lists - \\ --limit N stop after N blocks (dev speed) - \\ -x stop on the first failing block - \\ --report-only print the summary but always exit 0 (otherwise: exit 1 if any block fails) - \\ -; - -pub fn main(init: std.process.Init) !void { - const gpa = init.gpa; - const args = try init.minimal.args.toSlice(init.arena.allocator()); - - var opts = spec_runner.Options{ .fixtures_dir = "spec-tests/fixtures/zkevm/blockchain_tests" }; - var report_only = false; - - var i: usize = 1; - while (i < args.len) : (i += 1) { - const arg = args[i]; - if (std.mem.eql(u8, arg, "--fixtures") and i + 1 < args.len) { - i += 1; - opts.fixtures_dir = args[i]; - } else if (std.mem.eql(u8, arg, "--file") and i + 1 < args.len) { - i += 1; - opts.single_file = args[i]; - } else if (std.mem.eql(u8, arg, "--fork") and i + 1 < args.len) { - i += 1; - opts.fork_filter = args[i]; - } else if (std.mem.eql(u8, arg, "--match") and i + 1 < args.len) { - i += 1; - opts.path_match = args[i]; - } else if (std.mem.eql(u8, arg, "--limit") and i + 1 < args.len) { - i += 1; - opts.limit = std.fmt.parseInt(u64, args[i], 10) catch { - std.debug.print("error: --limit expects an integer, got '{s}'\n", .{args[i]}); - std.process.exit(2); - }; - } else if (std.mem.eql(u8, arg, "-x")) { - opts.stop_on_fail = true; - } else if (std.mem.eql(u8, arg, "--report-only")) { - report_only = true; - } else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { - std.debug.print("{s}", .{usage}); - return; - } else { - std.debug.print("error: unexpected argument '{s}'\n{s}", .{ arg, usage }); - std.process.exit(2); - } - } - - std.debug.print("running {s} guest over {s}\n", .{ VanillaAdapter.label, opts.single_file orelse opts.fixtures_dir }); - - const stats = try spec_runner.run(VanillaAdapter, init.io, gpa, opts); - - const total = stats.total(); - const pct: u64 = if (total > 0) 100 * stats.passed / total else 0; - std.debug.print("\n============================================================\n", .{}); - std.debug.print(" {s}\n", .{VanillaAdapter.label}); - std.debug.print(" files: {} blocks: {} passed: {} failed: {} ({}%)\n", .{ - stats.files, stats.blocks, stats.passed, stats.failed, pct, - }); - std.debug.print("============================================================\n", .{}); - - if (stats.failed > 0 and !report_only) std.process.exit(1); -} diff --git a/riscv-guests/l2-execution/test/extended_vanilla_runner.zig b/riscv-guests/l2-execution/test/extended_vanilla_runner.zig new file mode 100644 index 00000000000..1e3bdcbff0c --- /dev/null +++ b/riscv-guests/l2-execution/test/extended_vanilla_runner.zig @@ -0,0 +1,206 @@ +//! `extended-vanilla-runner` — reference-test guard: the extended guest, run through the dummy-fill +//! wrap (`vanilla_wrap.wrapVanillaAsExtended`), must agree with the EF fixture's OWN expected +//! validity verdict (`successful_validation`, byte 32 of the vanilla `SszStatelessValidationResult` +//! — see zesu's `ssz_output.zig`) over the real EF zkevm corpus. This checks that same property +//! cheaply on the host, instead of compiling to riscv64 and executing the real guest ELF via ZkC. +//! +//! Deliberately NOT a differential check against a second, independently-run implementation (e.g. +//! re-running zesu's own `executor.executeStatelessInput` inline): a reference-test corpus's whole +//! point is to BE the source of truth, so this checks the extended guest against the fixture's own +//! expected result directly — that also catches a bug shared by both the extended and vanilla +//! paths (they delegate to the same zesu executor), which a vanilla-vs-extended differential check +//! never could, since both sides would agree while still being wrong. +//! +//! The allowed disagreements are `error.ExecutionRequestsNotSupported` (EF fixtures carrying +//! EIP-7685 requests) and `error.WithdrawalsNotSupported` (EF fixtures carrying beacon-chain +//! withdrawals) — both valid to vanilla Ethereum but rejected by Lineth policy. Any other +//! disagreement fails the run. +//! +//! Reuses `spec_runner.zig`'s fixture walk; this file supplies only the `Adapter`, CLI, and +//! histogram. Run via `zig build extended-vanilla`. + +const std = @import("std"); +const spec_runner = @import("spec_runner.zig"); +const l2_execution = @import("l2_execution"); +const l2_execution_ssz = @import("l2_execution_ssz"); +const vanilla_wrap = @import("vanilla_wrap"); + +/// Error-name -> occurrence count, across every disagreement where the extended pipeline errored. +/// File-scope: the comptime `Adapter` contract has no room for extra per-run state, and this tool +/// runs its whole walk from a single `main()` invocation, so there is only ever one "session". +var error_histogram: std.StringHashMap(u64) = undefined; + +fn recordError(name: []const u8) void { + const entry = error_histogram.getOrPut(name) catch return; + if (!entry.found_existing) entry.value_ptr.* = 0; + entry.value_ptr.* += 1; +} + +const ExtendedVanillaAdapter = struct { + pub const label = "extended guest vs EF fixture ground truth (dummy-wrapped l2_execution.runL2Execution vs the fixture's own successful_validation)"; + + /// Skip any fixture exercising EIP-8025's fork-activation schedule (a populated + /// chain_config.activation_block/activation_timestamp): this guest is single-fork and fixed + /// (see vanilla_wrap.vanillaHasForkActivationSchedule's doc comment for why). Decode failure + /// belongs to `adaptInput`/`runAndCheck`'s coarse invalid-result handling — the malformed-SSZ + /// negative-test case — so it flows through there unchanged. + pub fn shouldSkip( + alloc: std.mem.Allocator, + ssz_stateless_input: []const u8, + ctx: spec_runner.BlockContext, + ) bool { + _ = ctx; + return vanilla_wrap.vanillaHasForkActivationSchedule(alloc, ssz_stateless_input) catch false; + } + + pub fn adaptInput( + alloc: std.mem.Allocator, + ssz_stateless_input: []const u8, + ctx: spec_runner.BlockContext, + ) ?[]const u8 { + _ = ctx; + return vanilla_wrap.wrapVanillaAsExtended(alloc, ssz_stateless_input) catch null; + } + + pub fn runAndCheck( + alloc: std.mem.Allocator, + guest_input: ?[]const u8, + expected_output: []const u8, + ctx: spec_runner.BlockContext, + ) !bool { + // Ground truth: the fixture's OWN expected result, not a second, independently-run + // implementation (see the file header comment for why). + if (expected_output.len <= 32) { + std.debug.print("FAIL {s}[{}] expected_output too short ({} bytes)\n", .{ ctx.test_name, ctx.block_index, expected_output.len }); + return false; + } + const expected_valid = expected_output[32] == 0x01; + var extended_err_name: []const u8 = ""; + const extended_valid = blk: { + const gi = guest_input orelse { + extended_err_name = "AdaptInputFailed"; + break :blk false; + }; + const extended_in = l2_execution_ssz.decodeInput(alloc, gi) catch |err| { + extended_err_name = @errorName(err); + break :blk false; + }; + // The vanilla bytes are carried verbatim as the single payload's stateless_input_ssz. + std.debug.assert(extended_in.payloads.len == 1); + _ = l2_execution.runL2Execution(alloc, extended_in) catch |err| { + extended_err_name = @errorName(err); + break :blk false; + }; + break :blk true; + }; + + if (extended_valid == expected_valid) return true; + + // The allowed disagreements (see the file header comment): a fixture-valid block the + // extended guest rejects for a Lineth-policy reason (EIP-7685 requests or withdrawals). + if (!extended_valid and expected_valid and + (std.mem.eql(u8, extended_err_name, "ExecutionRequestsNotSupported") or + std.mem.eql(u8, extended_err_name, "WithdrawalsNotSupported"))) + { + return true; + } + + if (extended_valid) { + std.debug.print( + "FAIL {s}[{}] disagree: fixture=invalid extended=valid\n", + .{ ctx.test_name, ctx.block_index }, + ); + } else { + recordError(extended_err_name); + std.debug.print( + "FAIL {s}[{}] disagree: fixture=valid extended=invalid ({s})\n", + .{ ctx.test_name, ctx.block_index, extended_err_name }, + ); + } + return false; + } +}; + +const usage = + \\extended-vanilla-runner — reference-test guard: assert the dummy-wrapped extended l2-execution guest + \\(l2_execution.runL2Execution) agrees with the EF fixture's own expected validity verdict, over + \\EF zkevm stateless fixtures. The only allowed disagreement is a fixture-valid block whose + \\EIP-7685 execution requests the extended guest rejects by Lineth policy. + \\ + \\usage: extended-vanilla-runner [--fixtures DIR] [--file FILE] [--fork NAME] [--match SUBSTR] [--limit N] [-x] [--report-only] + \\ --fixtures DIR directory of blockchain_tests JSON fixtures (passed by `zig build extended-vanilla`) + \\ --file FILE run a single fixture file instead of the whole directory + \\ --fork NAME only run test cases whose network == NAME (case-insensitive), e.g. Amsterdam + \\ --match SUBSTR only run fixture files whose path contains SUBSTR, e.g. eip7928_block_level_access_lists + \\ --limit N stop after N blocks (dev speed) + \\ -x stop on the first disagreeing block + \\ --report-only print the summary but always exit 0 (otherwise: exit 1 if any block disagrees) + \\ +; + +pub fn main(init: std.process.Init) !void { + const gpa = init.gpa; + const args = try init.minimal.args.toSlice(init.arena.allocator()); + + error_histogram = std.StringHashMap(u64).init(gpa); + defer error_histogram.deinit(); + + var opts = spec_runner.Options{ .fixtures_dir = "spec-tests/fixtures/zkevm/blockchain_tests" }; + var report_only = false; + + var i: usize = 1; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (std.mem.eql(u8, arg, "--fixtures") and i + 1 < args.len) { + i += 1; + opts.fixtures_dir = args[i]; + } else if (std.mem.eql(u8, arg, "--file") and i + 1 < args.len) { + i += 1; + opts.single_file = args[i]; + } else if (std.mem.eql(u8, arg, "--fork") and i + 1 < args.len) { + i += 1; + opts.fork_filter = args[i]; + } else if (std.mem.eql(u8, arg, "--match") and i + 1 < args.len) { + i += 1; + opts.path_match = args[i]; + } else if (std.mem.eql(u8, arg, "--limit") and i + 1 < args.len) { + i += 1; + opts.limit = std.fmt.parseInt(u64, args[i], 10) catch { + std.debug.print("error: --limit expects an integer, got '{s}'\n", .{args[i]}); + std.process.exit(2); + }; + } else if (std.mem.eql(u8, arg, "-x")) { + opts.stop_on_fail = true; + } else if (std.mem.eql(u8, arg, "--report-only")) { + report_only = true; + } else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { + std.debug.print("{s}", .{usage}); + return; + } else { + std.debug.print("error: unexpected argument '{s}'\n{s}", .{ arg, usage }); + std.process.exit(2); + } + } + + std.debug.print("running {s}\n over {s}\n", .{ ExtendedVanillaAdapter.label, opts.single_file orelse opts.fixtures_dir }); + + const stats = try spec_runner.run(ExtendedVanillaAdapter, init.io, gpa, opts); + + const total = stats.total(); + const pct: u64 = if (total > 0) 100 * stats.passed / total else 0; + std.debug.print("\n============================================================\n", .{}); + std.debug.print(" {s}\n", .{ExtendedVanillaAdapter.label}); + std.debug.print(" files: {} blocks: {} agree: {} disagree: {} skipped: {} ({}%)\n", .{ + stats.files, stats.blocks, stats.passed, stats.failed, stats.skipped, pct, + }); + if (error_histogram.count() > 0) { + std.debug.print(" disagreement error histogram (extended pipeline's error, when it errored):\n", .{}); + var it = error_histogram.iterator(); + while (it.next()) |entry| { + std.debug.print(" {s}: {}\n", .{ entry.key_ptr.*, entry.value_ptr.* }); + } + } + std.debug.print("============================================================\n", .{}); + + if (stats.failed > 0 and !report_only) std.process.exit(1); +} diff --git a/riscv-guests/l2-execution/test/spec_runner.zig b/riscv-guests/l2-execution/test/spec_runner.zig index 591a7e87353..fca50f95847 100644 --- a/riscv-guests/l2-execution/test/spec_runner.zig +++ b/riscv-guests/l2-execution/test/spec_runner.zig @@ -10,17 +10,28 @@ //! Everything here (dir walk, JSON parse, per-block extraction, fork filter, reporting) is //! guest-agnostic. The *only* guest-specific piece is the comptime `Adapter`, which adapts the //! fixture's vanilla SSZ `StatelessInput` to whatever shape a given guest consumes and then runs -//! and checks it. The vanilla guest's adapter is identity; a future extended guest (see -//! rollup_spec — extra `forced_transactions`/rollup fields) supplies an adapter whose -//! `adaptInput` wraps + re-encodes the input, and reuses this file unchanged. +//! and checks it. `extended_vanilla_runner.zig`'s `ExtendedVanillaAdapter` (the sole consumer of +//! this file) wraps the vanilla input into the extended `L2ExecutionProofPrivateInput` shape +//! (dummy-filled rollup fields — see `vanilla_wrap.zig`) and checks validity against the fixture's +//! own expected output. //! //! Adapter contract (comptime duck-typed): //! pub const label: []const u8 -//! /// Transform the fixture's SSZ StatelessInput into this guest's input bytes. -//! pub fn adaptInput(alloc: std.mem.Allocator, ssz_stateless_input: []const u8, ctx: BlockContext) ![]const u8 -//! /// Run the guest on the adapted input and compare against the fixture's expected output. -//! /// Returns true on pass; on failure prints a one-line `FAIL …` diagnostic and returns false. -//! pub fn runAndCheck(alloc: std.mem.Allocator, guest_input: []const u8, expected_output: []const u8, ctx: BlockContext) !bool +//! /// Transform the fixture's SSZ StatelessInput into this guest's input bytes; null if +//! /// adaptation fails. A null is handed to `runAndCheck` as a coarse "this block is invalid" +//! /// signal, the same way any other guest-pipeline rejection is handled. This matches the +//! /// granularity a real batch proof (`l2_execution.runL2Execution` over a payload range) reports: +//! /// one pass/fail verdict for the whole range. Malformed SSZ the EF corpus deliberately feeds a +//! /// block, expecting rejection, is exactly this case. +//! pub fn adaptInput(alloc: std.mem.Allocator, ssz_stateless_input: []const u8, ctx: BlockContext) ?[]const u8 +//! /// Run the guest on the adapted input (null if adaptation failed) and compare against the +//! /// fixture's expected output. Returns true on pass; on failure prints a one-line `FAIL …` +//! /// diagnostic and returns false. +//! pub fn runAndCheck(alloc: std.mem.Allocator, guest_input: ?[]const u8, expected_output: []const u8, ctx: BlockContext) !bool +//! /// True if this block exercises a property belonging to the vanilla reference guest's +//! /// multi-fork/schedule model rather than this guest's own fixed-fork design. Skipped blocks +//! /// are excluded entirely from the pass/fail tally. +//! pub fn shouldSkip(alloc: std.mem.Allocator, ssz_stateless_input: []const u8, ctx: BlockContext) bool const std = @import("std"); const zkevm_fixture = @import("zkevm_fixture.zig"); @@ -46,6 +57,7 @@ pub const Stats = struct { blocks: u64 = 0, passed: u64 = 0, failed: u64 = 0, + skipped: u64 = 0, pub fn total(self: Stats) u64 { return self.passed + self.failed; @@ -126,7 +138,7 @@ fn processFile( // A fixture we can't read or parse is a failure, not a silent skip: counting it keeps a // systemic regression (e.g. parseBlocks breaking across the whole corpus) from passing green. - const text = std.Io.Dir.cwd().readFileAlloc(io, path, alloc, .limited(256 * 1024 * 1024)) catch |err| { + const text = std.Io.Dir.cwd().readFileAlloc(io, path, alloc, .limited(1 << 30)) catch |err| { std.debug.print("FAIL cannot read '{s}': {}\n", .{ path, err }); stats.failed += 1; return; @@ -155,11 +167,12 @@ fn processFile( }; stats.blocks += 1; - const guest_input = Adapter.adaptInput(alloc, block.input, ctx) catch |err| { - std.debug.print("FAIL {s}[{}] adaptInput error: {s}\n", .{ ctx.test_name, ctx.block_index, @errorName(err) }); - stats.failed += 1; + if (Adapter.shouldSkip(alloc, block.input, ctx)) { + stats.skipped += 1; continue; - }; + } + + const guest_input = Adapter.adaptInput(alloc, block.input, ctx); const ok = Adapter.runAndCheck(alloc, guest_input, block.expected_output, ctx) catch |err| blk: { std.debug.print("FAIL {s}[{}] runAndCheck error: {s}\n", .{ ctx.test_name, ctx.block_index, @errorName(err) }); break :blk false; diff --git a/riscv-guests/l2-execution/test/stdlibs_accel_test.zig b/riscv-guests/l2-execution/test/stdlibs_accel_test.zig index 22aac081c3d..d19a7fa2126 100644 --- a/riscv-guests/l2-execution/test/stdlibs_accel_test.zig +++ b/riscv-guests/l2-execution/test/stdlibs_accel_test.zig @@ -1,12 +1,12 @@ //! Integration smoke test for the delegated precompiles (zesu-zkvm `stdlibs_accel`, imported as -//! `zesu_zkvm_accel`). The guest's in-guest precompiles delegate to this module via zkvm_provide.zig; +//! `zesu_zkvm_stdlibs`). The guest's in-guest precompiles delegate to this module via zkvm_provide.zig; //! correctness of the implementation is upstream's responsibility, but this guards the pinned //! dependency version + our import wiring by round-tripping its secp256k1 ecrecover on the host. //! //! std + the dependency only — no fixtures, no native crypto libs. const std = @import("std"); -const accel = @import("zesu_zkvm_accel"); +const stdlibs = @import("zesu_zkvm_stdlibs"); const Secp256k1 = std.crypto.ecc.Secp256k1; const Scalar = Secp256k1.scalar.Scalar; @@ -56,7 +56,7 @@ test "delegated ecrecover round-trips signatures back to the signing key" { const zb = z.toBytes(.big); var out: [64]u8 = undefined; - try std.testing.expect(accel.ecrecover(&zb, &found.?.sig, found.?.recid, &out)); + try std.testing.expect(stdlibs.ecrecover(&zb, &found.?.sig, found.?.recid, &out)); try std.testing.expectEqualSlices(u8, &expected, &out); } } @@ -66,11 +66,11 @@ test "delegated ecrecover rejects malformed signatures" { var out: [64]u8 = undefined; // r = 0 and s = 0 are invalid. - try std.testing.expect(!accel.ecrecover(&z, &([_]u8{0} ** 64), 0, &out)); + try std.testing.expect(!stdlibs.ecrecover(&z, &([_]u8{0} ** 64), 0, &out)); // r ≥ n (all 0xFF) is non-canonical. var sig_bad_r: [64]u8 = undefined; sig_bad_r[0..32].* = [_]u8{0xFF} ** 32; sig_bad_r[32..].* = scalarFromU64(9).toBytes(.big); - try std.testing.expect(!accel.ecrecover(&z, &sig_bad_r, 0, &out)); + try std.testing.expect(!stdlibs.ecrecover(&z, &sig_bad_r, 0, &out)); } diff --git a/riscv-guests/l2-execution/test/testdata/stateless_input.ssz b/riscv-guests/l2-execution/test/testdata/stateless_input.ssz index 585cae6e194..77c6a863b53 100644 Binary files a/riscv-guests/l2-execution/test/testdata/stateless_input.ssz and b/riscv-guests/l2-execution/test/testdata/stateless_input.ssz differ diff --git a/riscv-guests/l2-execution/test/vanilla_wrap.zig b/riscv-guests/l2-execution/test/vanilla_wrap.zig new file mode 100644 index 00000000000..e63a751678a --- /dev/null +++ b/riscv-guests/l2-execution/test/vanilla_wrap.zig @@ -0,0 +1,103 @@ +//! Shared "dummy-fill" bridge: wraps a VANILLA EF stateless input (schema 0x0001) into an extended +//! `L2ExecutionProofPrivateInput` (schema 0x0002) so the extended l2-execution guest can run on the +//! same corpus the vanilla guest runs on (the EF execution-spec-tests zkevm fixtures). +//! +//! Real wiring: `test/extended_vanilla_runner.zig` calls this in-process (host reference-test +//! guard). +//! +//! Dummy-fill choices (see `runL2Execution`'s conflation invariants in l2_execution.zig): +//! - `payloads`: a single payload whose `stateless_input_ssz` is the vanilla bytes VERBATIM +//! (zero-copy) and `forced_transactions` empty — a single payload trivially satisfies the +//! parentHash-chaining / baseFee-constant checks (there's only one block in the "range"). +//! - `chain_config.chain_id`: copied from the vanilla input's own `chain_config.chain_id`, so the +//! `ChainIdMismatch` check (which compares the payload's chain_id against this field) passes. +//! - `chain_config.coinbase`: copied from the vanilla payload's `fee_recipient`, so the +//! `FeeRecipientMismatch` check passes. +//! - `chain_config.l2_message_service_address`: `DUMMY_L2_MESSAGE_SERVICE_ADDRESS` below, the zero +//! address. This is not an arbitrary placeholder: it's the sentinel `runL2Execution` recognizes +//! as "no L2MessageService configured" and responds to by suppressing the L1<->L2 bridge reads +//! entirely (both boundary rolling hashes and message numbers pinned to zero, no L2->L1 message +//! extraction) rather than attempting a witness-backed read that EF fixtures — which carry no +//! L2MessageService account and no post-state witness coverage — cannot satisfy. See +//! `l2_execution.zig`'s `bridge_suppressed` handling and its Python mirror in +//! `rollup_spec/l2_execution.py`. +//! - `parent_ftx_rolling_hash` / `parent_last_processed_ftx_number`: zero — there is no prior +//! range, so both start at their genesis values. + +const std = @import("std"); + +const ssz_decode = @import("zesu_ssz_decode"); +const l2_execution_ssz = @import("l2_execution_ssz"); + +/// The dummy L2MessageService address: the zero address, `runL2Execution`'s sentinel for "no +/// L2MessageService configured" (see the file-level doc comment above). Kept as a single named +/// constant purely for readability at call sites. +pub const DUMMY_L2_MESSAGE_SERVICE_ADDRESS: [20]u8 = @splat(0); + +/// Wrap a vanilla EF stateless input (raw SSZ `SszStatelessInput` bytes, schema 0x0001 framing as +/// produced by the EF fixtures) into an extended `L2ExecutionProofPrivateInput` (schema 0x0002), +/// re-encoded and ready to feed to `l2_execution_ssz.decodeInput` / `l2_execution.runL2Execution`. +/// +/// The vanilla bytes are carried through verbatim as the single payload's `stateless_input_ssz` — +/// this function never re-encodes or mutates them, only reads `chain_id`/`fee_recipient` off the +/// decoded form to fill the extended envelope's `chain_config`. +pub fn wrapVanillaAsExtended(alloc: std.mem.Allocator, vanilla_stateless_input_ssz: []const u8) ![]u8 { + const si = try ssz_decode.decode(alloc, vanilla_stateless_input_ssz); + const fee_recipient = si.new_payload_request.execution_payload.fee_recipient; + + const payloads = try alloc.alloc(l2_execution_ssz.LineaPayloadInput, 1); + payloads[0] = .{ + .stateless_input_ssz = vanilla_stateless_input_ssz, + .forced_transactions = &.{}, + }; + + const extended_input = l2_execution_ssz.L2ExecutionProofPrivateInput{ + .parent_ftx_rolling_hash = @splat(0), + .parent_last_processed_ftx_number = 0, + .chain_config = .{ + .chain_id = si.chain_config.chain_id, + .coinbase = fee_recipient, + .l2_message_service_address = DUMMY_L2_MESSAGE_SERVICE_ADDRESS, + }, + .payloads = payloads, + }; + + return l2_execution_ssz.encodeInput(alloc, extended_input); +} + +/// True when the vanilla stateless input declares any EIP-7685 execution request +/// (deposits / withdrawals / consolidations). The extended guest rejects these outright by Linea +/// policy (`error.ExecutionRequestsNotSupported`), so a harness feeding real EF fixtures should +/// SKIP such inputs rather than hand the prover a guaranteed-reject block. Kept here — the one place +/// that already SSZ-decodes the vanilla input — so the Go harness need not learn the SSZ layout. +pub fn vanillaHasExecutionRequests(alloc: std.mem.Allocator, vanilla_stateless_input_ssz: []const u8) !bool { + const si = try ssz_decode.decode(alloc, vanilla_stateless_input_ssz); + const r = si.new_payload_request.execution_requests; + return r.deposits.len != 0 or r.withdrawals.len != 0 or r.consolidations.len != 0; +} + +/// True when EIP-8025's fork-activation schedule mechanism, applied the way zesu's vanilla +/// `executeStatelessInput` enforces it, finds this block's declared active fork still pending: +/// `chain_config.activation_block`/`activation_timestamp` is either unset (zesu's own preamble +/// treats an unset pair as malformed too) or set to a point that postdates the block itself. This +/// mirrors zesu's `ChainConfigInvalid` comparison exactly, evaluated against the block's own +/// values — a presence-only check would misfire, since the EF corpus already populates a +/// trivially-satisfied `activation_timestamp = 0` (Amsterdam active from genesis) on every normal +/// block. +/// +/// `runL2Execution`/`execution.executeStatelessInputWithLogs` is a single, fixed-fork guest +/// (GUEST_FORK in l2_execution.zig, always Amsterdam), validated through the +/// `chain_config.fork_name` equality check alone. Linea's own encoding +/// (rollup_spec/stateless_input.py's `_ssz_chain_config_from_obj`) leaves these two fields empty +/// for real input. A harness feeding real EF fixtures should SKIP the rare fixture whose block +/// postdates its own declared activation point, keeping the comparison scoped to what this guest +/// implements. +pub fn vanillaHasForkActivationSchedule(alloc: std.mem.Allocator, vanilla_stateless_input_ssz: []const u8) !bool { + const si = try ssz_decode.decode(alloc, vanilla_stateless_input_ssz); + const cc = si.chain_config; + const ep = &si.new_payload_request.execution_payload; + if (cc.activation_block == null and cc.activation_timestamp == null) return true; + if (cc.activation_block) |b| if (ep.block_number < b) return true; + if (cc.activation_timestamp) |t| if (ep.timestamp < t) return true; + return false; +} diff --git a/rollup_spec/src/rollup_spec/l1_rollup.py b/rollup_spec/src/rollup_spec/l1_rollup.py index 64d3ed8c37e..e611289fdd1 100644 --- a/rollup_spec/src/rollup_spec/l1_rollup.py +++ b/rollup_spec/src/rollup_spec/l1_rollup.py @@ -5,7 +5,7 @@ from ethereum.state import Address from ethereum_types.numeric import U64 -from .l2_execution import hash_address_list, hash_hash_list +from .l2_execution import hash_address_list, hash_digest_list from .rollup import L2_L1_TREE_DEPTH, DataRollingHashWitness, RollupPublicInput @@ -188,7 +188,7 @@ def finalize_rollup( pi.end_processed_ftx_number, ) - if hash_hash_list(submission.l2_l1_roots) != pi.l2_l1_bridge_transaction_tree: + if hash_digest_list(submission.l2_l1_roots) != pi.l2_l1_bridge_transaction_tree: raise Exception("submitted L2-to-L1 roots do not match public input") for root in submission.l2_l1_roots: state.l2_merkle_roots_depths[root] = L2_L1_TREE_DEPTH diff --git a/rollup_spec/src/rollup_spec/l2_execution.py b/rollup_spec/src/rollup_spec/l2_execution.py index 2c66d468dca..f2b00f6a936 100644 --- a/rollup_spec/src/rollup_spec/l2_execution.py +++ b/rollup_spec/src/rollup_spec/l2_execution.py @@ -47,6 +47,15 @@ LAST_ANCHORED_L1_MESSAGE_NUMBER_SLOT: Bytes32 = Bytes32(int(280).to_bytes(32, "big")) L1_ROLLING_HASHES_MAPPING_BASE_SLOT: Bytes32 = Bytes32(int(281).to_bytes(32, "big")) +# Sentinel meaning "no L2MessageService configured" (see `_is_zero_address`). +ZERO_ADDRESS: Address = Address(b"\x00" * 20) +ZERO_HASH: Hash32 = Hash32(b"\x00" * 32) + + +def _is_zero_address(address: Address) -> bool: + """True for the all-zero 20-byte address — the "no L2MessageService" sentinel.""" + return bytes(address) == bytes(ZERO_ADDRESS) + def _mapping_slot(base_slot: Bytes32, key: bytes) -> Bytes32: """ @@ -65,7 +74,16 @@ def read_l1l2_bridge_state(state: L2State, l2_message_service_address: Address) Two reads: `lastAnchoredL1MessageNumber` at a fixed slot, then `l1RollingHashes[thatNumber]` at `keccak256(uint256_be(number) || base_slot)`. + + When `l2_message_service_address` is the zero address, there is no bridge + contract to read: both boundary values are zero. This is a real "no + L2MessageService configured" semantic — and it is what lets a vanilla + stateless input (which has no L2MessageService account, so its witness never + covers these slots) run through the guest unchanged. """ + if _is_zero_address(l2_message_service_address): + return ZERO_HASH, U64(0) + number_bytes = state.storage(l2_message_service_address, LAST_ANCHORED_L1_MESSAGE_NUMBER_SLOT) rolling_hash_number = U64(int.from_bytes(bytes(number_bytes), "big")) @@ -343,6 +361,9 @@ def run_l2_execution_guest(execution_input: L2ExecutionProofPrivateInput) -> L2E start_block_number = first_payload.block_number base_fee = Uint(first_payload.base_fee_per_gas) # asserted constant across the range (§2.1) l2_ms_address = execution_input.chain_config.l2_message_service_address + # "No L2MessageService configured" mode: a zero address suppresses both the L1->L2 bridge + # boundary reads (handled inside `read_l1l2_bridge_state`) and the L2->L1 message-log scan below. + bridge_suppressed = _is_zero_address(l2_ms_address) current_parent_hash = parent_block_hash current_ftx_rolling_hash = execution_input.parent_ftx_rolling_hash @@ -374,6 +395,10 @@ def run_l2_execution_guest(execution_input: L2ExecutionProofPrivateInput) -> L2E if requests.deposits or requests.withdrawals or requests.consolidations: raise Exception("execution requests are not supported by this rollup") + # ── Linea policy: no beacon-chain withdrawals — this is an L2 rollup, not L1 ── + if payload.withdrawals: + raise Exception("withdrawals are not supported by this rollup") + # ── State transition (delegated) ── # `execute_stateless_input` validates the witness header chain, the full # Engine-API payload, and replays the EVM (see its docstring); none of @@ -405,12 +430,14 @@ def run_l2_execution_guest(execution_input: L2ExecutionProofPrivateInput) -> L2E ) filtered_addresses.extend(block_filtered_addresses) - # L2->L1 messages from the block's logs. - for log in result.block_logs: - if log.address != l2_ms_address: - continue - if log.topics[0] == BRIDGE_L2L1_MESSAGE_SENT_TOPIC_0: - l2_l1_message_hashes.append(Hash32(log.topics[3])) + # L2->L1 messages from the block's logs (skipped entirely when no L2MessageService is + # configured — see `bridge_suppressed`). + if not bridge_suppressed: + for log in result.block_logs: + if log.address != l2_ms_address: + continue + if log.topics[0] == BRIDGE_L2L1_MESSAGE_SENT_TOPIC_0: + l2_l1_message_hashes.append(Hash32(log.topics[3])) current_parent_hash = payload.block_hash @@ -433,7 +460,7 @@ def run_l2_execution_guest(execution_input: L2ExecutionProofPrivateInput) -> L2E end_block_hash=last_payload.block_hash, end_block_number=last_payload.block_number, end_block_timestamp=U64(last_payload.timestamp), - l2_l1_messages_hash=hash_hash_list(l2_l1_message_hashes), + l2_l1_messages_hash=hash_digest_list(l2_l1_message_hashes), parent_l1_l2_bridge_rolling_hash=parent_rolling_hash, parent_l1_l2_bridge_rolling_hash_message_number=parent_rolling_hash_number, end_l1_l2_bridge_rolling_hash=end_rolling_hash, @@ -456,7 +483,7 @@ def run_l2_execution_guest(execution_input: L2ExecutionProofPrivateInput) -> L2E ) -def hash_hash_list(values: Sequence[Hash32]) -> Hash32: +def hash_digest_list(values: Sequence[Hash32]) -> Hash32: return keccak256(b"".join(bytes(value) for value in values)) diff --git a/rollup_spec/src/rollup_spec/rollup.py b/rollup_spec/src/rollup_spec/rollup.py index dc32c0c295b..bcc7dba29bd 100644 --- a/rollup_spec/src/rollup_spec/rollup.py +++ b/rollup_spec/src/rollup_spec/rollup.py @@ -32,7 +32,7 @@ L2ExecutionProofPublicInput, VerifiableL2ExecutionProof, hash_address_list, - hash_hash_list, + hash_digest_list, ) L2_L1_TREE_DEPTH = 5 @@ -606,7 +606,7 @@ def verify_l2_execution_proof(program_vk: Hash32, proof: L2ExecutionProof) -> No recursive_stark_verify(program_vk, proof.proof) # The three checks below are PRECOMPILE: keccak256 in production (used # to verify the preimage bindings that the rollup proof consumes). - if hash_hash_list(proof.l2_l1_messages) != proof.public_inputs.l2_l1_messages_hash: + if hash_digest_list(proof.l2_l1_messages) != proof.public_inputs.l2_l1_messages_hash: raise Exception("invalid L2-to-L1 message-list preimage") if hash_address_list(proof.tx_froms) != proof.public_inputs.tx_froms_hash: raise Exception("invalid txFromsHash preimage") @@ -659,7 +659,7 @@ def build_l2_messages_tree(msgs: Sequence[Hash32]) -> Tuple[List[Hash32], Hash32 calldata; the returned hash is the public `l2L1BridgeTransactionTree`. """ roots = build_l2_message_roots(msgs) - return roots, hash_hash_list(roots) + return roots, hash_digest_list(roots) def build_l2_message_roots(msgs: Sequence[Hash32]) -> List[Hash32]: diff --git a/rollup_spec/src/rollup_spec/rollup_aggregation.py b/rollup_spec/src/rollup_spec/rollup_aggregation.py index 3116f23c2df..1b6d8436733 100644 --- a/rollup_spec/src/rollup_spec/rollup_aggregation.py +++ b/rollup_spec/src/rollup_spec/rollup_aggregation.py @@ -5,7 +5,7 @@ from ethereum.state import Address from .l1_rollup import FinalizationSubmission -from .l2_execution import hash_address_list, hash_hash_list +from .l2_execution import hash_address_list, hash_digest_list from .rollup import ( BLOB_BYTES_LENGTH, RollupProof, @@ -81,7 +81,7 @@ def run_rollup_aggregation_guest( public_inputs = RollupPublicInput( end_block_number=last_proof.public_inputs.end_block_number, end_block_timestamp=last_proof.public_inputs.end_block_timestamp, - l2_l1_bridge_transaction_tree=hash_hash_list(merged_l2_l1_roots), + l2_l1_bridge_transaction_tree=hash_digest_list(merged_l2_l1_roots), parent_l1_l2_bridge_rolling_hash=first_proof.public_inputs.parent_l1_l2_bridge_rolling_hash, parent_l1_l2_bridge_rolling_hash_message_number=( first_proof.public_inputs.parent_l1_l2_bridge_rolling_hash_message_number @@ -134,7 +134,7 @@ def verify_rollup_proof(program_vk: Hash32, proof: RollupProof) -> None: # First: the recursive STARK verify against the explicit verify key. recursive_stark_verify(program_vk, proof.proof) # PRECOMPILE: keccak256 (preimage-binding checks). - if hash_hash_list(proof.l2_l1_roots) != proof.public_inputs.l2_l1_bridge_transaction_tree: + if hash_digest_list(proof.l2_l1_roots) != proof.public_inputs.l2_l1_bridge_transaction_tree: raise Exception("invalid l2L1BridgeTransactionTree preimage") if hash_address_list(proof.filtered_addresses) != proof.public_inputs.filtered_addresses_hash: raise Exception("invalid rollup filteredAddressesHash preimage") diff --git a/rollup_spec/tests/test_l1_rollup.py b/rollup_spec/tests/test_l1_rollup.py new file mode 100644 index 00000000000..1da40fb6c19 --- /dev/null +++ b/rollup_spec/tests/test_l1_rollup.py @@ -0,0 +1,160 @@ +""" +Business-oriented tests for L1 finalization ProgramVK anchoring +(`l1_rollup.finalize_rollup`). + +These assert observable finalization behavior — whether a finalization is +accepted or reverted, and the resulting on-chain state — not internal wiring. +The §ProgramVK-anchoring rule under test: L1 keeps a single combined +`approved_vks` set, and `finalize_rollup` reverts any finalization whose +committed VKs (the one combined `public_inputs.program_vks` list, order bound to +the proof) are not all approved. Exec vs rollup is NOT distinguished on L1. + +A `_base_state()` / `_base_submission()` pair is built so every pre-existing +finalization check passes trivially, isolating the VK-approval check as the only +variable across tests. + +Run from the rollup_spec/ directory: python -m pytest +""" + +import pytest + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum_types.numeric import U64 + +from rollup_spec.l1_rollup import ( + FinalizationSubmission, + LinethRollupState, + PlonkVerifier, + finalize_rollup, +) +from rollup_spec.l2_execution import hash_address_list, hash_digest_list +from rollup_spec.rollup import RollupPublicInput + +# Distinct VK / hash byte-pattern helpers (mirrors the style in +# test_proof_io_v1.py). Origins are noted only for tracing — L1 treats them as +# one combined list: 0xAA/0xA1 originate as exec VKs, 0xBB as a rollup VK. +_EXEC_VK_A = Hash32(bytes([0xAA]) * 32) +_EXEC_VK_B = Hash32(bytes([0xA1]) * 32) +_ROLLUP_VK = Hash32(bytes([0xBB]) * 32) + +_PARENT_DATA_ROLLING_HASH = Hash32(bytes([0x47]) * 32) +_END_DATA_ROLLING_HASH = Hash32(bytes([0x8D]) * 32) +_END_OFFSET = 500 +_PARENT_BLOCK_HASH = Hash32(bytes([0x46]) * 32) +_END_BLOCK_HASH = Hash32(bytes([0x9A]) * 32) +_L1L2_ROLLING_HASH = Hash32(bytes([0x22]) * 32) +_FTX_ROLLING_HASH = Hash32(bytes([0x44]) * 32) +_CHAIN_CONFIG_HASH = Hash32(bytes([0xC0]) * 32) + + +def _position_commitment(data_rolling_hash: Hash32, offset: int) -> Hash32: + """The `current_finalized_position_commitment` value sealing a given + (dataRollingHash, offset) end position (§3.6).""" + return keccak256(data_rolling_hash + offset.to_bytes(32, "big")) + + +def _base_state(approved_vks) -> LinethRollupState: + """ + An L1 state whose continuity anchors exactly match `_base_submission()`'s + public inputs, so all non-VK finalization checks pass. `approved_vks` is the + only knob the tests vary. + """ + return LinethRollupState( + current_finalized_position_commitment=_position_commitment(_PARENT_DATA_ROLLING_HASH, 0), + current_finalized_last_block_hash=_PARENT_BLOCK_HASH, + current_l2_block_number=U64(1000500), + current_l2_block_timestamp=U64(1763000000), + current_finalized_l1_l2_bridge_rolling_hash=_L1L2_ROLLING_HASH, + current_finalized_l1_l2_bridge_rolling_hash_message_number=U64(0), + current_finalized_ftx_rolling_hash=_FTX_ROLLING_HASH, + current_finalized_processed_ftx_number=U64(7), + verifier=PlonkVerifier(chain_configuration_hash=_CHAIN_CONFIG_HASH), + anchored_data_rolling_hashes={_END_DATA_ROLLING_HASH}, + approved_vks=set(approved_vks), + ) + + +def _base_submission(program_vks) -> FinalizationSubmission: + """ + A finalization submission carrying the single combined `program_vks` list + nested in the PI (order bound to the proof). Empty `l2_l1_roots` / + `filtered_addresses` keep the preimage-hash checks trivial (their keccak of + empty input is the PI hash), and the FTX/rolling-hash boundary values are + held constant across parent/end so continuity passes without any FTX deadline + machinery. `start_offset=0` is the fresh-start case, which `finalize_rollup` + accepts regardless of the previously-finalized offset. + """ + pi = RollupPublicInput( + end_block_number=U64(1000520), + end_block_timestamp=U64(1763000457), + l2_l1_bridge_transaction_tree=hash_digest_list([]), + parent_l1_l2_bridge_rolling_hash=_L1L2_ROLLING_HASH, + parent_l1_l2_bridge_rolling_hash_message_number=U64(0), + end_l1_l2_bridge_rolling_hash=_L1L2_ROLLING_HASH, + end_l1_l2_bridge_rolling_hash_message_number=U64(0), + dynamic_chain_config_hash=_CHAIN_CONFIG_HASH, + parent_ftx_rolling_hash=_FTX_ROLLING_HASH, + parent_ftx_number=U64(7), + end_ftx_rolling_hash=_FTX_ROLLING_HASH, + end_processed_ftx_number=U64(7), + filtered_addresses_hash=hash_address_list([]), + parent_data_rolling_hash=_PARENT_DATA_ROLLING_HASH, + end_data_rolling_hash=_END_DATA_ROLLING_HASH, + parent_block_hash=_PARENT_BLOCK_HASH, + end_block_hash=_END_BLOCK_HASH, + start_offset=0, + end_offset=_END_OFFSET, + program_vks=list(program_vks), + ) + return FinalizationSubmission( + public_inputs=pi, + proof=b"", + l2_l1_roots=[], + filtered_addresses=[], + l2_messaging_blocks_offsets=[], + ) + + +def _finalize(state: LinethRollupState, submission: FinalizationSubmission) -> None: + """`finalize_rollup`, supplying the (dataRollingHash, offset) pair that opens + `_base_state()`'s position commitment.""" + finalize_rollup(state, submission, _PARENT_DATA_ROLLING_HASH, 0) + + +def test_finalize_rollup_rejects_unapproved_vk() -> None: + # One committed VK (0xbb, rollup-origin) is NOT on the approved list — the + # "operator swapped in an unapproved guest" case. L1 does not distinguish + # exec vs rollup, so the single generic check rejects it. + state = _base_state(approved_vks={_EXEC_VK_A}) + initial_commitment = state.current_finalized_position_commitment + submission = _base_submission(program_vks=[_EXEC_VK_A, _ROLLUP_VK]) + with pytest.raises(Exception, match="program VK is not approved"): + _finalize(state, submission) + # Finalization reverted: state is unchanged. + assert state.current_finalized_position_commitment == initial_commitment + + +def test_finalize_rollup_accepts_two_approved_exec_vks() -> None: + # Goal-2b / multi-version finalization: a single finalization whose rollup + # proofs carried TWO different (both approved) exec VKs — two forks + # aggregated together, aggregation-grained — must succeed. The `program_vks` + # PI is the canonical sorted-distinct set, so the input is sorted ascending + # by byte value: 0xA1 (_EXEC_VK_B) < 0xAA (_EXEC_VK_A) < 0xBB (_ROLLUP_VK). + state = _base_state(approved_vks={_EXEC_VK_A, _EXEC_VK_B, _ROLLUP_VK}) + submission = _base_submission(program_vks=[_EXEC_VK_B, _EXEC_VK_A, _ROLLUP_VK]) + _finalize(state, submission) # must not raise + # Finalization applied: block hash, block number, and position commitment + # all advanced to the submission's end-of-range values. + assert state.current_finalized_last_block_hash == _END_BLOCK_HASH + assert int(state.current_l2_block_number) == 1000520 + assert state.current_finalized_position_commitment == _position_commitment(_END_DATA_ROLLING_HASH, _END_OFFSET) + + +def test_finalize_rollup_succeeds_when_all_vks_approved() -> None: + # No-regression: the ordinary single-exec-VK + rollup-VK happy path still + # finalizes when every committed VK is approved. + state = _base_state(approved_vks={_EXEC_VK_A, _ROLLUP_VK}) + submission = _base_submission(program_vks=[_EXEC_VK_A, _ROLLUP_VK]) + _finalize(state, submission) # must not raise + assert state.current_finalized_position_commitment == _position_commitment(_END_DATA_ROLLING_HASH, _END_OFFSET) + assert int(state.current_l2_block_number) == 1000520 diff --git a/rollup_spec/tests/test_l2_execution.py b/rollup_spec/tests/test_l2_execution.py new file mode 100644 index 00000000000..b1e3326c1a6 --- /dev/null +++ b/rollup_spec/tests/test_l2_execution.py @@ -0,0 +1,143 @@ +""" +Tests for the l2-execution guest's "no L2MessageService configured" (zero-address) +bridge-suppression path. + +The zero-address path is what lets a vanilla stateless input (no L2MessageService +account, witness covering only what execution touched) run through the extended +guest unchanged — it is the reference-side counterpart of the guest's +`bridge_suppressed` branch. + +Run from the rollup_spec/ directory: python -m pytest +""" + +from pathlib import Path + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.state import Address +from ethereum_types.numeric import U64 + +from rollup_spec import l2_execution +from rollup_spec.block import ChainConfig, LinethPayloadInput +from rollup_spec.fork import Log +from rollup_spec.l2_execution import ( + BRIDGE_L2L1_MESSAGE_SENT_TOPIC_0, + ZERO_ADDRESS, + ZERO_HASH, + L2ExecutionProofPrivateInput, + read_l1l2_bridge_state, + run_l2_execution_guest, +) +from rollup_spec.proof_io_v1 import decode_request_json +from rollup_spec.state_transition import ( + EMPTY_TRIE_ROOT_HASH, + L2State, + StatelessExecutionResult, +) +from rollup_spec.stateless_input import decode_stateless_input_ssz + +_TESTDATA_DIR = Path(l2_execution.__file__).resolve().parent / "prover_io" / "testdata" + + +def _fixture(name: str) -> Path: + """Resolve `.json`, allowing an optional `--` prefix.""" + matches = sorted(_TESTDATA_DIR.glob(f"*{name}")) + assert matches, f"no fixture matching *{name} in {_TESTDATA_DIR}" + assert len(matches) == 1, f"multiple fixtures matching *{name}: {matches}" + return matches[0] + + +def _golden_vanilla_stateless_input_ssz() -> bytes: + """A real, valid vanilla stateless-input SSZ slice, from the golden JSON request.""" + request = _fixture("getZkL2ExecutionProofV1.request.json").read_text() + return decode_request_json(request).payloads[0].stateless_input_ssz + + +def _zero_bridge_input(vanilla: bytes) -> L2ExecutionProofPrivateInput: + """ + Test-local setup: a single-payload extended input around a vanilla slice with a + zero `l2_message_service_address`, so the guest's bridge-suppression branch runs. + `chain_id`/`coinbase` are read off the vanilla input so the conflation invariants + (chain-id match, `feeRecipient == coinbase`) hold. + """ + si = decode_stateless_input_ssz(vanilla) + return L2ExecutionProofPrivateInput( + parent_ftx_rolling_hash=ZERO_HASH, + parent_last_processed_ftx_number=U64(0), + payloads=[LinethPayloadInput(stateless_input_ssz=vanilla)], + chain_config=ChainConfig( + l2_message_service_address=ZERO_ADDRESS, + coinbase=si.new_payload_request.execution_payload.fee_recipient, + chain_id=si.chain_config.chain_id, + ), + ) + + +# ── read_l1l2_bridge_state: zero-address short-circuit ────────────────────────── + + +def test_read_l1l2_bridge_state_zero_address_returns_zeros_without_state_access() -> None: + # A state whose .storage would raise if touched: proves the zero-address guard + # short-circuits before any MPT read. + class _ExplodingState: + def storage(self, *_args, **_kwargs): # noqa: ANN002, ANN003 + raise AssertionError("storage() must not be called for the zero address") + + rolling_hash, number = read_l1l2_bridge_state(_ExplodingState(), ZERO_ADDRESS) + assert rolling_hash == ZERO_HASH + assert int(number) == 0 + + +def test_read_l1l2_bridge_state_nonzero_address_still_reads_state() -> None: + # Contrast: a non-zero address is NOT suppressed, so the real MPT read runs + # (here against an empty-trie state, which proves absence -> zero). + state = L2State(state_root=EMPTY_TRIE_ROOT_HASH, witnesses=[]) + rolling_hash, number = read_l1l2_bridge_state(state, Address(bytes([0x11]) * 20)) + assert rolling_hash == ZERO_HASH # empty trie => proof of absence => zero + assert int(number) == 0 + + +# ── run_l2_execution_guest: full zero-address suppression ─────────────────────── + + +def test_run_l2_execution_guest_zero_address_suppresses_bridge_and_messages(monkeypatch) -> None: + vanilla = _golden_vanilla_stateless_input_ssz() + ext = _zero_bridge_input(vanilla) + + # A block log that WOULD be collected as an L2->L1 message if the scan ran: + # its address equals the (zero) configured L2MessageService and topic0 is the + # bridge signature. Suppression must skip it entirely. + matching_log = Log( + address=ZERO_ADDRESS, + topics=( + BRIDGE_L2L1_MESSAGE_SENT_TOPIC_0, + Hash32(b"\x00" * 32), + Hash32(b"\x00" * 32), + Hash32(bytes([0xAB]) * 32), + ), + data=b"", + ) + + def _fake_execute(stateless_input): # noqa: ANN001, ANN202 + return StatelessExecutionResult( + pre_state_root=Hash32(bytes([0x11]) * 32), + post_state_root=Hash32(bytes([0x22]) * 32), + block_logs=[matching_log], + ) + + # Boundary + crypto stubs: mock the delegated engine and skip payload-tx sender + # recovery (empty tx list) so the test needs no real EVM or secp256k1. + monkeypatch.setattr(l2_execution, "execute_stateless_input", _fake_execute) + monkeypatch.setattr(l2_execution, "parse_payload_transaction_rlps", lambda payload: []) + + proof = run_l2_execution_guest(ext) + pi = proof.public_inputs + + # All four bridge PI fields pinned to zero. + assert pi.parent_l1_l2_bridge_rolling_hash == ZERO_HASH + assert int(pi.parent_l1_l2_bridge_rolling_hash_message_number) == 0 + assert pi.end_l1_l2_bridge_rolling_hash == ZERO_HASH + assert int(pi.end_l1_l2_bridge_rolling_hash_message_number) == 0 + + # L2->L1 message scan skipped despite the matching log present. + assert proof.l2_l1_messages == [] + assert pi.l2_l1_messages_hash == Hash32(keccak256(b""))