Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions riscv-guests/l2-execution/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,27 @@ pub fn build(b: *std.Build) void {
vanilla_wrap_mod.addImport("zesu_ssz_decode", native_imports.ssz_decode);
vanilla_wrap_mod.addImport("l2_execution_ssz", l2_execution_ssz_mod);

// ── Vanilla StatelessInput SSZ encoder module (test/stateless_input_encode.zig) ─────────────────
// Test-only SSZ encoder for zesu's vanilla StatelessInput — the byte-level inverse of
// zesu_ssz_decode's decode, which ships with no matching encoder of its own. Wired as a shared
// named module (not a bare relative import) since two independent test roots use it: its own
// round-trip/golden tests below, and the conflation-plan DSL, which needs it to produce each
// fabricated payload's stateless_input_ssz bytes. Mirrors how `vanilla_wrap_mod` above is shared
// across two consumers.
const stateless_input_encode_mod = b.createModule(.{
.root_source_file = b.path("test/stateless_input_encode.zig"),
.target = native_target,
.optimize = host_optimize,
});
stateless_input_encode_mod.addImport("zesu_input", native_imports.input);
// A generic SSZ serialize/deserialize library over plain Zig structs. Test-only,
// native-target-only: the guest never imports this module (it has no relative or named import
// path to `stateless_input_encode.zig`), so `.ssz` never enters the freestanding riscv64 compile
// graph. The library's own build.zig exposes its module under the name "ssz.zig" (its literal
// `b.addModule` argument); "ssz" here is only this file's own local import name for it.
const ssz_dep = b.dependency("ssz", .{ .target = native_target, .optimize = host_optimize });
stateless_input_encode_mod.addImport("ssz", ssz_dep.module("ssz.zig"));
Comment thread
Filter94 marked this conversation as resolved.
Outdated

// ── `l2-execution-wrap` native host tool ────────────────────────────────────────────────────────
// Wraps a vanilla EF stateless-input .ssz into an extended L2ExecutionProofPrivateInput .ssz
// (zero l2MessageServiceAddress -> bridge suppression), so the ZkC harness can feed the extended
Expand Down Expand Up @@ -353,6 +374,127 @@ pub fn build(b: *std.Build) void {

test_step.dependOn(&b.addRunArtifact(tests).step);

// ── Shared legacy-tx RLP encoder (test/legacy_tx_rlp.zig) ───────────────────────────────────
// One RLP encoder for a legacy transaction's fixed field list, shared by every test fixture
// that builds one from named fields rather than a byte literal.
const legacy_tx_rlp_mod = b.createModule(.{
.root_source_file = b.path("test/legacy_tx_rlp.zig"),
.target = native_target,
.optimize = host_optimize,
});
legacy_tx_rlp_mod.addImport("zesu_executor", native_imports.executor);

// ── Vanilla StatelessInput SSZ encoder (test/stateless_input_encode.zig) unit tests ────────
// Test-only SSZ encoder for zesu's vanilla StatelessInput — the byte-level inverse of
// zesu_ssz_decode's decode, which ships with no matching encoder of its own. A round-trip
// test builds a StatelessInput as readable Zig and checks it survives encode-then-decode; a
// golden test decodes this same fixture and checks re-encoding it reproduces the original
// bytes exactly. Needs the same zesu_input/zesu_ssz_decode imports as the vanilla-wrap
// wiring, plus the fixtures module already built for the guest smoke test above.
const stateless_input_encode_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("test/stateless_input_encode_test.zig"),
.target = native_target,
.optimize = host_optimize,
}),
});
stateless_input_encode_tests.root_module.addImport("zesu_input", native_imports.input);
stateless_input_encode_tests.root_module.addImport("zesu_ssz_decode", native_imports.ssz_decode);
stateless_input_encode_tests.root_module.addImport("evm_execution_fixtures", fixtures_mod);
stateless_input_encode_tests.root_module.addImport("stateless_input_encode", stateless_input_encode_mod);
stateless_input_encode_tests.root_module.addImport("legacy_tx_rlp", legacy_tx_rlp_mod);
linkNativeZesuCrypto(stateless_input_encode_tests, native_target, native_crypto);
test_step.dependOn(&b.addRunArtifact(stateless_input_encode_tests).step);

