diff --git a/riscv-guests/l2-execution/build.zig b/riscv-guests/l2-execution/build.zig index 5b20c149bb..a3891f5c4d 100644 --- a/riscv-guests/l2-execution/build.zig +++ b/riscv-guests/l2-execution/build.zig @@ -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 @@ -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 diff --git a/riscv-guests/l2-execution/build.zig.zon b/riscv-guests/l2-execution/build.zig.zon index cf026838b5..1da6034db4 100644 --- a/riscv-guests/l2-execution/build.zig.zon +++ b/riscv-guests/l2-execution/build.zig.zon @@ -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", diff --git a/riscv-guests/l2-execution/src/execution.zig b/riscv-guests/l2-execution/src/execution.zig index 2a697ea421..918462aebe 100644 --- a/riscv-guests/l2-execution/src/execution.zig +++ b/riscv-guests/l2-execution/src/execution.zig @@ -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; diff --git a/riscv-guests/l2-execution/src/l2_execution.zig b/riscv-guests/l2-execution/src/l2_execution.zig index 8e8bedcbfb..2d1a9a342e 100644 --- a/riscv-guests/l2-execution/src/l2_execution.zig +++ b/riscv-guests/l2-execution/src/l2_execution.zig @@ -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; @@ -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) { @@ -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; @@ -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; @@ -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; }; diff --git a/riscv-guests/l2-execution/test/conflation_plan.zig b/riscv-guests/l2-execution/test/conflation_plan.zig new file mode 100644 index 0000000000..dcc90965ef --- /dev/null +++ b/riscv-guests/l2-execution/test/conflation_plan.zig @@ -0,0 +1,546 @@ +//! Test DSL for the l2-execution guest's conflation logic. +//! +//! `ConflationPlan` fabricates a self-consistent multi-block guest input — real keccak-rooted +//! state tries, a real hash-chained header sequence, and the real SSZ envelope byte path — from a +//! small set of knobs with realistic defaults, so a scenario only states what it deviates from. +//! `StubEngine` stands in for per-block EVM execution at `l2_execution.runL2ExecutionWithEngine`'s +//! seam, trusting each payload's own declared outcome instead of running the EVM. `run`/ +//! `expectReject` drive the two together end to end, through the guest's real conflation logic. +//! +//! Every block in a plan starts from the same world state (`world0`) except the last, which +//! transitions to a second world state (`world1`) differing only in the L1<->L2 bridge storage a +//! scenario declares via `bridgeStorage` — mirroring how a real range conflates N blocks against +//! one pre-state and commits one post-state. + +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 rlp_decode = @import("zesu_rlp_decode"); +const l2_execution = @import("l2_execution"); +const l2_execution_ssz = @import("l2_execution_ssz"); +const stateless_input_encode = @import("stateless_input_encode"); + +const types = executor.executor_types; +const tx_decode = executor.executor_tx_decode; +const tx_signing = executor.executor_tx_signing; +const rlp = executor.executor_rlp_encode; + +// ─── Constants ────────────────────────────────────────────────────────────────────────────────── + +const ZERO_ADDRESS: [20]u8 = @splat(0); +const ZERO_HASH: [32]u8 = @splat(0); + +/// An arbitrary, visually-distinct default coinbase — every payload's `fee_recipient` matches it +/// by default, satisfying the guest's own `FeeRecipientMismatch` check with no per-block effort. +const DEFAULT_COINBASE: [20]u8 = @splat(0xc0); + +/// A realistic L2MessageService address a caller opts into directly when it wants declared bridge +/// storage to be observable — a plain field assignment (e.g. `.l2_message_service_address = +/// conflation_plan.DEFAULT_L2_MESSAGE_SERVICE_ADDRESS`), the same way every other override in this +/// DSL works (`block.base_fee = ...`, `block.fee_recipient = ...`). +pub const DEFAULT_L2_MESSAGE_SERVICE_ADDRESS: [20]u8 = @splat(0xee); + +/// The vanilla wire schema's Amsterdam fork byte. +const AMSTERDAM_FORK_BYTE: u8 = 0x15; + +/// A realistic gas limit; every header/payload shares it, and `gas_used` is pinned at exactly +/// half of it (see `buildHeader`'s doc comment for why). +const DEFAULT_GAS_LIMIT: u64 = 30_000_000; +const DEFAULT_BASE_FEE: u64 = 1_000_000_000; +const DEFAULT_BASE_TIMESTAMP: u64 = 1_700_000_000; +const BLOCK_TIME_SECONDS: u64 = 12; + +/// L2MessageService's storage layout: the guest's own layout constants of the same name, +/// mirrored here since only the slot-address FORMULA (not these raw numbers) is reachable +/// through `test_api`. +const LAST_ANCHORED_L1_MESSAGE_NUMBER_SLOT: u64 = 280; +const L1_ROLLING_HASHES_MAPPING_BASE_SLOT: u64 = 281; + +/// One packed dummy deposit-request-sized item — enough to make `execution_requests.deposits` +/// non-empty regardless of its actual (unparsed, opaque) content. +const NON_EMPTY_DEPOSIT_BYTES: [192]u8 = @splat(0xde); + +const NON_EMPTY_WITHDRAWALS = [_]input.Withdrawal{ + .{ .index = 0, .validator_index = 0, .address = ZERO_ADDRESS, .amount = 1 }, +}; + +const TWO_DEFAULT_BLOCKS = [_]BlockPlan{ .{}, .{} }; + +fn isZeroAddress(address: [20]u8) bool { + return std.mem.allEqual(u8, &address, 0); +} + +/// Default timestamps: strictly increasing, one realistic block time apart. +fn defaultTimestamp(block_index: usize) u64 { + return DEFAULT_BASE_TIMESTAMP + @as(u64, @intCast(block_index)) * BLOCK_TIME_SECONDS; +} + +// ─── Bridge storage ───────────────────────────────────────────────────────────────────────────── + +/// The L1->L2 bridge rolling-hash state realized in a world's L2MessageService storage: `number` +/// at the fixed slot, `hash` at the mapping slot keyed by `number`. +pub const BridgeValue = struct { + number: u64 = 0, + hash: [32]u8 = ZERO_HASH, +}; + +// ─── Per-block plan ───────────────────────────────────────────────────────────────────────────── + +pub const BlockPlan = struct { + /// Signed transaction RLPs included in this block, in order. + signed_tx_rlps: []const []const u8 = &.{}, + /// Logs for each entry in `signed_tx_rlps`, index-aligned; a shorter slice leaves the + /// remaining transactions with no logs. + tx_logs: []const []const types.Log = &.{}, + /// Forced-transaction witnesses this block declares (§6.5). + forced_transactions: []const l2_execution_ssz.ForcedTransactionWitness = &.{}, + + /// Overrides the range-level constant base fee for this block only. + base_fee: ?u64 = null, + /// Overrides the range-level coinbase-derived fee recipient for this block only. + fee_recipient: ?[20]u8 = null, + /// Overrides the INNER vanilla stateless-input's own chain_config.chain_id (as opposed to + /// the range-level `ConflationPlan.chain_id`, which every block matches by default). + chain_id: ?u64 = null, + /// Overrides this block's derived (strictly-increasing) timestamp. + timestamp: ?u64 = null, + /// Populates `execution_requests.deposits` with one packed dummy item — enough to trip the + /// guest's Lineth-does-not-support-EIP-7685 rejection. + non_empty_execution_requests: bool = false, + /// Populates payload-level `withdrawals` with one dummy entry — enough to trip the guest's + /// no-beacon-chain-withdrawals rejection. + non_empty_withdrawals: bool = false, + /// Overrides the vanilla schema's fork byte (default: Amsterdam's, 0x15). 0x11 (Prague) is + /// the documented decodable alternative — zesu's SSZ decoder accepts it and yields + /// `fork_name = "Prague"`, reaching the guest's own fork check as a genuine mismatch rather + /// than a decode failure. + active_fork_idx: ?u8 = null, +}; + +// ─── The built (pre-envelope-encoding) value and the execution-seam stub ─────────────────────────── + +/// The pieces `StubEngine` needs per call, plus the fully-assembled envelope `run()` encodes and +/// round-trips. Built once by `ConflationPlan.build()`, then driven through the guest's real +/// conflation logic by `run()`. +pub const Built = struct { + /// Block count; also the number of `executeStatelessInputWithLogs` calls `run()` expects. + blocks_len: usize, + /// Declared logs, indexed `[block_index][tx_index]`. + tx_logs: []const []const []const types.Log, + /// World1's node RLPs, inserted into the shared `node_index` only when `StubEngine` processes + /// the last block — mirroring how the real engine admits post-state nodes mid-conflation. + world1_nodes: []const []const u8, + envelope: l2_execution_ssz.L2ExecutionProofPrivateInput, + /// The range's parent and end block hashes, exactly as the guest parrots them into + /// `public_inputs.parent_block_hash`/`end_block_hash` (`payloads[0]`'s parent_hash and + /// `payloads[n-1]`'s block_hash, after any hooks) — exposed since a scenario asserting the + /// full PI needs to compare against these independently-derived values, and the header RLP + /// bytes they come from are otherwise private to this file's `buildHeader`. + parent_block_hash: [32]u8 = ZERO_HASH, + end_block_hash: [32]u8 = ZERO_HASH, +}; + +/// Structural counterpart of `execution.ProofOutputWithLogs` — same fields, so +/// `runL2ExecutionWithEngine`'s generic `Engine.executeStatelessInputWithLogs(...)` call sites and +/// the parity test's field-by-field comparison both work without importing that type by name. +const StubProofOutput = struct { + pre_state_root: [32]u8, + post_state_root: [32]u8, + receipts_root: [32]u8, + receipts: []const types.Receipt, + fork_name: []const u8, +}; + +/// Contract stub for the `runL2ExecutionWithEngine(comptime Engine, ...)` seam: trusts each +/// payload's own declared state roots and this plan's declared logs instead of running the EVM. +pub const StubEngine = struct { + /// The plan currently driving this stub, set by `ConflationPlan.run()` for the duration of + /// one `runL2ExecutionWithEngine` call, and cleared after. `null` outside that window — the + /// parity test drives this engine directly with `active` left `null`, exercising its + /// no-declared-plan default (zero receipts) against real fixture data instead of any + /// particular scenario. + pub var active: ?*const Built = null; + var next_index: usize = 0; + + pub fn executeStatelessInputWithLogs( + alloc: std.mem.Allocator, + si: input.StatelessInput, + fork_name: []const u8, + node_index: *mpt.NodeIndex, + ) !StubProofOutput { + const ep = &si.new_payload_request.execution_payload; + const call_index = next_index; + next_index += 1; + + 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; + }; + + var receipts = std.ArrayListUnmanaged(types.Receipt).empty; + if (active) |built| { + const decoded_txs = try tx_decode.decodeTxs(alloc, ep.raw_transactions); + const block_logs: []const []const types.Log = if (call_index < built.tx_logs.len) built.tx_logs[call_index] else &.{}; + + for (decoded_txs, 0..) |*tx, tx_idx| { + const from = try tx_signing.recoverSender(alloc, tx, si.chain_config.chain_id) orelse + return error.StubEngineSenderRecoveryFailed; + const logs: []const types.Log = if (tx_idx < block_logs.len) block_logs[tx_idx] else &.{}; + try receipts.append(alloc, .{ + .type = tx.type, + .tx_hash = mpt.keccak256(ep.raw_transactions[tx_idx]), + .tx_index = @intCast(tx_idx), + .block_hash = ep.block_hash, + .block_number = ep.block_number, + .from = from, + .to = tx.to, + .cumulative_gas_used = 0, + .gas_used = 0, + .contract_address = null, + // Receipt.logs is a mutable slice in zesu's own type (real execution builds + // it fresh per block); this plan's declared logs are read-only fixture data, + // so reusing them via `@constCast` is safe. + .logs = @constCast(logs), + .logs_bloom = @splat(0), + .status = 1, + .effective_gas_price = 0, + }); + } + + if (built.blocks_len > 0 and call_index == built.blocks_len - 1) { + for (built.world1_nodes) |node_rlp| try node_index.put(mpt.keccak256(node_rlp), node_rlp); + } + } + + return .{ + .pre_state_root = pre_state_root, + .post_state_root = ep.state_root, + .receipts_root = ep.receipts_root, + .receipts = try receipts.toOwnedSlice(alloc), + .fork_name = fork_name, + }; + } +}; + +// ─── Real MPT world-state construction ───────────────────────────────────────────────────────── + +const WorldState = struct { + root: [32]u8, + nodes: []const []const u8, +}; + +fn collectNodeRlps(alloc: std.mem.Allocator, index: *mpt.NodeIndex) ![]const []const u8 { + var out = std.ArrayListUnmanaged([]const u8).empty; + var it = index.valueIterator(); + while (it.next()) |v| try out.append(alloc, v.*); + return out.toOwnedSlice(alloc); +} + +fn buildAccountRlp(alloc: std.mem.Allocator, nonce: u64, balance: u256, storage_root: [32]u8, code_hash: [32]u8) ![]const u8 { + const items = [_][]const u8{ + try rlp.encodeU64(alloc, nonce), + try rlp.encodeU256(alloc, balance), + try rlp.encodeBytes(alloc, &storage_root), + try rlp.encodeBytes(alloc, &code_hash), + }; + return rlp.encodeList(alloc, &items); +} + +/// A real MPT world state for the L2MessageService's two bridge storage slots. `null` `l2ms_address` +/// (bridge-suppressed mode) yields the canonical empty-trie root and no nodes at all — genuinely +/// correct MPT semantics for an account-less trie, not a shortcut. Otherwise, a single account leaf +/// whose storage trie holds `bridge.number`/`bridge.hash` at the guest's own slot layout; a zero +/// value is naturally omitted by the trie, matching real EVM "unset slot" semantics. +fn buildWorld(alloc: std.mem.Allocator, l2ms_address: ?[20]u8, bridge: BridgeValue) !WorldState { + const address = l2ms_address orelse return .{ .root = mpt.builder.EMPTY_TRIE_HASH, .nodes = &.{} }; + + var index = try mpt.buildNodeIndex(alloc, &.{}); + + var storage_root = mpt.builder.EMPTY_TRIE_HASH; + const number_slot = l2_execution.test_api.u64ToSlot32Fn(LAST_ANCHORED_L1_MESSAGE_NUMBER_SLOT); + try mpt.updateStorageChainedIndexed(alloc, &storage_root, number_slot, @as(u256, bridge.number), &index); + + const rolling_hash_slot = l2_execution.test_api.mappingSlotFn( + l2_execution.test_api.u64ToSlot32Fn(L1_ROLLING_HASHES_MAPPING_BASE_SLOT), + l2_execution.test_api.u64ToSlot32Fn(bridge.number), + ); + const hash_value = std.mem.readInt(u256, &bridge.hash, .big); + try mpt.updateStorageChainedIndexed(alloc, &storage_root, rolling_hash_slot, hash_value, &index); + + const account_rlp = try buildAccountRlp(alloc, 0, 0, storage_root, primitives.KECCAK_EMPTY); + var state_root = mpt.builder.EMPTY_TRIE_HASH; + try mpt.updateAccountChainedIndexed(alloc, &state_root, mpt.keccak256(&address), account_rlp, &index); + + return .{ .root = state_root, .nodes = try collectNodeRlps(alloc, &index) }; +} + +// ─── Real header construction ────────────────────────────────────────────────────────────────── + +/// A real, RLP-list-encoded Ethereum block header carrying the given parent hash, number, state +/// root, and timestamp — every other field a realistic constant. `gas_used` is pinned at exactly +/// half of `gas_limit` (the EIP-1559 gas target), so a constant `base_fee_per_gas` across the +/// whole header chain reproduces itself under the real base-fee formula, matching this DSL's +/// constant-base-fee payload default. Stops right after `base_fee_per_gas` (fields 0-15): the +/// guest's own header consumers (`rlp_decode.findPreStateRoot`, and `decodeParentHeader` on the +/// real execution path) read no field past it, so every later-fork optional field is simply +/// absent rather than needing invented values for something nothing here checks. +fn buildHeader(alloc: std.mem.Allocator, parent_hash: [32]u8, number: u64, state_root: [32]u8, timestamp: u64) ![]const u8 { + const zero_hash: [32]u8 = @splat(0); + const zero_addr: [20]u8 = @splat(0); + const zero_bloom: [256]u8 = @splat(0); + + const items = [_][]const u8{ + try rlp.encodeBytes(alloc, &parent_hash), // [0] + try rlp.encodeBytes(alloc, &zero_hash), // [1] ommers_hash + try rlp.encodeBytes(alloc, &zero_addr), // [2] beneficiary + try rlp.encodeBytes(alloc, &state_root), // [3] + try rlp.encodeBytes(alloc, &zero_hash), // [4] transactions_root + try rlp.encodeBytes(alloc, &zero_hash), // [5] receipts_root + try rlp.encodeBytes(alloc, &zero_bloom), // [6] logs_bloom + try rlp.encodeU64(alloc, 0), // [7] difficulty + try rlp.encodeU64(alloc, number), // [8] + try rlp.encodeU64(alloc, DEFAULT_GAS_LIMIT), // [9] + try rlp.encodeU64(alloc, DEFAULT_GAS_LIMIT / 2), // [10] gas_used == gas target + try rlp.encodeU64(alloc, timestamp), // [11] + try rlp.encodeBytes(alloc, &.{}), // [12] extra_data + try rlp.encodeBytes(alloc, &zero_hash), // [13] mix_hash + try rlp.encodeU64(alloc, 0), // [14] nonce + try rlp.encodeU64(alloc, DEFAULT_BASE_FEE), // [15] base_fee_per_gas + }; + return rlp.encodeList(alloc, &items); +} + +fn ownedSingleHeader(alloc: std.mem.Allocator, header: []const u8) ![]const []const u8 { + const out = try alloc.alloc([]const u8, 1); + out[0] = header; + return out; +} + +// ─── The plan ─────────────────────────────────────────────────────────────────────────────────── + +pub const ConflationPlan = struct { + /// One entry per block; the slice length IS the range's block count (empty selects the + /// `EmptyPayloads` rejection case). + blocks: []const BlockPlan = &TWO_DEFAULT_BLOCKS, + + chain_id: u64 = 59144, + coinbase: [20]u8 = DEFAULT_COINBASE, + /// Zero means bridge-suppressed mode: the L1<->L2 bridge rolling-hash reads and the L2->L1 + /// message scan are both skipped. + l2_message_service_address: [20]u8 = ZERO_ADDRESS, + parent_ftx_rolling_hash: [32]u8 = ZERO_HASH, + parent_last_processed_ftx_number: u64 = 0, + /// The first block's number. 0 selects genesis mode: a real parent header cannot exist for + /// block 0, so `payloads[0]`'s parent_hash is the all-zero hash and its witness carries no + /// parent header at all. + start_block_number: u64 = 1_000_000, + + bridge_parent: ?BridgeValue = null, + bridge_end: ?BridgeValue = null, + + // ── Post-derivation hooks, applied to the built (not-yet-encoded) value ── + /// Truncates payload `i`'s encoded stateless_input_ssz bytes so the vanilla SSZ decoder + /// rejects them outright. + corrupt_stateless_input_at: ?usize = null, + /// Overrides payload `i`'s parent_hash after derivation, breaking the natural hash chain. + override_parent_hash_at: ?ParentHashOverride = null, + /// Drops payload `i`'s witness headers entirely. + drop_witness_headers_at: ?usize = null, + /// Forces `payloads[0]`'s parent_hash to a nonzero value — meaningful in genesis mode, where + /// the natural derived value is the all-zero hash and the guest requires exactly that. + override_genesis_parent_hash: ?[32]u8 = null, + + pub const ParentHashOverride = struct { index: usize, parent_hash: [32]u8 }; + + /// Declares this range's L1<->L2 bridge storage at the range's pre-state (`.parent`) or + /// post-state (`.end`) — realized as real storage under the L2MessageService's own layout by + /// `build()`. These declared values are only realized in the trie if + /// `l2_message_service_address` is non-zero at build time — a zero address keeps the range + /// suppressed and the declared values are simply never observed. + pub fn bridgeStorage(self: *ConflationPlan, which: enum { parent, end }, value: BridgeValue) void { + switch (which) { + .parent => self.bridge_parent = value, + .end => self.bridge_end = value, + } + } + + /// Derives a fully self-consistent, real-MPT/real-header multi-block input, applies any + /// declared hooks, and encodes every payload's vanilla bytes — everything `run()` needs short + /// of the outer envelope round-trip. + pub fn build(self: ConflationPlan, alloc: std.mem.Allocator) !Built { + const n = self.blocks.len; + const chain_config = l2_execution_ssz.ChainConfig{ + .l2_message_service_address = self.l2_message_service_address, + .coinbase = self.coinbase, + .chain_id = self.chain_id, + }; + + if (n == 0) { + return .{ + .blocks_len = 0, + .tx_logs = &.{}, + .world1_nodes = &.{}, + .envelope = .{ + .parent_ftx_rolling_hash = self.parent_ftx_rolling_hash, + .parent_last_processed_ftx_number = self.parent_last_processed_ftx_number, + .chain_config = chain_config, + .payloads = &.{}, + }, + }; + } + + const genesis = self.start_block_number == 0; + const l2ms_for_world: ?[20]u8 = if (isZeroAddress(self.l2_message_service_address)) null else self.l2_message_service_address; + const world0_bridge = self.bridge_parent orelse BridgeValue{}; + const world0 = try buildWorld(alloc, l2ms_for_world, world0_bridge); + // Inherits world0's values (no divergence) unless `.end` was declared — "equal to world0 + // when no bridge storage declared" holds field-by-field, not just in the no-bridge-at-all + // case. + const world1_bridge = self.bridge_end orelse world0_bridge; + const world1 = try buildWorld(alloc, l2ms_for_world, world1_bridge); + + // ── Headers: a real hash chain. header[i] represents block (start+i); its state_root is + // that block's OWN post-state (world0 for every block but the last, world1 for the last) + // — exactly mirroring payload[i].state_root, since a header's state_root IS its block's + // post-execution root. The range parent header (block start-1, non-genesis only) carries + // world0's root: the range's pre-state is, by definition, block `start`'s pre-state. + // + // Genesis mode's block 0 has no witnessable pre-state at all (the real engine's own + // pre_state_root fallback is `payload.state_root` itself whenever no header resolves) — + // this DSL derives a distinct world0/world1 pair meaningfully whenever `n > 1`, or + // whenever the range starts after genesis. + var headers = try alloc.alloc([]const u8, n); + const first_ts = self.blocks[0].timestamp orelse defaultTimestamp(0); + var range_parent_header: ?[]const u8 = null; + var built_parent_block_hash: [32]u8 = ZERO_HASH; + var built_end_block_hash: [32]u8 = ZERO_HASH; + if (!genesis) { + const parent_ts = if (first_ts >= BLOCK_TIME_SECONDS) first_ts - BLOCK_TIME_SECONDS else 0; + range_parent_header = try buildHeader(alloc, ZERO_HASH, self.start_block_number - 1, world0.root, parent_ts); + } + var prev_header: ?[]const u8 = range_parent_header; + for (0..n) |i| { + const ts = self.blocks[i].timestamp orelse defaultTimestamp(i); + const state_root_i = if (i == n - 1) world1.root else world0.root; + const parent_hash_field = if (prev_header) |h| mpt.keccak256(h) else ZERO_HASH; + headers[i] = try buildHeader(alloc, parent_hash_field, self.start_block_number + i, state_root_i, ts); + prev_header = headers[i]; + } + + // ── Payloads: one vanilla StatelessInput per block, each witnessing only its own parent + // header (genesis's block 0 witnesses none), then hook-adjusted and encoded. + var payloads = try alloc.alloc(l2_execution_ssz.LineaPayloadInput, n); + var tx_logs = try alloc.alloc([]const []const types.Log, n); + + for (0..n) |i| { + const block = self.blocks[i]; + const witness_header: ?[]const u8 = if (i == 0) range_parent_header else headers[i - 1]; + const natural_parent_hash = if (witness_header) |h| mpt.keccak256(h) else ZERO_HASH; + const parent_hash_field = if (i == 0) (self.override_genesis_parent_hash orelse natural_parent_hash) else natural_parent_hash; + const state_root_i = if (i == n - 1) world1.root else world0.root; + + var si = input.StatelessInput{ + .new_payload_request = .{ + .execution_payload = .{ + .parent_hash = parent_hash_field, + .fee_recipient = block.fee_recipient orelse self.coinbase, + .state_root = state_root_i, + .receipts_root = ZERO_HASH, + .logs_bloom = @splat(0), + .prev_randao = ZERO_HASH, + .block_number = self.start_block_number + i, + .gas_limit = DEFAULT_GAS_LIMIT, + .gas_used = DEFAULT_GAS_LIMIT / 2, + .timestamp = block.timestamp orelse defaultTimestamp(i), + .extra_data = &.{}, + .base_fee_per_gas = block.base_fee orelse DEFAULT_BASE_FEE, + .block_hash = mpt.keccak256(headers[i]), + .transactions = &.{}, + .raw_transactions = block.signed_tx_rlps, + .withdrawals = if (block.non_empty_withdrawals) &NON_EMPTY_WITHDRAWALS else &.{}, + .blob_gas_used = 0, + .excess_blob_gas = 0, + .slot_number = self.start_block_number + i, + .block_access_list = &.{}, + }, + .parent_beacon_block_root = ZERO_HASH, + .versioned_hashes = &.{}, + .execution_requests = if (block.non_empty_execution_requests) + .{ .deposits = &NON_EMPTY_DEPOSIT_BYTES } + else + .{}, + }, + .witness = .{ + .nodes = world0.nodes, + .codes = &.{}, + .headers = if (witness_header) |h| try ownedSingleHeader(alloc, h) else &.{}, + }, + .chain_config = .{ + .chain_id = block.chain_id orelse self.chain_id, + .active_fork_idx = block.active_fork_idx orelse AMSTERDAM_FORK_BYTE, + .activation_block = 0, + .activation_timestamp = null, + }, + .public_keys = &.{}, + }; + + if (self.drop_witness_headers_at) |idx| { + if (idx == i) si.witness.headers = &.{}; + } + if (self.override_parent_hash_at) |o| { + if (o.index == i) si.new_payload_request.execution_payload.parent_hash = o.parent_hash; + } + + if (i == 0) built_parent_block_hash = si.new_payload_request.execution_payload.parent_hash; + if (i == n - 1) built_end_block_hash = si.new_payload_request.execution_payload.block_hash; + + var ssz_bytes = try stateless_input_encode.encode(alloc, si); + if (self.corrupt_stateless_input_at) |idx| { + if (idx == i) ssz_bytes = ssz_bytes[0..@min(ssz_bytes.len, 1)]; + } + + payloads[i] = .{ .stateless_input_ssz = ssz_bytes, .forced_transactions = block.forced_transactions }; + tx_logs[i] = block.tx_logs; + } + + return .{ + .blocks_len = n, + .tx_logs = tx_logs, + .world1_nodes = world1.nodes, + .envelope = .{ + .parent_ftx_rolling_hash = self.parent_ftx_rolling_hash, + .parent_last_processed_ftx_number = self.parent_last_processed_ftx_number, + .chain_config = chain_config, + .payloads = payloads, + }, + .parent_block_hash = built_parent_block_hash, + .end_block_hash = built_end_block_hash, + }; + } + + /// Builds the plan, round-trips the whole envelope through the guest's real SSZ byte path + /// (`l2_execution_ssz.encodeInput`/`decodeInput`), then drives it through + /// `l2_execution.runL2ExecutionWithEngine` bound to `StubEngine` — so every scenario walks + /// the guest's real conflation logic end to end, on real bytes, with only per-block execution + /// stubbed out. + pub fn run(self: ConflationPlan, alloc: std.mem.Allocator) !l2_execution_ssz.L2ExecutionProofOutput { + const built = try self.build(alloc); + const raw = try l2_execution_ssz.encodeInput(alloc, built.envelope); + const decoded = try l2_execution_ssz.decodeInput(alloc, raw); + + StubEngine.active = &built; + StubEngine.next_index = 0; + defer StubEngine.active = null; + + return l2_execution.runL2ExecutionWithEngine(StubEngine, alloc, decoded); + } + + /// Runs the plan and asserts it fails with exactly `expected_error`. + pub fn expectReject(self: ConflationPlan, alloc: std.mem.Allocator, expected_error: anyerror) !void { + try std.testing.expectError(expected_error, self.run(alloc)); + } +}; diff --git a/riscv-guests/l2-execution/test/conflation_plan_parity_test.zig b/riscv-guests/l2-execution/test/conflation_plan_parity_test.zig new file mode 100644 index 0000000000..f1f3ff04da --- /dev/null +++ b/riscv-guests/l2-execution/test/conflation_plan_parity_test.zig @@ -0,0 +1,113 @@ +//! Stub-realism guard for the conflation-plan test DSL's `StubEngine`. +//! +//! One test proves `StubEngine` (with no plan driving it) computes the same roots and receipt +//! count as the real per-block execution seam on real data — the guarantee every scenario built +//! on this DSL implicitly relies on. A second, separate test smoke-checks the `ConflationPlan` +//! DSL itself: a default plan should run clean through the guest's real conflation logic before +//! any scenario suite is built on top of it. A third calls the real per-block seam directly, the +//! same way the first test does, to prove its own rejection of an unresolvable, non-genesis +//! parent header — independent of any conflation-level gate. + +const std = @import("std"); +const testing = std.testing; + +const fixtures = @import("evm_execution_fixtures"); +const ssz_decode = @import("zesu_ssz_decode"); +const zesu_allocator = @import("zesu_allocator"); +const mpt = @import("zesu_mpt"); +const input = @import("zesu_input"); +const l2_execution = @import("l2_execution"); +const conflation_plan = @import("conflation_plan.zig"); + +test "StubEngine with no active plan matches the real execution seam on the committed EF fixture" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const fixture = try fixtures.loadStatelessBlock(alloc, fixtures.embedded.zkevm_stateless_block); + const si = try ssz_decode.decode(alloc, fixture.input); + + // executeStatelessInputWithLogs (both the real one and the stub) expects the zesu_allocator + // singleton set by the caller. + zesu_allocator.set(alloc); + + var real_index = try mpt.buildNodeIndex(alloc, si.witness.nodes); + defer real_index.deinit(); + const real = try l2_execution.test_api.executeStatelessInputWithLogsFn(alloc, si, si.chain_config.fork_name.?, &real_index); + + var stub_index = try mpt.buildNodeIndex(alloc, si.witness.nodes); + defer stub_index.deinit(); + conflation_plan.StubEngine.active = null; + const stub = try conflation_plan.StubEngine.executeStatelessInputWithLogs(alloc, si, si.chain_config.fork_name.?, &stub_index); + + try testing.expectEqualSlices(u8, &real.pre_state_root, &stub.pre_state_root); + try testing.expectEqualSlices(u8, &real.post_state_root, &stub.post_state_root); + try testing.expectEqualSlices(u8, &real.receipts_root, &stub.receipts_root); + try testing.expectEqual(real.receipts.len, stub.receipts.len); + try testing.expectEqualStrings(real.fork_name, stub.fork_name); +} + +test "a default 2-block ConflationPlan runs through StubEngine end to end" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const plan = conflation_plan.ConflationPlan{}; + const output = try plan.run(alloc); + + try testing.expectEqual(plan.start_block_number, output.start_block_number); + try testing.expectEqual(plan.start_block_number + 1, output.public_inputs.end_block_number); +} + +test "executeStatelessInputWithLogs rejects a non-genesis block with no resolvable parent header witness" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // executeStatelessInputWithLogs expects the zesu_allocator singleton set by the caller (see + // the first test above). + zesu_allocator.set(alloc); + + // A minimal StatelessInput: block_number and parent_hash are both nonzero — not genesis — and + // witness.headers is empty, so no header can ever resolve a pre-state root. The seam must + // reject at this very first step, before touching any other field, so everything else below + // is zeroed. + const si = input.StatelessInput{ + .new_payload_request = .{ + .execution_payload = .{ + .parent_hash = @splat(0xaa), + .fee_recipient = @splat(0), + .state_root = @splat(0), + .receipts_root = @splat(0), + .logs_bloom = @splat(0), + .prev_randao = @splat(0), + .block_number = 5, + .gas_limit = 0, + .gas_used = 0, + .timestamp = 0, + .extra_data = &.{}, + .base_fee_per_gas = 0, + .block_hash = @splat(0), + .transactions = &.{}, + .withdrawals = &.{}, + .blob_gas_used = 0, + .excess_blob_gas = 0, + }, + .parent_beacon_block_root = @splat(0), + }, + .witness = .{ + .nodes = &.{}, + .codes = &.{}, + .headers = &.{}, + }, + }; + + var node_index = try mpt.buildNodeIndex(alloc, &.{}); + defer node_index.deinit(); + + // fork_name is never inspected: rejection happens before the fork lookup that would use it. + try testing.expectError( + error.MissingParentHeaderWitness, + l2_execution.test_api.executeStatelessInputWithLogsFn(alloc, si, "Amsterdam", &node_index), + ); +} diff --git a/riscv-guests/l2-execution/test/l2_execution_range_test.zig b/riscv-guests/l2-execution/test/l2_execution_range_test.zig new file mode 100644 index 0000000000..aacc2ecac6 --- /dev/null +++ b/riscv-guests/l2-execution/test/l2_execution_range_test.zig @@ -0,0 +1,377 @@ +//! Scenario tests for the l2-execution guest's conflation logic, built on the plan DSL. +//! +//! One rich happy-path scenario exercises every public-input field at once over a realistic +//! 2-block range: real signed transactions, L2->L1 bridge messages, L1<->L2 bridge storage, and +//! forced transactions spanning both blocks — checked against independently-derived expected +//! values. Twelve one-mutation scenarios each drift a single field or hook away from a realistic +//! default range, one per rejection the guest's conflation logic enforces. + +const std = @import("std"); +const testing = std.testing; + +const executor = @import("zesu_executor"); +const mpt = @import("zesu_mpt"); +const secp256k1 = @import("zesu_secp256k1"); +const l2_execution = @import("l2_execution"); +const l2_execution_ssz = @import("l2_execution_ssz"); +const conflation_plan = @import("conflation_plan.zig"); +const legacy_tx_rlp = @import("legacy_tx_rlp"); + +const types = executor.executor_types; +const api = l2_execution.test_api; + +const ZERO_HASH: [32]u8 = @splat(0); + +/// The plan DSL's own default base fee. The chain-config hash formula takes it as a separate +/// argument (it is not itself a `ChainConfig` field), so computing the expected +/// `dynamic_chain_config_hash` needs this value directly. +const RANGE_BASE_FEE: u64 = 1_000_000_000; + +// ─── A realistic 2-block range exercising every public-input field at once ───────────────────── +// +// Four secp256k1-signed legacy (type-0) transactions for chain_id 59144, built and signed live in +// this file via zesu's own real secp256k1 backend (`buildSignedFixtureTx`, below) — the same +// signing primitive the guest's own sender-recovery (`recoverFixtureSender`) already trusts for +// verification. Shared fields: gasPrice=1e9, gas=21000, to=0xbb*20, data=b""; each tx has its own +// nonce (0-3), value (1000/2000/3000/4000), and private key +// (keccak256("l2exec-range-fixture/T"), one label per tx). T1 and T2 ride in block 0, T3 in +// block 1; T4 never appears in a block, only as the second forced transaction's witness. +// +// Senders are NOT hand-frozen: `recoverFixtureSender` derives each one from the tx bytes below, +// so it can never drift out of sync with them. The scenario test's own +// `expected_end_ftx_rolling_hash` likewise derives from these transactions' own bytes rather than +// a pinned literal, so it too can never drift out of sync with them. + +/// The plan DSL's own default chain_id (`ConflationPlan.chain_id`'s default), needed directly +/// here since sender recovery (EIP-155: recid comes from `v - chain_id*2 - 35`) happens before +/// the plan is built. +const RANGE_CHAIN_ID: u64 = 59144; + +/// Fields shared by all four range fixtures (T1-T4); only `nonce`/`value` vary per tx (see each +/// `buildSignedFixtureTx` call site in the scenario test below). +const RANGE_TX_GAS_PRICE: u128 = 1_000_000_000; +const RANGE_TX_GAS: u64 = 21_000; +const RANGE_TX_TO: [20]u8 = @splat(0xbb); + +/// Deterministic per-tx private key: keccak256("l2exec-range-fixture/T"), one label per tx. +fn fixturePrivateKey(comptime label: []const u8) [32]u8 { + return mpt.keccak256("l2exec-range-fixture/" ++ label); +} + +/// Builds and signs one of T1-T4 live: RLP-encodes the unsigned EIP-155 preimage +/// `[nonce, gasPrice, gas, to, value, data="", chainId, "", ""]` (`buildLegacyTxRlp` with +/// `v=chainId, r=s=0`), hashes it (keccak256), signs with the label's deterministic private key +/// via zesu's real secp256k1 backend, then re-encodes with the derived `v = chainId*2 + 35 + +/// recid`. libsecp256k1's signing is RFC-6979 (deterministic nonce), so the same +/// (label, nonce, value) triple always produces the same signature bytes, run to run. +fn buildSignedFixtureTx(alloc: std.mem.Allocator, comptime label: []const u8, nonce: u64, value: u256) ![]const u8 { + const private_key = fixturePrivateKey(label); + const unsigned_rlp = try legacy_tx_rlp.buildLegacyTxRlp(alloc, nonce, RANGE_TX_GAS_PRICE, RANGE_TX_GAS, RANGE_TX_TO, value, &.{}, RANGE_CHAIN_ID, 0, 0); + const msg_hash = mpt.keccak256(unsigned_rlp); + + const ctx = secp256k1.getContext() orelse return error.Secp256k1ContextUnavailable; + const signature = ctx.sign(msg_hash, private_key) orelse return error.FixtureTxSigningFailed; + const r = std.mem.readInt(u256, signature.sig[0..32], .big); + const s = std.mem.readInt(u256, signature.sig[32..64], .big); + const v: u256 = @as(u256, RANGE_CHAIN_ID) * 2 + 35 + @as(u256, signature.recid); + + return legacy_tx_rlp.buildLegacyTxRlp(alloc, nonce, RANGE_TX_GAS_PRICE, RANGE_TX_GAS, RANGE_TX_TO, value, &.{}, v, r, s); +} + +fn recoverFixtureSender(alloc: std.mem.Allocator, signed_tx_rlp: []const u8, chain_id: u64) ![20]u8 { + const decoded = try executor.executor_tx_decode.decodeTxs(alloc, &.{signed_tx_rlp}); + var tx = decoded[0]; + return (try api.recoverSenderFn(alloc, &tx, chain_id)).?; +} + +/// L2MessageService's `MessageSent` event topic0, copied from the guest's own constant of the +/// same value (the log-matching logic it feeds is otherwise private to the guest). +const BRIDGE_MESSAGE_SENT_TOPIC0: [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, +}; +const NON_BRIDGE_TOPIC0: [32]u8 = @splat(0x01); +const MESSAGE_HASH_1: [32]u8 = @splat(0x51); +const MESSAGE_HASH_2: [32]u8 = @splat(0x52); + +const PARENT_BRIDGE_HASH: [32]u8 = @splat(0x11); +const END_BRIDGE_HASH: [32]u8 = @splat(0x22); + +/// Comfortably above the plan DSL's own default range (block numbers ~1_000_000/1_000_001), so +/// both forced transactions below clear their deadline check regardless of which block handles +/// them. +const FTX_DEADLINE: u64 = 2_000_000; + +/// Mirrors the plan DSL's own base timestamp (1_700_000_000) plus one 12-second block time. +const EXPECTED_BLOCK1_TIMESTAMP: u64 = 1_700_000_012; + +/// A log at a fixed placeholder position (this guest's message extraction only ever inspects +/// `address`/`topics`), duplicating `topics` onto the allocator like a real per-block execution's +/// own log construction would. +fn testLog(alloc: std.mem.Allocator, address: [20]u8, topics: []const [32]u8) !types.Log { + return .{ + .address = address, + .topics = try alloc.dupe([32]u8, topics), + .data = &.{}, + .block_number = 0, + .tx_hash = ZERO_HASH, + .tx_index = 0, + .block_hash = ZERO_HASH, + .log_index = 0, + }; +} + +test "a realistic 2-block range produces every public-input field exactly" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // T1: nonce=0, value=1000, sender rides in block 0 and doubles as FTX1's INCLUDED witness. + const t1_rlp = try buildSignedFixtureTx(alloc, "T1", 0, 1000); + // T2: nonce=1, value=2000, sender rides in block 0 alongside T1. + const t2_rlp = try buildSignedFixtureTx(alloc, "T2", 1, 2000); + // T3: nonce=2, value=3000, sender rides in block 1. + const t3_rlp = try buildSignedFixtureTx(alloc, "T3", 2, 3000); + // T4: nonce=3, value=4000, sender never rides in a block — only bubbles up as FTX2's + // FILTERED_ADDRESS_FROM witness. + const t4_rlp = try buildSignedFixtureTx(alloc, "T4", 3, 4000); + + const msg_log_1 = try testLog(alloc, conflation_plan.DEFAULT_L2_MESSAGE_SERVICE_ADDRESS, &.{ BRIDGE_MESSAGE_SENT_TOPIC0, ZERO_HASH, ZERO_HASH, MESSAGE_HASH_1 }); + const non_matching_log = try testLog(alloc, conflation_plan.DEFAULT_L2_MESSAGE_SERVICE_ADDRESS, &.{ NON_BRIDGE_TOPIC0, ZERO_HASH, ZERO_HASH, ZERO_HASH }); + const msg_log_2 = try testLog(alloc, conflation_plan.DEFAULT_L2_MESSAGE_SERVICE_ADDRESS, &.{ BRIDGE_MESSAGE_SENT_TOPIC0, ZERO_HASH, ZERO_HASH, MESSAGE_HASH_2 }); + + // Senders are derived from the tx bytes themselves, not hand-copied literals — see + // `recoverFixtureSender`'s doc comment above. + const t1_sender = try recoverFixtureSender(alloc, t1_rlp, RANGE_CHAIN_ID); + const t2_sender = try recoverFixtureSender(alloc, t2_rlp, RANGE_CHAIN_ID); + const t3_sender = try recoverFixtureSender(alloc, t3_rlp, RANGE_CHAIN_ID); + const t4_sender = try recoverFixtureSender(alloc, t4_rlp, RANGE_CHAIN_ID); + + // end_ftx_rolling_hash, computed independently from FTX1/FTX2's own derived (tx_hash, sender) + // via the SAME already-trusted rolling-hash primitive the guest's own FTX loop uses + // internally — this composition checks that the scenario's OWN FTX loop applies that + // primitive to the right data in the right order, starting from zero32 and chained through + // FTX1 (T1) then FTX2 (T4), exactly mirroring runL2Execution's FTX loop. + const t1_tx_hash = mpt.keccak256(t1_rlp); + const t4_tx_hash = mpt.keccak256(t4_rlp); + const ftx_rolling_hash_after_ftx1 = api.addToForcedTxRollingHashFn(ZERO_HASH, t1_tx_hash, FTX_DEADLINE, t1_sender); + const expected_end_ftx_rolling_hash = api.addToForcedTxRollingHashFn(ftx_rolling_hash_after_ftx1, t4_tx_hash, FTX_DEADLINE, t4_sender); + + const ftx1 = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = t1_rlp, + .acceptance = api.Acceptance.INCLUDED, + .deadline = FTX_DEADLINE, + }; + const ftx2 = l2_execution_ssz.ForcedTransactionWitness{ + .number = 2, + .signed_tx_rlp = t4_rlp, + .acceptance = api.Acceptance.FILTERED_ADDRESS_FROM, + .deadline = FTX_DEADLINE, + }; + + const block0_logs = [_][]const types.Log{ &.{msg_log_1}, &.{non_matching_log} }; + const block1_logs = [_][]const types.Log{&.{msg_log_2}}; + const blocks = [_]conflation_plan.BlockPlan{ + .{ + .signed_tx_rlps = &.{ t1_rlp, t2_rlp }, + .tx_logs = &block0_logs, + .forced_transactions = &.{ftx1}, + }, + .{ + .signed_tx_rlps = &.{t3_rlp}, + .tx_logs = &block1_logs, + .forced_transactions = &.{ftx2}, + }, + }; + var plan = conflation_plan.ConflationPlan{ + .blocks = &blocks, + .parent_last_processed_ftx_number = 0, + .l2_message_service_address = conflation_plan.DEFAULT_L2_MESSAGE_SERVICE_ADDRESS, + }; + plan.bridgeStorage(.parent, .{ .number = 5, .hash = PARENT_BRIDGE_HASH }); + plan.bridgeStorage(.end, .{ .number = 7, .hash = END_BRIDGE_HASH }); + + const built = try plan.build(alloc); + const output = try plan.run(alloc); + + // Header chain: the guest only ever parrots these back, so they're checked against the DSL's + // own independently-derived header hashes rather than the guest's own pass-through logic. + try testing.expectEqualSlices(u8, &built.parent_block_hash, &output.public_inputs.parent_block_hash); + try testing.expectEqualSlices(u8, &built.end_block_hash, &output.public_inputs.end_block_hash); + try testing.expectEqual(plan.start_block_number, output.start_block_number); + try testing.expectEqual(plan.start_block_number + 1, output.public_inputs.end_block_number); + try testing.expectEqual(EXPECTED_BLOCK1_TIMESTAMP, output.public_inputs.end_block_timestamp); + + // L2->L1 messages, in block order; T2's non-matching-topic0 log is collected by neither. + const expected_messages = [_][32]u8{ MESSAGE_HASH_1, MESSAGE_HASH_2 }; + const expected_messages_hash = try api.hashDigestListFn(alloc, &expected_messages); + try testing.expectEqualSlices(u8, &expected_messages_hash, &output.public_inputs.l2_l1_messages_hash); + try testing.expectEqual(@as(usize, 2), output.l2_l1_messages.len); + try testing.expectEqualSlices(u8, &MESSAGE_HASH_1, &output.l2_l1_messages[0]); + try testing.expectEqualSlices(u8, &MESSAGE_HASH_2, &output.l2_l1_messages[1]); + + // L1<->L2 bridge: parent/end numbers and hashes echo exactly what bridgeStorage declared. + try testing.expectEqual(@as(u64, 5), output.public_inputs.parent_l1_l2_bridge_rolling_hash_message_number); + try testing.expectEqualSlices(u8, &PARENT_BRIDGE_HASH, &output.public_inputs.parent_l1_l2_bridge_rolling_hash); + try testing.expectEqual(@as(u64, 7), output.public_inputs.end_l1_l2_bridge_rolling_hash_message_number); + try testing.expectEqualSlices(u8, &END_BRIDGE_HASH, &output.public_inputs.end_l1_l2_bridge_rolling_hash); + + // Chain config hash over the range's real address/coinbase/chainId, at the range's base fee. + const chain_config = l2_execution_ssz.ChainConfig{ + .l2_message_service_address = plan.l2_message_service_address, + .coinbase = plan.coinbase, + .chain_id = plan.chain_id, + }; + const expected_chain_config_hash = api.chainConfigHashFn(chain_config, RANGE_BASE_FEE); + try testing.expectEqualSlices(u8, &expected_chain_config_hash, &output.public_inputs.dynamic_chain_config_hash); + + // Forced transactions: FTX1 (INCLUDED, tx=T1) and FTX2 (FILTERED_ADDRESS_FROM, tx=T4) both + // update the rolling hash across the range; only FTX2 bubbles up a filtered address. + try testing.expectEqualSlices(u8, &plan.parent_ftx_rolling_hash, &output.public_inputs.parent_ftx_rolling_hash); + try testing.expectEqual(plan.parent_last_processed_ftx_number, output.public_inputs.parent_processed_ftx_number); + try testing.expectEqual(@as(u64, 2), output.public_inputs.end_processed_ftx_number); + try testing.expectEqualSlices(u8, &expected_end_ftx_rolling_hash, &output.public_inputs.end_ftx_rolling_hash); + + const expected_filtered_hash = try api.hashAddressListFn(alloc, &.{t4_sender}); + try testing.expectEqualSlices(u8, &expected_filtered_hash, &output.public_inputs.filtered_addresses_hash); + try testing.expectEqual(@as(usize, 1), output.filtered_addresses.len); + try testing.expectEqualSlices(u8, &t4_sender, &output.filtered_addresses[0]); + + // tx_froms, in block-then-transaction order: T1's sender, T2's sender, then T3's sender. + const expected_tx_froms = [_][20]u8{ t1_sender, t2_sender, t3_sender }; + const expected_tx_froms_hash = try api.hashAddressListFn(alloc, &expected_tx_froms); + try testing.expectEqualSlices(u8, &expected_tx_froms_hash, &output.public_inputs.tx_froms_hash); + try testing.expectEqual(@as(usize, 3), output.tx_froms.len); + try testing.expectEqualSlices(u8, &t1_sender, &output.tx_froms[0]); + try testing.expectEqualSlices(u8, &t2_sender, &output.tx_froms[1]); + try testing.expectEqualSlices(u8, &t3_sender, &output.tx_froms[2]); +} + +// ─── One mutation away from a realistic default range ────────────────────────────────────────── +// +// Each scenario takes the plan DSL's own 2-block default range and drifts exactly one field or +// hook away from it, catching the guest's conflation-level checks the same way a real range +// would: a violation introduced mid-range, not just at the first block. + +const JUNK_HASH: [32]u8 = @splat(0xfe); +const NON_COINBASE_ADDRESS: [20]u8 = @splat(0xfa); + +test "an empty payload list is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const plan = conflation_plan.ConflationPlan{ .blocks = &.{} }; + try plan.expectReject(arena.allocator(), error.EmptyPayloads); +} + +test "a corrupted stateless input is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const plan = conflation_plan.ConflationPlan{ .corrupt_stateless_input_at = 1 }; + try plan.expectReject(arena.allocator(), error.InvalidStatelessInput); +} + +test "a mismatched inner chain id is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const blocks = [_]conflation_plan.BlockPlan{ .{}, .{ .chain_id = 1 } }; + const plan = conflation_plan.ConflationPlan{ .blocks = &blocks }; + try plan.expectReject(arena.allocator(), error.ChainIdMismatch); +} + +test "a broken parent-hash chain is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const plan = conflation_plan.ConflationPlan{ + .override_parent_hash_at = .{ .index = 1, .parent_hash = JUNK_HASH }, + }; + try plan.expectReject(arena.allocator(), error.ParentHashChainMismatch); +} + +test "a non-constant base fee is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const blocks = [_]conflation_plan.BlockPlan{ .{}, .{ .base_fee = RANGE_BASE_FEE + 1 } }; + const plan = conflation_plan.ConflationPlan{ .blocks = &blocks }; + try plan.expectReject(arena.allocator(), error.BaseFeeNotConstant); +} + +test "a fee recipient other than the range's coinbase is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const blocks = [_]conflation_plan.BlockPlan{ .{}, .{ .fee_recipient = NON_COINBASE_ADDRESS } }; + const plan = conflation_plan.ConflationPlan{ .blocks = &blocks }; + try plan.expectReject(arena.allocator(), error.FeeRecipientMismatch); +} + +test "a missing parent header witness is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const plan = conflation_plan.ConflationPlan{ .drop_witness_headers_at = 1 }; + try plan.expectReject(arena.allocator(), error.MissingParentHeaderWitness); +} + +test "a genesis range with a zero parent hash is accepted" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const blocks = [_]conflation_plan.BlockPlan{.{}}; + const plan = conflation_plan.ConflationPlan{ .start_block_number = 0, .blocks = &blocks }; + const output = try plan.run(arena.allocator()); + try testing.expectEqual(@as(u64, 0), output.public_inputs.end_block_number); +} + +test "a genesis range with a nonzero parent hash is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const blocks = [_]conflation_plan.BlockPlan{.{}}; + const plan = conflation_plan.ConflationPlan{ + .start_block_number = 0, + .blocks = &blocks, + .override_genesis_parent_hash = JUNK_HASH, + }; + try plan.expectReject(arena.allocator(), error.InvalidGenesisParentHash); +} + +test "non-empty execution requests are rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const blocks = [_]conflation_plan.BlockPlan{ .{}, .{ .non_empty_execution_requests = true } }; + const plan = conflation_plan.ConflationPlan{ .blocks = &blocks }; + try plan.expectReject(arena.allocator(), error.ExecutionRequestsNotSupported); +} + +test "non-empty withdrawals are rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const blocks = [_]conflation_plan.BlockPlan{ .{}, .{ .non_empty_withdrawals = true } }; + const plan = conflation_plan.ConflationPlan{ .blocks = &blocks }; + try plan.expectReject(arena.allocator(), error.WithdrawalsNotSupported); +} + +test "an unsupported fork is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const blocks = [_]conflation_plan.BlockPlan{ .{}, .{ .active_fork_idx = 0x11 } }; + const plan = conflation_plan.ConflationPlan{ .blocks = &blocks }; + try plan.expectReject(arena.allocator(), error.UnsupportedFork); +} + +// Proves `bridgeStorage`'s decoupling: declaring bridge storage no longer forces the address on, +// so a plan can leave it at its suppressed zero default and the declared values are simply never +// realized. +test "bridge storage declared while the address stays at its suppressed zero default is never observed" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var plan = conflation_plan.ConflationPlan{}; + plan.bridgeStorage(.parent, .{ .number = 5, .hash = PARENT_BRIDGE_HASH }); + plan.bridgeStorage(.end, .{ .number = 7, .hash = END_BRIDGE_HASH }); + + const output = try plan.run(arena.allocator()); + + try testing.expectEqual(@as(u64, 0), output.public_inputs.parent_l1_l2_bridge_rolling_hash_message_number); + try testing.expectEqualSlices(u8, &ZERO_HASH, &output.public_inputs.parent_l1_l2_bridge_rolling_hash); + try testing.expectEqual(@as(u64, 0), output.public_inputs.end_l1_l2_bridge_rolling_hash_message_number); + try testing.expectEqualSlices(u8, &ZERO_HASH, &output.public_inputs.end_l1_l2_bridge_rolling_hash); +} diff --git a/riscv-guests/l2-execution/test/legacy_tx_rlp.zig b/riscv-guests/l2-execution/test/legacy_tx_rlp.zig new file mode 100644 index 0000000000..a53bc8e2b8 --- /dev/null +++ b/riscv-guests/l2-execution/test/legacy_tx_rlp.zig @@ -0,0 +1,39 @@ +//! Shared legacy (type-0) transaction RLP encoder for test fixtures. + +const std = @import("std"); +const executor = @import("zesu_executor"); + +const rlp = executor.executor_rlp_encode; + +/// RLP-encodes a legacy transaction as `[nonce, gasPrice, gasLimit, to, value, data, v, r, s]`, +/// matching the field order the decoder's legacy branch expects. `to = null` encodes as an empty +/// RLP string (contract creation). `v` is the raw wire-format value, not y_parity: pass +/// `chain_id*2 + 35 + recid` for a signed EIP-155 transaction, `27 + recid` for a signed +/// pre-EIP-155 one, or the bare `chain_id` with `r = s = 0` for the EIP-155 signing preimage (a +/// zero `u256` RLP-encodes as an empty string, the preimage's `""` fields). +pub fn buildLegacyTxRlp( + alloc: std.mem.Allocator, + nonce: u64, + gas_price: u128, + gas_limit: u64, + to: ?[20]u8, + value: u256, + data: []const u8, + v: u256, + r: u256, + s: u256, +) ![]const u8 { + const to_encoded = if (to) |addr| try rlp.encodeBytes(alloc, &addr) else try rlp.encodeBytes(alloc, &.{}); + const items = [_][]const u8{ + try rlp.encodeU64(alloc, nonce), + try rlp.encodeU128(alloc, gas_price), + try rlp.encodeU64(alloc, gas_limit), + to_encoded, + try rlp.encodeU256(alloc, value), + try rlp.encodeBytes(alloc, data), + try rlp.encodeU256(alloc, v), + try rlp.encodeU256(alloc, r), + try rlp.encodeU256(alloc, s), + }; + return rlp.encodeList(alloc, &items); +} diff --git a/riscv-guests/l2-execution/test/stateless_input_encode.zig b/riscv-guests/l2-execution/test/stateless_input_encode.zig new file mode 100644 index 0000000000..e533c5576b --- /dev/null +++ b/riscv-guests/l2-execution/test/stateless_input_encode.zig @@ -0,0 +1,209 @@ +//! Test-only SSZ encoder for the vanilla `SszStatelessInput` (Amsterdam stateless block execution) — +//! the exact byte-level inverse of the decoder this package's guest consumes. Every wire-shape type +//! below mirrors that decoder's corresponding section, in the same order, so a schema change is a +//! side-by-side edit. This exists purely so tests can build a `StatelessInput` as readable, diffable +//! Zig and turn it into the bytes the guest's real decode path accepts — the vendored decoder ships +//! with no matching encoder of its own. +//! +//! Each wire container below is a plain Zig struct — fixed fields as arrays/ints, variable fields as +//! slices — serialized by `ssz.serialize`'s generic comptime reflection over the struct's fields in +//! declaration order: the same 4-byte little-endian offset-table convention SSZ and the decoder both +//! use, so declaring a field as a fixed array vs. a slice is itself what selects "inline in the fixed +//! head" vs. "offset into the variable region". Two of the wire's conventions need a specific Zig +//! shape to come out right: +//! - a `List[ByteList[N], M]` (a list of variable-length byte blobs — transactions/witness-nodes/ +//! codes/headers) is a slice of byte slices (`[]const []const u8`): the outer list is variable +//! (gets an offset table), each inner blob is raw bytes (`@sizeOf(u8) == 1`, so the library packs +//! it with no per-item framing of its own). +//! - the optional `activation_block`/`activation_timestamp` fields are 0-or-1-element slices of +//! `u64` (`[]const u64`), not Zig's native `?u64` — a slice of fixed-size elements serializes as +//! one offset plus packed concatenation with no internal length prefix, exactly the "presence is +//! the encoded length" convention `SszForkActivation` uses. `?u64` would instead serialize as a +//! 1-byte selector plus value: a real SSZ shape, just a different one than this container uses. +//! +//! Container layouts match the decoder exactly (fixed region sizes): +//! SszStatelessInput: 16 bytes [4+4+4+4] all-variable (v0.4.1) +//! SszNewPayloadRequest: 44 bytes [4+4+32+4] +//! SszExecutionPayload: 540 bytes (Amsterdam/V4 shape) +//! SszExecutionWitness: 12 bytes [4+4+4] +//! SszWithdrawal: 44 bytes fixed (8+8+20+8) +//! +//! Always produces the tightly-packed (canonical, zero-gap) form: every offset points immediately +//! past its own fixed head or the previous variable field, matching the real bytes this package's +//! guest is fed in practice — the natural result of serializing fields in declaration order with no +//! padding, not a property this file arranges by hand. Always produces the Amsterdam (V4) execution- +//! payload shape — this package's guest fixes its fork to Amsterdam, and V4 is the wire shape that +//! fork carries. Output starts directly at the two schema bytes; the Ere length prefix belongs to the +//! optional outer transport framing the decoder strips before this format begins. + +const std = @import("std"); +const input = @import("zesu_input"); +const ssz = @import("ssz"); + +// ── Wire-shape containers ────────────────────────────────────────────────────── +// +// `input.Withdrawal` and `input.ExecutionWitness` already match their wire shape field-for-field +// (same fields, same order, same types), and `input.ExecutionRequests` already matches +// SszExecutionRequests's 5-slot offset table (deposits, withdrawals, consolidations, +// builder_deposits, builder_exits, in that order) — all three are used directly below with no shadow +// type of their own. The three containers below need a dedicated wire shape because the decoded +// convenience type either orders fields differently than the wire (`NewPayloadRequest`), carries a +// wire-irrelevant field alongside the wire one (`ExecutionPayload`'s decoded `transactions` alongside +// wire `raw_transactions`), or represents optionality with Zig's `?T` where the wire uses a 0-or-1- +// length list (`ChainConfig`'s activation fields). + +/// SszExecutionPayload's 540-byte fixed region, field-for-field in wire order. `base_fee_per_gas` is +/// a `u256` (the wire's real width) rather than the decoded convenience type's `u64` — only the low 8 +/// bytes are ever set, and the library zero-fills the rest of the 32-byte integer. `slot_number` is a +/// plain `u64` rather than `?u64`: it is unconditionally present on the wire, and is optional in the +/// decoded convenience type only pending a genuine zero-value default. +const SszExecutionPayload = struct { + parent_hash: [32]u8, + fee_recipient: [20]u8, + state_root: [32]u8, + receipts_root: [32]u8, + logs_bloom: [256]u8, + prev_randao: [32]u8, + block_number: u64, + gas_limit: u64, + gas_used: u64, + timestamp: u64, + extra_data: []const u8, + base_fee_per_gas: u256, + block_hash: [32]u8, + transactions: []const []const u8, + withdrawals: []const input.Withdrawal, + blob_gas_used: u64, + excess_blob_gas: u64, + block_access_list: []const u8, + slot_number: u64, +}; + +/// SszNewPayloadRequest's 44-byte fixed head, in wire order: execution_payload offset, versioned_ +/// hashes offset, parent_beacon_block_root inline, execution_requests offset. The decoded convenience +/// type declares `parent_beacon_block_root` before `versioned_hashes`; the wire orders them the other +/// way, so the field order here is what actually selects the wire's layout, not the decoded type's. +const SszNewPayloadRequest = struct { + execution_payload: SszExecutionPayload, + versioned_hashes: []const [32]u8, + parent_beacon_block_root: [32]u8, + execution_requests: input.ExecutionRequests, +}; + +/// SszForkActivation's 8-byte fixed head: one offset per optional, each pointing to a 0-or-1-element +/// `u64` list. Presence is conveyed entirely by the encoded length, matching how the decoder reads it +/// (it reads a `u64` only when an offset delta is exactly 8, and leaves the field `null` otherwise). +const SszForkActivation = struct { + activation_block: []const u64, + activation_timestamp: []const u64, +}; + +/// SszForkConfig's 4-byte fixed head: the activation container's offset, its only field — fork +/// identity travels in the schema prefix, not here. +const SszForkConfig = struct { + activation: SszForkActivation, +}; + +/// SszChainConfig's 12-byte fixed head: chain_id inline, fork_config offset. +const SszChainConfig = struct { + chain_id: u64, + fork_config: SszForkConfig, +}; + +/// SszStatelessInput's 16-byte all-variable fixed head (v0.4.1): one offset per field, in wire order. +/// `public_keys` is a packed list of fixed 65-byte ByteVectors (uncompressed secp256k1, 0x04 prefix +/// retained) — `[]const [65]u8`, not `[]const []const u8`, so the library packs them with no per-item +/// offset table, matching the wire's "no framing, just concatenation" convention for fixed-size items. +const SszStatelessInput = struct { + new_payload_request: SszNewPayloadRequest, + witness: input.ExecutionWitness, + chain_config: SszChainConfig, + public_keys: []const [65]u8, +}; + +const PUBKEY_SIZE: usize = 65; + +/// Converts a `?u64` into the 0-or-1-element slice `SszForkActivation` needs, backed by `buf` (which +/// must outlive the caller's use of the returned slice — see `encode`, which keeps one such buffer +/// per activation field alive across its own `ssz.serialize` call). +fn optionalAsSlice(buf: *[1]u64, value: ?u64) []const u64 { + if (value) |v| { + buf[0] = v; + return buf[0..1]; + } + return &.{}; +} + +/// Encode a `StatelessInput` into the SSZ `SszStatelessInput` bytes the decoder accepts. The exact +/// byte-level inverse of `decode`: `decode(alloc, encode(alloc, si))` reproduces `si`. +/// +/// `chain_config.fork_name` carries no wire bytes of its own — it is a display string the decoder +/// derives from the schema's fork byte, so encoding reads `active_fork_idx` for that byte and leaves +/// `fork_name` unread. +pub fn encode(alloc: std.mem.Allocator, si: input.StatelessInput) ![]u8 { + const ep = si.new_payload_request.execution_payload; + + const public_keys = try alloc.alloc([PUBKEY_SIZE]u8, si.public_keys.len); + defer alloc.free(public_keys); + for (si.public_keys, 0..) |key, i| { + if (key.len != PUBKEY_SIZE) return error.InvalidPublicKeySize; + @memcpy(&public_keys[i], key); + } + + var activation_block_buf: [1]u64 = undefined; + var activation_timestamp_buf: [1]u64 = undefined; + + const body = SszStatelessInput{ + .new_payload_request = .{ + .execution_payload = .{ + .parent_hash = ep.parent_hash, + .fee_recipient = ep.fee_recipient, + .state_root = ep.state_root, + .receipts_root = ep.receipts_root, + .logs_bloom = ep.logs_bloom, + .prev_randao = ep.prev_randao, + .block_number = ep.block_number, + .gas_limit = ep.gas_limit, + .gas_used = ep.gas_used, + .timestamp = ep.timestamp, + .extra_data = ep.extra_data, + .base_fee_per_gas = ep.base_fee_per_gas, + .block_hash = ep.block_hash, + // The wire format's transaction list holds opaque RLP bytes, not the decoded + // `Transaction` struct — `raw_transactions` is the field that round-trips through the + // wire, exactly like the decoder populates it straight from this same list. + .transactions = ep.raw_transactions, + .withdrawals = ep.withdrawals, + .blob_gas_used = ep.blob_gas_used, + .excess_blob_gas = ep.excess_blob_gas, + .block_access_list = ep.block_access_list, + .slot_number = ep.slot_number orelse 0, + }, + .versioned_hashes = si.new_payload_request.versioned_hashes, + .parent_beacon_block_root = si.new_payload_request.parent_beacon_block_root, + .execution_requests = si.new_payload_request.execution_requests, + }, + .witness = si.witness, + .chain_config = .{ + .chain_id = si.chain_config.chain_id, + .fork_config = .{ + .activation = .{ + .activation_block = optionalAsSlice(&activation_block_buf, si.chain_config.activation_block), + .activation_timestamp = optionalAsSlice(&activation_timestamp_buf, si.chain_config.activation_timestamp), + }, + }, + }, + .public_keys = public_keys, + }; + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(alloc); + // The 2-byte schema id (fork byte from `chain_config.active_fork_idx` + revision byte 0x01) is + // Linea's own outer framing, not part of the SSZ container itself — prepended here directly to + // the same buffer `ssz.serialize` appends the body into, ahead of it. + try out.append(alloc, @intCast(si.chain_config.active_fork_idx)); + try out.append(alloc, 0x01); + try ssz.serialize(SszStatelessInput, body, &out, alloc); + + return out.toOwnedSlice(alloc); +} diff --git a/riscv-guests/l2-execution/test/stateless_input_encode_test.zig b/riscv-guests/l2-execution/test/stateless_input_encode_test.zig new file mode 100644 index 0000000000..b3d642567d --- /dev/null +++ b/riscv-guests/l2-execution/test/stateless_input_encode_test.zig @@ -0,0 +1,249 @@ +//! Tests for the test-only vanilla `StatelessInput` SSZ encoder. +//! +//! Two independent guarantees: +//! - round-trip: a hand-built `StatelessInput`, touching every variable-length branch the wire +//! format has, survives encode-then-decode with zesu's real decoder. +//! - golden re-encode: decoding a real EF fixture's vanilla bytes and re-encoding the result +//! reproduces those bytes exactly, byte-for-byte. + +const std = @import("std"); +const input = @import("zesu_input"); +const ssz_decode = @import("zesu_ssz_decode"); +const fixtures = @import("evm_execution_fixtures"); +const stateless_input_encode = @import("stateless_input_encode"); +const legacy_tx_rlp = @import("legacy_tx_rlp"); + +fn repeat(comptime n: usize, byte: u8) [n]u8 { + var out: [n]u8 = undefined; + for (&out) |*b| b.* = byte; + return out; +} + +fn expectByteListListEqual(want: []const []const u8, got: []const []const u8) !void { + try std.testing.expectEqual(want.len, got.len); + for (want, got) |w, g| try std.testing.expectEqualSlices(u8, w, g); +} + +// ─── Legacy transactions ──────────────────────────────────────────────────────────────────────────── +// Two standalone legacy-RLP transactions (`raw[0] >= 0xc0`, the decoder's legacy-tx branch), generated +// from the named fields below via `buildLegacyTxRlp` so their bytes are correct by construction. They +// differ in every field that varies a legacy transaction's RLP shape: presence of a `to` address (tx +// A) vs. contract creation (tx B), an EIP-155 `chainId`-carrying `v` (tx A) vs. a pre-EIP-155 `v` with +// no chain id (tx B), and an empty vs. non-empty `data`. +// +// Tx A: nonce=7, gasPrice=1e9, gas=21000, to=0xaa*20, value=1000, data=b"", chainId=59144, yParity=1. +const TX_A_TO = repeat(20, 0xaa); +const TX_A_R: u256 = 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef; +const TX_A_S: u256 = 0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba09876543; +// Raw wire-format `v` for an EIP-155-protected legacy tx: chain_id*2 + 35 + y_parity. +const TX_A_V_RAW: u256 = 59144 * 2 + 35 + 1; + +// Tx B: nonce=0, gasPrice=2e9, gas=100000, to=None (creation), value=0, data=6 bytes, v=28 (pre-155). +const TX_B_DATA = [_]u8{ 0x60, 0x80, 0x60, 0x40, 0x52, 0x00 }; +const TX_B_R: u256 = 0x2222222222222222222222222222222222222222222222222222222222222222; +// One canonical-encoding byte narrower than TX_B_R: RLP integers drop leading zero bytes, so this +// value's minimal big-endian encoding is 31 bytes, not 32. +const TX_B_S: u256 = 90462569716653277674664832038037428010367175520031690655826237506182132531; +// Raw wire-format `v` for a pre-EIP-155 legacy tx: 27 + y_parity. +const TX_B_V_RAW: u256 = 27 + 1; + +// ─── Other variable-length fixture data ──────────────────────────────────────────────────────────── + +const NODE_0 = repeat(10, 0xa1); +const NODE_2 = repeat(4, 0xa3); +// A zero-length middle entry exercises the offset-table's degenerate adjacent-equal-offsets case. +const NODES = [_][]const u8{ &NODE_0, &[_]u8{}, &NODE_2 }; + +const CODE_0 = repeat(50, 0xb1); +const CODE_1 = repeat(1, 0xb2); +const CODES = [_][]const u8{ &CODE_0, &CODE_1 }; + +const HEADER_0 = repeat(90, 0xc1); +const HEADER_1 = repeat(32, 0xc2); +const HEADERS = [_][]const u8{ &HEADER_0, &HEADER_1 }; + +const PUBKEY_0 = repeat(65, 0xd1); +const PUBKEY_1 = repeat(65, 0xd2); +const PUBKEYS = [_][]const u8{ &PUBKEY_0, &PUBKEY_1 }; + +const WITHDRAWALS = [_]input.Withdrawal{ + .{ .index = 1, .validator_index = 2, .address = repeat(20, 0x08), .amount = 32_000_000_000 }, + .{ .index = 2, .validator_index = 3, .address = repeat(20, 0x09), .amount = 1 }, +}; + +const VERSIONED_HASHES = [_][32]u8{ repeat(32, 0x0a), repeat(32, 0x0b) }; + +const DEPOSITS_BYTES = repeat(192, 0xde); // one packed SszDepositRequest-sized item, opaque here +const BUILDER_EXITS_BYTES = repeat(68, 0xef); // one packed SszBuilderExitRequest-sized item + +const EXTRA_DATA = [_]u8{ 0xde, 0xad, 0xbe, 0xef }; +const BLOCK_ACCESS_LIST_BYTES = repeat(16, 0x99); + +/// A hand-built `StatelessInput` touching every variable-length branch the wire format has: multiple +/// transactions, a zero-length witness entry alongside non-empty ones, present public keys, non-empty +/// withdrawals/versioned-hashes/execution-requests, and the fork-activation optionals in opposite +/// states from each other (`activation_block` set, `activation_timestamp` unset). +fn sampleInput(raw_transactions: []const []const u8) input.StatelessInput { + return .{ + .new_payload_request = .{ + .execution_payload = .{ + .parent_hash = repeat(32, 0x01), + .fee_recipient = repeat(20, 0x02), + .state_root = repeat(32, 0x03), + .receipts_root = repeat(32, 0x04), + .logs_bloom = @splat(0), + .prev_randao = repeat(32, 0x05), + .block_number = 1_000_501, + .gas_limit = 30_000_000, + .gas_used = 42_000, + .timestamp = 1_763_000_000, + .extra_data = &EXTRA_DATA, + .base_fee_per_gas = 7_000_000_000, + .block_hash = repeat(32, 0x06), + // The encoder reads `raw_transactions`, the wire-format field; this decoded + // convenience view stays empty here and is repopulated by decode. + .transactions = &.{}, + .raw_transactions = raw_transactions, + .withdrawals = &WITHDRAWALS, + .blob_gas_used = 131_072, + .excess_blob_gas = 0, + .slot_number = 424_242, + .block_access_list = &BLOCK_ACCESS_LIST_BYTES, + }, + .parent_beacon_block_root = repeat(32, 0x07), + .versioned_hashes = &VERSIONED_HASHES, + .execution_requests = .{ + .deposits = &DEPOSITS_BYTES, + .withdrawals = &.{}, + .consolidations = &.{}, + .builder_deposits = &.{}, + .builder_exits = &BUILDER_EXITS_BYTES, + }, + }, + .witness = .{ + .nodes = &NODES, + .codes = &CODES, + .headers = &HEADERS, + }, + .chain_config = .{ + .chain_id = 59144, + .active_fork_idx = 0x15, // Amsterdam + .activation_block = 12_345, + .activation_timestamp = null, + }, + .public_keys = &PUBKEYS, + }; +} + +test "encode then decode round-trips every field, covering every variable-length branch" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const tx_a_rlp = try legacy_tx_rlp.buildLegacyTxRlp(alloc, 7, 1_000_000_000, 21_000, TX_A_TO, 1000, &.{}, TX_A_V_RAW, TX_A_R, TX_A_S); + const tx_b_rlp = try legacy_tx_rlp.buildLegacyTxRlp(alloc, 0, 2_000_000_000, 100_000, null, 0, &TX_B_DATA, TX_B_V_RAW, TX_B_R, TX_B_S); + // A named local, scoped to this test function, so the array's storage lasts as long as `value`, + // `encoded`, and `decoded` below need it: its elements are runtime slices, and this function's own + // stack frame is the shortest-lived scope that still covers every later use. + const raw_txs = [_][]const u8{ tx_a_rlp, tx_b_rlp }; + + const value = sampleInput(&raw_txs); + const encoded = try stateless_input_encode.encode(alloc, value); + const decoded = try ssz_decode.decode(alloc, encoded); + + const want_ep = value.new_payload_request.execution_payload; + const ep = decoded.new_payload_request.execution_payload; + try std.testing.expectEqualSlices(u8, &want_ep.parent_hash, &ep.parent_hash); + try std.testing.expectEqualSlices(u8, &want_ep.fee_recipient, &ep.fee_recipient); + try std.testing.expectEqualSlices(u8, &want_ep.state_root, &ep.state_root); + try std.testing.expectEqualSlices(u8, &want_ep.receipts_root, &ep.receipts_root); + try std.testing.expectEqualSlices(u8, &want_ep.logs_bloom, &ep.logs_bloom); + try std.testing.expectEqualSlices(u8, &want_ep.prev_randao, &ep.prev_randao); + try std.testing.expectEqual(want_ep.block_number, ep.block_number); + try std.testing.expectEqual(want_ep.gas_limit, ep.gas_limit); + try std.testing.expectEqual(want_ep.gas_used, ep.gas_used); + try std.testing.expectEqual(want_ep.timestamp, ep.timestamp); + try std.testing.expectEqualSlices(u8, want_ep.extra_data, ep.extra_data); + try std.testing.expectEqual(want_ep.base_fee_per_gas, ep.base_fee_per_gas); + try std.testing.expectEqualSlices(u8, &want_ep.block_hash, &ep.block_hash); + try std.testing.expectEqual(want_ep.blob_gas_used, ep.blob_gas_used); + try std.testing.expectEqual(want_ep.excess_blob_gas, ep.excess_blob_gas); + try std.testing.expectEqual(want_ep.slot_number, ep.slot_number); + try std.testing.expectEqualSlices(u8, want_ep.block_access_list, ep.block_access_list); + + // Transactions: the raw RLP bytes round-trip byte-exact, and independently decode to exactly the + // transactions this fixture encodes — proof the raw bytes this encoder wrote are the real thing, + // not opaque filler. + try std.testing.expectEqual(@as(usize, 2), ep.raw_transactions.len); + try std.testing.expectEqualSlices(u8, tx_a_rlp, ep.raw_transactions[0]); + try std.testing.expectEqualSlices(u8, tx_b_rlp, ep.raw_transactions[1]); + + try std.testing.expectEqual(@as(usize, 2), ep.transactions.len); + const tx_a = ep.transactions[0]; + try std.testing.expectEqual(@as(u64, 7), tx_a.nonce); + try std.testing.expectEqual(@as(u128, 1_000_000_000), tx_a.gas_price); + try std.testing.expectEqual(@as(u64, 21_000), tx_a.gas_limit); + try std.testing.expectEqualSlices(u8, &TX_A_TO, &tx_a.to.?); + try std.testing.expectEqual(@as(u256, 1000), tx_a.value); + try std.testing.expectEqual(@as(usize, 0), tx_a.data.len); + try std.testing.expectEqual(@as(?u64, 59144), tx_a.chain_id); + try std.testing.expectEqual(@as(u64, 1), tx_a.v); + try std.testing.expectEqual(TX_A_R, tx_a.r); + try std.testing.expectEqual(TX_A_S, tx_a.s); + + const tx_b = ep.transactions[1]; + try std.testing.expectEqual(@as(u64, 0), tx_b.nonce); + try std.testing.expectEqual(@as(u128, 2_000_000_000), tx_b.gas_price); + try std.testing.expectEqual(@as(u64, 100_000), tx_b.gas_limit); + try std.testing.expectEqual(@as(?[20]u8, null), tx_b.to); + try std.testing.expectEqual(@as(u256, 0), tx_b.value); + try std.testing.expectEqualSlices(u8, &TX_B_DATA, tx_b.data); + try std.testing.expectEqual(@as(?u64, null), tx_b.chain_id); + try std.testing.expectEqual(@as(u64, 1), tx_b.v); + try std.testing.expectEqual(TX_B_R, tx_b.r); + try std.testing.expectEqual(TX_B_S, tx_b.s); + + try std.testing.expectEqual(WITHDRAWALS.len, ep.withdrawals.len); + for (WITHDRAWALS, ep.withdrawals) |want, got| { + try std.testing.expectEqual(want.index, got.index); + try std.testing.expectEqual(want.validator_index, got.validator_index); + try std.testing.expectEqualSlices(u8, &want.address, &got.address); + try std.testing.expectEqual(want.amount, got.amount); + } + + const npr = value.new_payload_request; + const got_npr = decoded.new_payload_request; + try std.testing.expectEqualSlices(u8, &npr.parent_beacon_block_root, &got_npr.parent_beacon_block_root); + try std.testing.expectEqual(VERSIONED_HASHES.len, got_npr.versioned_hashes.len); + for (VERSIONED_HASHES, got_npr.versioned_hashes) |want, got| try std.testing.expectEqualSlices(u8, &want, &got); + + try std.testing.expectEqualSlices(u8, npr.execution_requests.deposits, got_npr.execution_requests.deposits); + try std.testing.expectEqualSlices(u8, npr.execution_requests.withdrawals, got_npr.execution_requests.withdrawals); + try std.testing.expectEqualSlices(u8, npr.execution_requests.consolidations, got_npr.execution_requests.consolidations); + try std.testing.expectEqualSlices(u8, npr.execution_requests.builder_deposits, got_npr.execution_requests.builder_deposits); + try std.testing.expectEqualSlices(u8, npr.execution_requests.builder_exits, got_npr.execution_requests.builder_exits); + + try expectByteListListEqual(&NODES, decoded.witness.nodes); + try expectByteListListEqual(&CODES, decoded.witness.codes); + try expectByteListListEqual(&HEADERS, decoded.witness.headers); + + try std.testing.expectEqual(value.chain_config.chain_id, decoded.chain_config.chain_id); + try std.testing.expectEqualStrings("Amsterdam", decoded.chain_config.fork_name.?); + try std.testing.expectEqual(value.chain_config.active_fork_idx, decoded.chain_config.active_fork_idx); + try std.testing.expectEqual(value.chain_config.activation_block, decoded.chain_config.activation_block); + try std.testing.expectEqual(value.chain_config.activation_timestamp, decoded.chain_config.activation_timestamp); + + try expectByteListListEqual(&PUBKEYS, decoded.public_keys); +} + +test "encode reproduces the EF fixture's real vanilla stateless-input bytes byte-for-byte" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const fixture = try fixtures.loadStatelessBlock(alloc, fixtures.embedded.zkevm_stateless_block); + const decoded = try ssz_decode.decode(alloc, fixture.input); + const re_encoded = try stateless_input_encode.encode(alloc, decoded); + + try std.testing.expectEqualSlices(u8, fixture.input, re_encoded); +}