Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
117 changes: 117 additions & 0 deletions riscv-guests/l2-execution/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,25 @@ 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);
// Lazy: only fetched when a test needing stateless_input_encode is actually built. Module name
// is "ssz.zig" (the dependency's own b.addModule argument), not "ssz".
if (b.lazyDependency("ssz", .{ .target = native_target, .optimize = host_optimize })) |ssz_dep| {
stateless_input_encode_mod.addImport("ssz", ssz_dep.module("ssz.zig"));
}

// ── `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 @@ -360,6 +379,104 @@ 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 ────────
// Reuses the zesu_input/zesu_ssz_decode imports already resolved above for vanilla_wrap_mod,
// 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) ────────────
// conflation_plan.zig is pulled in by relative import, not its own module, so every import
// it needs is wired directly on this root module instead.
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) ───────────────
// Same relative-import reasoning as the parity test above: needs conflation_plan.zig's own
// import set wired directly here.
//
// secp256k1_wrapper.zig can't be rooted directly as its own module the way
// modexp_impl_mod/ripemd160_impl_mod are: unlike those two, this file is ALSO
// relatively-imported by zesu's own accel_impl root (already in this graph via
// accelerators), and Zig rejects one file belonging to two modules at once. The exposed
// accelerators surface has no path to `sign`/`getContext` either (it only exposes
// verify/ecrecover). A WriteFile step copies the file byte-for-byte to a fresh path
// nothing else claims, so the copy can root its own module. That module needs its own C
// include path for its `@cImport`'d secp256k1.h — C include paths are per-module and don't
// inherit from linkNativeZesuCrypto below (zesu's own build.zig hits the same constraint
// wiring accel_impl).
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
9 changes: 9 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,15 @@
.build_common = .{ .path = "../build_common" },
// Lineth 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_-",
.lazy = true,
},
},
.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