// ── Conflation-plan test DSL parity guard (test/conflation_plan_parity_test.zig) ────────────
// Proves `conflation_plan.zig`'s `StubEngine` (the l2-execution guest's execution-seam stub)
// is a faithful stand-in for the real per-block execution seam on real data, and smoke-tests
// the `ConflationPlan` DSL itself end to end through the guest's real conflation logic.
// `conflation_plan.zig` is pulled in by relative import from the test file, so every import
// it needs (the full zesu set its real-MPT/real-header derivation uses, `l2_execution` for
// the seam, `l2_execution_ssz` for the envelope codec, and the shared
// `stateless_input_encode` module) is wired directly on this root module.
const conflation_plan_parity_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("test/conflation_plan_parity_test.zig"),
.target = native_target,
.optimize = host_optimize,
}),
});
conflation_plan_parity_tests.root_module.addImport("l2_execution", l2_execution_mod);
conflation_plan_parity_tests.root_module.addImport("l2_execution_ssz", l2_execution_ssz_mod);
conflation_plan_parity_tests.root_module.addImport("zesu_executor", native_imports.executor);
conflation_plan_parity_tests.root_module.addImport("zesu_mpt", native_imports.mpt);
conflation_plan_parity_tests.root_module.addImport("zesu_input", native_imports.input);
conflation_plan_parity_tests.root_module.addImport("zesu_primitives", native_imports.primitives);
conflation_plan_parity_tests.root_module.addImport("zesu_allocator", native_imports.allocator);
conflation_plan_parity_tests.root_module.addImport("zesu_rlp_decode", native_imports.rlp_decode);
conflation_plan_parity_tests.root_module.addImport("zesu_ssz_decode", native_imports.ssz_decode);
conflation_plan_parity_tests.root_module.addImport("stateless_input_encode", stateless_input_encode_mod);
conflation_plan_parity_tests.root_module.addImport("evm_execution_fixtures", fixtures_mod);
linkNativeZesuCrypto(conflation_plan_parity_tests, native_target, native_crypto);
test_step.dependOn(&b.addRunArtifact(conflation_plan_parity_tests).step);

// ── Conflation-plan range scenario suite (test/l2_execution_range_test.zig) ───────────────
// One rich happy-path scenario asserting the full 16-field public input plus preimages,
// and twelve one-mutation rejection scenarios, both built on the `ConflationPlan` DSL —
// driving the guest's real conflation logic (`l2_execution.runL2ExecutionWithEngine`)
// through `StubEngine` instead of live EVM execution. Needs exactly the import set
// `conflation_plan.zig` itself needs, since it's pulled in by relative import from the
// test root, same as the parity test above.
//
// secp256k1 signing primitive (test-only): the scenario's T1-T4 fixtures are built and
// signed live instead of loaded from pre-minted files, using zesu's own real backend.
// Cannot be wired by rooting a module directly at zesu's vendored
// src/crypto/backends/secp256k1_wrapper.zig the way modexp_impl_mod/ripemd160_impl_mod
// above root modules at their own backend files: unlike those two (which nothing else in
// the graph reaches), this file is ALSO relatively-imported by zesu's own native
// accel_impl root (src/crypto/default.zig, for its ecrecover/verify implementation), and
// accel_impl is always in this graph already (accelerators, needed transitively by
// zesu_executor/zesu_mpt for real EVM ecrecover). Rooting a second module at the same
// path double-claims it — Zig rejects a file belonging to two modules at once — and the
// exposed accelerators/accel_impl surface has no path to `sign`/`getContext` themselves
// (that surface only exposes verify/ecrecover; zesu itself never signs). A `WriteFile`
// step first copies the vendored file byte-for-byte to a fresh path outside zesu's own
// module tree, so the copy — the same real code, just reachable from a path nothing else
// claims — can root its own module. This file's `sign`/`getContext` reach a real
// `@cImport`'d secp256k1.h, and C include paths are per-module and don't cross a
// dependency boundary (zesu's own build.zig hits the same constraint wiring its
// `accel_impl` module: see its `addIncludePath` call there) — so this module needs its
// own include path even though `linkNativeZesuCrypto` below already links libsecp256k1
// into this test binary for `recoverFixtureSender`'s ecrecover path.
const secp256k1_wrapper_copy = b.addWriteFiles();
const secp256k1_wrapper_copy_path = secp256k1_wrapper_copy.addCopyFile(
zesu_native.path("src/crypto/backends/secp256k1_wrapper.zig"),
"secp256k1_wrapper.zig",
);
const secp256k1_wrapper_mod = b.createModule(.{
.root_source_file = secp256k1_wrapper_copy_path,
.target = native_target,
.optimize = host_optimize,
});
secp256k1_wrapper_mod.addIncludePath(.{ .cwd_relative = native_crypto.include_path });

const l2_execution_range_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("test/l2_execution_range_test.zig"),
.target = native_target,
.optimize = host_optimize,
}),
});
l2_execution_range_tests.root_module.addImport("l2_execution", l2_execution_mod);
l2_execution_range_tests.root_module.addImport("l2_execution_ssz", l2_execution_ssz_mod);
l2_execution_range_tests.root_module.addImport("zesu_executor", native_imports.executor);
l2_execution_range_tests.root_module.addImport("zesu_mpt", native_imports.mpt);
l2_execution_range_tests.root_module.addImport("zesu_input", native_imports.input);
l2_execution_range_tests.root_module.addImport("zesu_primitives", native_imports.primitives);
l2_execution_range_tests.root_module.addImport("zesu_rlp_decode", native_imports.rlp_decode);
l2_execution_range_tests.root_module.addImport("zesu_secp256k1", secp256k1_wrapper_mod);
l2_execution_range_tests.root_module.addImport("stateless_input_encode", stateless_input_encode_mod);
l2_execution_range_tests.root_module.addImport("legacy_tx_rlp", legacy_tx_rlp_mod);
linkNativeZesuCrypto(l2_execution_range_tests, native_target, native_crypto);
test_step.dependOn(&b.addRunArtifact(l2_execution_range_tests).step);

// ── 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
Expand Down
8 changes: 8 additions & 0 deletions riscv-guests/l2-execution/build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
.build_common = .{ .path = "../build_common" },
// Linea zkVM accelerator wrappers (sibling path dependency).
.lineth_accelerators = .{ .path = "../lineth-accelerators" },
// A generic SSZ serialize/deserialize library over plain Zig structs, targeting the same
// Zig version this package pins. Test-only: gives the test-side vanilla StatelessInput
// encoder a real implementation to serialize against instead of a hand-rolled
// offset-table encoder. Pinned to the v0.0.11 tag.
.ssz = .{
.url = "https://github.com/blockblaz/ssz.zig/archive/refs/tags/v0.0.11.tar.gz",
.hash = "ssz-0.0.9-Lfwd68m_AwAFGpz2g1kHvokWjp_UsvqDb3Z2D46w5s_-",
},
},
.paths = .{
"build.zig",
Expand Down
11 changes: 9 additions & 2 deletions riscv-guests/l2-execution/src/execution.zig
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,15 @@ pub fn executeStatelessInputWithLogs(

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;
// A resolvable witness header is required for every non-genesis block: without one, this
// would fall back to the payload's OWN claimed (post-execution) state_root as its pre-state
// root, which is self-referential and disconnected from the real state behind
// `ep.parent_hash`. Genesis — block 0 with an all-zero parent hash — is the only exemption.
const pre_state_root = rlp_decode.findPreStateRoot(si.witness.headers, ep.block_number) orelse blk: {
const is_genesis = ep.block_number == 0 and std.mem.allEqual(u8, &ep.parent_hash, 0);
if (!is_genesis) return error.MissingParentHeaderWitness;
break :blk ep.state_root;
};

const HeaderInfo = struct { number: u64, parent_hash: [32]u8, hash: [32]u8 };
var header_infos = std.ArrayListUnmanaged(HeaderInfo).empty;
Expand Down
37 changes: 25 additions & 12 deletions riscv-guests/l2-execution/src/l2_execution.zig
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,14 @@ fn validateForcedTransactions(
/// 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 {
return runL2ExecutionWithEngine(execution, alloc, in);
}

/// Same as `runL2Execution`, but with the per-block execution step taken as a comptime `Engine`
/// parameter instead of being fixed to the `execution` module. This is the seam at which a test DSL
/// binds a stub engine, driving the conflation logic below end to end with declared per-block
/// results in place of real EVM execution.
pub fn runL2ExecutionWithEngine(comptime Engine: type, 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;
Expand Down Expand Up @@ -350,16 +358,17 @@ pub fn runL2Execution(alloc: std.mem.Allocator, in: l2_execution_ssz.L2Execution
// 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
// skip the header check for anything other than a real genesis block. `execution.zig`'s own
// `pre_state_root` derivation enforces this same resolution for itself outside genuine
// genesis, returning `error.MissingParentHeaderWitness` when `rlp_decode.findPreStateRoot`
// finds no match — an unresolved fallback to the payload's OWN claimed (post-execution)
// state_root would otherwise stand in as its pre-state root, self-referential and completely
// disconnected from the real state behind `payload.parent_hash`. Combined with a no-op block,
// that would let 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). Checking it here too, before any per-block execution runs, 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) {
Expand Down Expand Up @@ -396,7 +405,7 @@ pub fn runL2Execution(alloc: std.mem.Allocator, in: l2_execution_ssz.L2Execution
// 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);
const result = try Engine.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;
Expand Down Expand Up @@ -468,7 +477,7 @@ pub fn runL2Execution(alloc: std.mem.Allocator, in: l2_execution_ssz.L2Execution
};
}

// ─── Exposed for unit tests only (test/l2_execution_test.zig) ─────────────────────────────────────
// ─── Exposed for unit tests only ───────────────────────────────────────────────────────────────

pub const test_api = struct {
pub const u64ToSlot32Fn = u64ToSlot32;
Expand All @@ -484,4 +493,8 @@ pub const test_api = struct {
pub const validateForcedTransactionsFn = validateForcedTransactions;
pub const recoverSenderFn = tx_signing.recoverSender;
pub const Acceptance = ForcedTransactionAcceptance;
/// The real per-block execution seam, exposed so test code can run it directly against the
/// same inputs a stub engine receives — this module's own import of it is the only reachable
/// path without a build-graph module double-claim.
pub const executeStatelessInputWithLogsFn = execution.executeStatelessInputWithLogs;
};
Loading
Loading