From 40754d76d737d62d79df5c6cc6802edf98f9c65b Mon Sep 17 00:00:00 2001 From: Roman <4833306+Filter94@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:34:40 +0200 Subject: [PATCH 1/3] chore(riscv-guest): WIP --- riscv-guests/l2-execution/build.zig | 89 +++ .../l2-execution/src/l2_execution.zig | 16 +- .../l2-execution/test/conflation_plan.zig | 543 ++++++++++++++++++ .../test/conflation_plan_parity_test.zig | 57 ++ .../test/l2_execution_range_test.zig | 355 ++++++++++++ .../test/stateless_input_encode.zig | 334 +++++++++++ .../test/stateless_input_encode_test.zig | 254 ++++++++ 7 files changed, 1646 insertions(+), 2 deletions(-) create mode 100644 riscv-guests/l2-execution/test/conflation_plan.zig create mode 100644 riscv-guests/l2-execution/test/conflation_plan_parity_test.zig create mode 100644 riscv-guests/l2-execution/test/l2_execution_range_test.zig create mode 100644 riscv-guests/l2-execution/test/stateless_input_encode.zig create mode 100644 riscv-guests/l2-execution/test/stateless_input_encode_test.zig diff --git a/riscv-guests/l2-execution/build.zig b/riscv-guests/l2-execution/build.zig index 97751fdd30..cb6f3e5bb1 100644 --- a/riscv-guests/l2-execution/build.zig +++ b/riscv-guests/l2-execution/build.zig @@ -266,6 +266,20 @@ 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); + // ── `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 @@ -353,6 +367,81 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&b.addRunArtifact(tests).step); + // ── Vanilla StatelessInput SSZ encoder (test/stateless_input_encode.zig) unit tests ──────── + // Test-only SSZ encoder for zesu's vanilla StatelessInput — the byte-level inverse of + // zesu_ssz_decode's decode, which ships with no matching encoder of its own. A round-trip + // test builds a StatelessInput as readable Zig and checks it survives encode-then-decode; a + // golden test decodes this same fixture and checks re-encoding it reproduces the original + // bytes exactly. Needs the same zesu_input/zesu_ssz_decode imports as the vanilla-wrap + // wiring, plus the fixtures module already built for the guest smoke test above. + const stateless_input_encode_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("test/stateless_input_encode_test.zig"), + .target = native_target, + .optimize = host_optimize, + }), + }); + stateless_input_encode_tests.root_module.addImport("zesu_input", native_imports.input); + stateless_input_encode_tests.root_module.addImport("zesu_ssz_decode", native_imports.ssz_decode); + stateless_input_encode_tests.root_module.addImport("evm_execution_fixtures", fixtures_mod); + stateless_input_encode_tests.root_module.addImport("stateless_input_encode", stateless_input_encode_mod); + linkNativeZesuCrypto(stateless_input_encode_tests, native_target, native_crypto); + test_step.dependOn(&b.addRunArtifact(stateless_input_encode_tests).step); + + // ── Conflation-plan test DSL parity guard (test/conflation_plan_parity_test.zig) ──────────── + // Proves `conflation_plan.zig`'s `StubEngine` (the l2-execution guest's execution-seam stub) + // is a faithful stand-in for the real per-block execution seam on real data, and smoke-tests + // the `ConflationPlan` DSL itself end to end through the guest's real conflation logic. + // `conflation_plan.zig` is pulled in by relative import from the test file, so every import + // it needs (the full zesu set its real-MPT/real-header derivation uses, `l2_execution` for + // the seam, `l2_execution_ssz` for the envelope codec, and the shared + // `stateless_input_encode` module) is wired directly on this root module. + const conflation_plan_parity_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("test/conflation_plan_parity_test.zig"), + .target = native_target, + .optimize = host_optimize, + }), + }); + conflation_plan_parity_tests.root_module.addImport("l2_execution", l2_execution_mod); + conflation_plan_parity_tests.root_module.addImport("l2_execution_ssz", l2_execution_ssz_mod); + conflation_plan_parity_tests.root_module.addImport("zesu_executor", native_imports.executor); + conflation_plan_parity_tests.root_module.addImport("zesu_mpt", native_imports.mpt); + conflation_plan_parity_tests.root_module.addImport("zesu_input", native_imports.input); + conflation_plan_parity_tests.root_module.addImport("zesu_primitives", native_imports.primitives); + conflation_plan_parity_tests.root_module.addImport("zesu_allocator", native_imports.allocator); + conflation_plan_parity_tests.root_module.addImport("zesu_rlp_decode", native_imports.rlp_decode); + conflation_plan_parity_tests.root_module.addImport("zesu_ssz_decode", native_imports.ssz_decode); + conflation_plan_parity_tests.root_module.addImport("stateless_input_encode", stateless_input_encode_mod); + conflation_plan_parity_tests.root_module.addImport("evm_execution_fixtures", fixtures_mod); + linkNativeZesuCrypto(conflation_plan_parity_tests, native_target, native_crypto); + test_step.dependOn(&b.addRunArtifact(conflation_plan_parity_tests).step); + + // ── Conflation-plan range scenario suite (test/l2_execution_range_test.zig) ─────────────── + // One rich happy-path scenario asserting the full 16-field public input plus preimages, + // and twelve one-mutation rejection scenarios, both built on the `ConflationPlan` DSL — + // driving the guest's real conflation logic (`l2_execution.runL2ExecutionWithEngine`) + // through `StubEngine` instead of live EVM execution. Needs exactly the import set + // `conflation_plan.zig` itself needs, since it's pulled in by relative import from the + // test root, same as the parity test above. + 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("stateless_input_encode", stateless_input_encode_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/src/l2_execution.zig b/riscv-guests/l2-execution/src/l2_execution.zig index 8e8bedcbfb..be37c3cfb7 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; @@ -396,7 +404,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 +476,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 +492,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..12753510c3 --- /dev/null +++ b/riscv-guests/l2-execution/test/conflation_plan.zig @@ -0,0 +1,543 @@ +//! 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); + +/// The address `bridgeStorage` switches on automatically once a plan declares real bridge values +/// (a declared value is only observable once the guest's own zero-address suppression is off). +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 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. `bridgeStorage` switches this on automatically. + 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()`. Also switches `l2_message_service_address` on (a fixed test constant) if it is + /// still the suppressed zero address, since a declared bridge value is only observable once + /// the guest actually reads it. + pub fn bridgeStorage(self: *ConflationPlan, which: enum { parent, end }, value: BridgeValue) void { + switch (which) { + .parent => self.bridge_parent = value, + .end => self.bridge_end = value, + } + if (isZeroAddress(self.l2_message_service_address)) { + self.l2_message_service_address = DEFAULT_L2_MESSAGE_SERVICE_ADDRESS; + } + } + + /// 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..69329c3212 --- /dev/null +++ b/riscv-guests/l2-execution/test/conflation_plan_parity_test.zig @@ -0,0 +1,57 @@ +//! Stub-realism guard for `conflation_plan.zig`'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. + +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 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); +} 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..a8eacc5e15 --- /dev/null +++ b/riscv-guests/l2-execution/test/l2_execution_range_test.zig @@ -0,0 +1,355 @@ +//! 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 (Python-minted where the guest's own formula would otherwise only be checked against +//! itself). 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 l2_execution = @import("l2_execution"); +const l2_execution_ssz = @import("l2_execution_ssz"); +const conflation_plan = @import("conflation_plan.zig"); + +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 real secp256k1-signed legacy (type-0) transactions for chain_id 59144, minted with +// rollup_spec/.venv/bin/python (coincurve): nonce 0-3, gasPrice=1e9, gas=21000, to=0xbb*20, +// value=1000/2000/3000/4000, data=b"", one distinct private key per tx +// (keccak256(b"l2exec-range-fixture/T")). 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. Generating snippet (an +// EIP-155 legacy tx: r/s/recid from `PrivateKey(priv).sign_recoverable(keccak(unsigned_rlp), +// hasher=None)`, v = chainId*2+35+recid, sender = keccak(pubkey_uncompressed[1:])[-20:]): +// +// from eth_utils import keccak +// from coincurve import PrivateKey +// priv = keccak(b"l2exec-range-fixture/T1") # one label per tx +// fields = [nonce, 1_000_000_000, 21000, TO, value, b""] # each RLP-encoded individually +// unsigned = rlp_list(fields + [59144, b"", b""]) # EIP-155 signing preimage +// sig = PrivateKey(priv).sign_recoverable(keccak(unsigned), hasher=None) # r(32) s(32) recid(1) +// signed = rlp_list(fields + [v, r, s]) # v = 59144*2 + 35 + recid +const T1_RLP = [_]u8{ + 0xf8, 0x68, 0x80, 0x84, 0x3b, 0x9a, 0xca, 0x00, 0x82, 0x52, 0x08, 0x94, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0x82, 0x03, 0xe8, 0x80, 0x83, 0x01, 0xce, 0x33, 0xa0, 0x68, 0x1e, 0x7a, 0x01, 0xf9, 0x5d, 0x52, + 0x59, 0xdb, 0xb4, 0x5e, 0x5f, 0xdb, 0xa5, 0xe6, 0xa3, 0xc3, 0xc2, 0xe1, 0x0a, 0x55, 0xb7, 0x2f, + 0xb3, 0x09, 0x71, 0x5c, 0xaf, 0xca, 0x80, 0x81, 0x82, 0xa0, 0x05, 0x30, 0xf5, 0xe7, 0x87, 0x58, + 0xbd, 0x76, 0x4c, 0x9b, 0x88, 0x5b, 0x1a, 0x0e, 0x7c, 0x11, 0xeb, 0x99, 0x09, 0xf5, 0x4d, 0x4b, + 0xa5, 0x3a, 0x62, 0x83, 0xf4, 0xe9, 0xc0, 0xb2, 0x40, 0x06, +}; +const T1_SENDER = [_]u8{ + 0x84, 0x30, 0x35, 0xbd, 0xa9, 0x0a, 0x1b, 0xa3, 0x7b, 0x23, 0xa1, 0xfd, 0xbe, 0x62, 0xda, 0x52, + 0x4e, 0xf3, 0xe2, 0xa3, +}; +const T2_RLP = [_]u8{ + 0xf8, 0x68, 0x01, 0x84, 0x3b, 0x9a, 0xca, 0x00, 0x82, 0x52, 0x08, 0x94, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0x82, 0x07, 0xd0, 0x80, 0x83, 0x01, 0xce, 0x34, 0xa0, 0x84, 0xb1, 0x4a, 0xe2, 0x25, 0xe0, 0x3c, + 0xbc, 0xb7, 0x5a, 0x9a, 0x69, 0x09, 0x65, 0xc1, 0x32, 0xc8, 0xd6, 0xf5, 0xfa, 0x19, 0xd7, 0xb5, + 0x2e, 0x97, 0x5b, 0xa4, 0x3d, 0xde, 0xda, 0x4f, 0x5f, 0xa0, 0x63, 0x81, 0xf7, 0xf3, 0x3c, 0xcc, + 0x44, 0x93, 0x56, 0x64, 0x44, 0x8b, 0x8a, 0x7d, 0xa0, 0xe4, 0x89, 0x04, 0x52, 0x78, 0x74, 0xb5, + 0xa3, 0xff, 0xe8, 0xaa, 0x36, 0x39, 0xab, 0xdb, 0xe9, 0xf7, +}; +const T2_SENDER = [_]u8{ + 0x58, 0x30, 0x5d, 0x39, 0xef, 0xe2, 0xb0, 0xb9, 0xde, 0x41, 0x26, 0x54, 0x9e, 0x6f, 0xd3, 0x73, + 0x2e, 0xe7, 0xce, 0xff, +}; +const T3_RLP = [_]u8{ + 0xf8, 0x68, 0x02, 0x84, 0x3b, 0x9a, 0xca, 0x00, 0x82, 0x52, 0x08, 0x94, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0x82, 0x0b, 0xb8, 0x80, 0x83, 0x01, 0xce, 0x34, 0xa0, 0x78, 0x49, 0x97, 0x26, 0x8f, 0x3f, 0x1d, + 0x29, 0xbd, 0x75, 0x1b, 0x21, 0x4a, 0x96, 0x43, 0x96, 0x66, 0x20, 0x33, 0x9f, 0x69, 0x05, 0xd6, + 0x15, 0xb2, 0x3a, 0xbd, 0x3e, 0xfd, 0x10, 0xfd, 0x58, 0xa0, 0x61, 0xd2, 0xce, 0x00, 0x0a, 0x5f, + 0x22, 0xed, 0xcf, 0x1b, 0x22, 0x92, 0xf2, 0xb9, 0xd5, 0x92, 0x44, 0xa1, 0x84, 0x61, 0xe4, 0xdf, + 0x9f, 0xf7, 0xcc, 0x78, 0xf9, 0x1b, 0xd1, 0x77, 0x87, 0xbe, +}; +const T3_SENDER = [_]u8{ + 0x26, 0xcb, 0x46, 0x99, 0x5a, 0x42, 0x7f, 0xb9, 0x76, 0xa9, 0x6c, 0x58, 0x8b, 0x09, 0xc5, 0x87, + 0x01, 0xf1, 0x47, 0xff, +}; +const T4_RLP = [_]u8{ + 0xf8, 0x68, 0x03, 0x84, 0x3b, 0x9a, 0xca, 0x00, 0x82, 0x52, 0x08, 0x94, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0x82, 0x0f, 0xa0, 0x80, 0x83, 0x01, 0xce, 0x34, 0xa0, 0x0a, 0x0e, 0xef, 0x62, 0x1d, 0xd9, 0xca, + 0x64, 0xbe, 0x53, 0x3f, 0x02, 0x04, 0xea, 0xca, 0xb6, 0x93, 0xb3, 0x85, 0x34, 0xb6, 0x2a, 0xa2, + 0xd2, 0xb9, 0x36, 0x70, 0xd1, 0x8d, 0xda, 0x6a, 0x84, 0xa0, 0x44, 0xd3, 0xaf, 0x85, 0x4d, 0xc0, + 0x79, 0xda, 0x86, 0x95, 0xa3, 0x03, 0x2a, 0x0b, 0xc9, 0x79, 0xab, 0x5a, 0xcf, 0xdf, 0x70, 0x40, + 0x07, 0x54, 0xbd, 0xc7, 0x85, 0xa9, 0x41, 0x58, 0xdf, 0x22, +}; +const T4_SENDER = [_]u8{ + 0x90, 0x9c, 0x96, 0x32, 0x2d, 0xbe, 0x7c, 0x94, 0xb7, 0xcf, 0x16, 0x80, 0x21, 0xb3, 0x2a, 0x9c, + 0x2b, 0x53, 0x63, 0x46, +}; + +/// 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); + +/// The bridge address `bridgeStorage` switches on automatically, copied from the plan DSL's own +/// default of the same value — every MessageSent log below is emitted at this address. +const L2_MESSAGE_SERVICE_ADDRESS: [20]u8 = @splat(0xee); + +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; + +/// end_ftx_rolling_hash, independently computed over the same two steps with the same python +/// venv — mirrors the guest's own keccak(prev || txHash || deadline_be32 || from) chain, starting +/// from zero32, chained through FTX1 (T1) then FTX2 (T4): +// +// def step(prev, tx_hash, deadline, sender): +// return keccak(prev + tx_hash + deadline.to_bytes(32, "big") + sender) +// end = step(step(b"\x00" * 32, T1_hash, FTX_DEADLINE, T1_sender), T4_hash, FTX_DEADLINE, T4_sender) +const EXPECTED_END_FTX_ROLLING_HASH = [_]u8{ + 0x08, 0x0d, 0x38, 0xc0, 0x6e, 0x38, 0xf3, 0xeb, 0xbf, 0x36, 0xdf, 0x77, 0xc4, 0x27, 0xdc, 0x83, + 0x94, 0x31, 0x51, 0x73, 0xe4, 0xbf, 0x79, 0x5a, 0x73, 0x10, 0x6a, 0x9e, 0xc0, 0x5b, 0x6d, 0xdf, +}; + +/// 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(); + + const msg_log_1 = try testLog(alloc, L2_MESSAGE_SERVICE_ADDRESS, &.{ BRIDGE_MESSAGE_SENT_TOPIC0, ZERO_HASH, ZERO_HASH, MESSAGE_HASH_1 }); + const non_matching_log = try testLog(alloc, L2_MESSAGE_SERVICE_ADDRESS, &.{ NON_BRIDGE_TOPIC0, ZERO_HASH, ZERO_HASH, ZERO_HASH }); + const msg_log_2 = try testLog(alloc, L2_MESSAGE_SERVICE_ADDRESS, &.{ BRIDGE_MESSAGE_SENT_TOPIC0, ZERO_HASH, ZERO_HASH, MESSAGE_HASH_2 }); + + 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 }; + 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); +} 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..fd503f91d7 --- /dev/null +++ b/riscv-guests/l2-execution/test/stateless_input_encode.zig @@ -0,0 +1,334 @@ +//! 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 section 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. +//! +//! 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 — see EP_FIXED_SIZE) +//! 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. 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"); + +// ── Primitive writes (little-endian) ───────────────────────────────────────── + +inline fn writeU32(out: []u8, off: usize, value: u32) void { + std.mem.writeInt(u32, out[off..][0..4], value, .little); +} + +inline fn writeU64(out: []u8, off: usize, value: u64) void { + std.mem.writeInt(u64, out[off..][0..8], value, .little); +} + +// ── List[ByteList] encoder ──────────────────────────────────────────────────── + +/// Encode SSZ `List[ByteList[...], N]` from a slice of opaque byte blobs. The exact inverse of the +/// decoder's byte-list-list reader: N×4-byte LE offsets (each the size of the offset table plus +/// every preceding element's length) followed by the concatenated element data, tightly packed. +fn encodeByteListList(alloc: std.mem.Allocator, items: []const []const u8) ![]u8 { + const head = items.len * 4; + var total: usize = head; + for (items) |item| total += item.len; + + const out = try alloc.alloc(u8, total); + var offset: u32 = @intCast(head); + for (items, 0..) |item, i| { + writeU32(out, i * 4, offset); + offset += @intCast(item.len); + } + var pos: usize = head; + for (items) |item| { + @memcpy(out[pos..][0..item.len], item); + pos += item.len; + } + return out; +} + +// ── SszWithdrawal encoder ───────────────────────────────────────────────────── + +/// SszWithdrawal fixed size: index(8) + validator_index(8) + address(20) + amount(uint64=8) = 44. +const WITHDRAWAL_SIZE: usize = 44; + +fn encodeWithdrawal(out: *[WITHDRAWAL_SIZE]u8, w: input.Withdrawal) void { + writeU64(out, 0, w.index); + writeU64(out, 8, w.validator_index); + @memcpy(out[16..36], &w.address); + writeU64(out, 36, w.amount); +} + +/// Withdrawals are a packed list of fixed-size items — no offset table, just concatenation. +fn encodeWithdrawals(alloc: std.mem.Allocator, withdrawals: []const input.Withdrawal) ![]u8 { + const out = try alloc.alloc(u8, withdrawals.len * WITHDRAWAL_SIZE); + for (withdrawals, 0..) |w, i| { + encodeWithdrawal(out[i * WITHDRAWAL_SIZE ..][0..WITHDRAWAL_SIZE], w); + } + return out; +} + +// ── SszExecutionRequests encoder ────────────────────────────────────────────── + +/// Fixed head: one 4-byte offset per request type — deposits, withdrawals, consolidations, +/// builder_deposits, builder_exits (EIP-8282 / zkevm@v0.6.2) — in that order. +const ER_TYPE_COUNT: usize = 5; +const ER_FIXED_SIZE: usize = ER_TYPE_COUNT * 4; + +fn encodeExecutionRequests(alloc: std.mem.Allocator, er: input.ExecutionRequests) ![]u8 { + const parts = [_][]const u8{ er.deposits, er.withdrawals, er.consolidations, er.builder_deposits, er.builder_exits }; + comptime std.debug.assert(parts.len == ER_TYPE_COUNT); + + var total: usize = ER_FIXED_SIZE; + for (parts) |p| total += p.len; + + const out = try alloc.alloc(u8, total); + var offset: u32 = @intCast(ER_FIXED_SIZE); + for (parts, 0..) |p, i| { + writeU32(out, i * 4, offset); + offset += @intCast(p.len); + } + var pos: usize = ER_FIXED_SIZE; + for (parts) |p| { + @memcpy(out[pos..][0..p.len], p); + pos += p.len; + } + return out; +} + +// ── SszExecutionPayload encoder (Amsterdam / V4) ────────────────────────────── + +/// Fixed region byte offsets, matching the decoder's table exactly: +/// [0..32] parent_hash +/// [32..52] fee_recipient +/// [52..84] state_root +/// [84..116] receipts_root +/// [116..372] logs_bloom +/// [372..404] prev_randao +/// [404..412] block_number +/// [412..420] gas_limit +/// [420..428] gas_used +/// [428..436] timestamp +/// [436..440] → extra_data (variable offset) +/// [440..472] base_fee_per_gas (uint256 LE — only the low 8 bytes carry a value, since the decoded +/// struct field is a u64; the high 24 bytes are always written zero) +/// [472..504] block_hash +/// [504..508] → transactions (variable offset) +/// [508..512] → withdrawals (variable offset) +/// [512..520] blob_gas_used +/// [520..528] excess_blob_gas +/// [528..532] → block_access_list (variable offset) +/// [532..540] slot_number +const EP_FIXED_SIZE: usize = 540; + +fn encodeExecutionPayload(alloc: std.mem.Allocator, ep: input.ExecutionPayload) ![]u8 { + // 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. + const txs_bytes = try encodeByteListList(alloc, ep.raw_transactions); + const wd_bytes = try encodeWithdrawals(alloc, ep.withdrawals); + + const off_extra_data: usize = EP_FIXED_SIZE; + const off_transactions: usize = off_extra_data + ep.extra_data.len; + const off_withdrawals: usize = off_transactions + txs_bytes.len; + const off_bal: usize = off_withdrawals + wd_bytes.len; + const total: usize = off_bal + ep.block_access_list.len; + + const out = try alloc.alloc(u8, total); + @memcpy(out[0..32], &ep.parent_hash); + @memcpy(out[32..52], &ep.fee_recipient); + @memcpy(out[52..84], &ep.state_root); + @memcpy(out[84..116], &ep.receipts_root); + @memcpy(out[116..372], &ep.logs_bloom); + @memcpy(out[372..404], &ep.prev_randao); + writeU64(out, 404, ep.block_number); + writeU64(out, 412, ep.gas_limit); + writeU64(out, 420, ep.gas_used); + writeU64(out, 428, ep.timestamp); + writeU32(out, 436, @intCast(off_extra_data)); + @memset(out[440..472], 0); + writeU64(out, 440, ep.base_fee_per_gas); + @memcpy(out[472..504], &ep.block_hash); + writeU32(out, 504, @intCast(off_transactions)); + writeU32(out, 508, @intCast(off_withdrawals)); + writeU64(out, 512, ep.blob_gas_used); + writeU64(out, 520, ep.excess_blob_gas); + writeU32(out, 528, @intCast(off_bal)); + writeU64(out, 532, ep.slot_number orelse 0); + + @memcpy(out[off_extra_data..off_transactions], ep.extra_data); + @memcpy(out[off_transactions..off_withdrawals], txs_bytes); + @memcpy(out[off_withdrawals..off_bal], wd_bytes); + @memcpy(out[off_bal..], ep.block_access_list); + + return out; +} + +// ── SszNewPayloadRequest encoder ────────────────────────────────────────────── + +/// Fixed head: execution_payload offset(4) + versioned_hashes offset(4) + +/// parent_beacon_block_root(32) + execution_requests offset(4) = 44. +const NPR_FIXED_SIZE: usize = 44; + +fn encodeNewPayloadRequest(alloc: std.mem.Allocator, npr: input.NewPayloadRequest) ![]u8 { + const ep_bytes = try encodeExecutionPayload(alloc, npr.execution_payload); + + // versioned_hashes: List[Bytes32, 4096] — packed 32-byte elements, no offset table. + const vh_bytes = try alloc.alloc(u8, npr.versioned_hashes.len * 32); + for (npr.versioned_hashes, 0..) |h, i| @memcpy(vh_bytes[i * 32 ..][0..32], &h); + + const er_bytes = try encodeExecutionRequests(alloc, npr.execution_requests); + + const off_ep: usize = NPR_FIXED_SIZE; + const off_vh: usize = off_ep + ep_bytes.len; + const off_er: usize = off_vh + vh_bytes.len; + const total: usize = off_er + er_bytes.len; + + const out = try alloc.alloc(u8, total); + writeU32(out, 0, @intCast(off_ep)); + writeU32(out, 4, @intCast(off_vh)); + @memcpy(out[8..40], &npr.parent_beacon_block_root); + writeU32(out, 40, @intCast(off_er)); + + @memcpy(out[off_ep..off_vh], ep_bytes); + @memcpy(out[off_vh..off_er], vh_bytes); + @memcpy(out[off_er..], er_bytes); + + return out; +} + +// ── SszExecutionWitness encoder ─────────────────────────────────────────────── + +/// Fixed head: state(nodes) offset(4) + codes offset(4) + headers offset(4) = 12. +const WITNESS_FIXED_SIZE: usize = 12; + +fn encodeExecutionWitness(alloc: std.mem.Allocator, w: input.ExecutionWitness) ![]u8 { + const nodes_bytes = try encodeByteListList(alloc, w.nodes); + const codes_bytes = try encodeByteListList(alloc, w.codes); + const headers_bytes = try encodeByteListList(alloc, w.headers); + + const off_state: usize = WITNESS_FIXED_SIZE; + const off_codes: usize = off_state + nodes_bytes.len; + const off_headers: usize = off_codes + codes_bytes.len; + const total: usize = off_headers + headers_bytes.len; + + const out = try alloc.alloc(u8, total); + writeU32(out, 0, @intCast(off_state)); + writeU32(out, 4, @intCast(off_codes)); + writeU32(out, 8, @intCast(off_headers)); + + @memcpy(out[off_state..off_codes], nodes_bytes); + @memcpy(out[off_codes..off_headers], codes_bytes); + @memcpy(out[off_headers..], headers_bytes); + + return out; +} + +// ── SszForkActivation encoder ───────────────────────────────────────────────── + +/// Fixed head: block_number-list offset(4) + timestamp-list offset(4) = 8. Each list holds 0 or 1 +/// uint64 — presence is entirely conveyed by the encoded length, exactly like the decoder reads it. +const ACTIVATION_FIXED_SIZE: usize = 8; + +fn encodeForkActivation(alloc: std.mem.Allocator, activation_block: ?u64, activation_timestamp: ?u64) ![]u8 { + const off_bn: usize = ACTIVATION_FIXED_SIZE; + const off_ts: usize = off_bn + @as(usize, if (activation_block != null) 8 else 0); + const total: usize = off_ts + @as(usize, if (activation_timestamp != null) 8 else 0); + + const out = try alloc.alloc(u8, total); + writeU32(out, 0, @intCast(off_bn)); + writeU32(out, 4, @intCast(off_ts)); + if (activation_block) |b| writeU64(out, off_bn, b); + if (activation_timestamp) |t| writeU64(out, off_ts, t); + return out; +} + +// ── SszForkConfig encoder ───────────────────────────────────────────────────── + +/// Fixed head: activation_offset(4) = 4 (only field — fork identity travels in the schema prefix, not +/// here; zkevm@v0.6.2 dropped the fork/blob_schedule fields this container used to carry). +const FORK_CONFIG_FIXED_SIZE: usize = 4; + +fn encodeForkConfig(alloc: std.mem.Allocator, activation_block: ?u64, activation_timestamp: ?u64) ![]u8 { + const activation_bytes = try encodeForkActivation(alloc, activation_block, activation_timestamp); + const out = try alloc.alloc(u8, FORK_CONFIG_FIXED_SIZE + activation_bytes.len); + writeU32(out, 0, @intCast(FORK_CONFIG_FIXED_SIZE)); + @memcpy(out[FORK_CONFIG_FIXED_SIZE..], activation_bytes); + return out; +} + +// ── SszChainConfig encoder ──────────────────────────────────────────────────── + +/// Fixed head: chain_id(8) + active_fork offset(4) = 12. +const CHAIN_CONFIG_FIXED_SIZE: usize = 12; + +fn encodeChainConfig(alloc: std.mem.Allocator, cc: input.ChainConfig) ![]u8 { + const fork_config_bytes = try encodeForkConfig(alloc, cc.activation_block, cc.activation_timestamp); + const out = try alloc.alloc(u8, CHAIN_CONFIG_FIXED_SIZE + fork_config_bytes.len); + writeU64(out, 0, cc.chain_id); + writeU32(out, 8, @intCast(CHAIN_CONFIG_FIXED_SIZE)); + @memcpy(out[CHAIN_CONFIG_FIXED_SIZE..], fork_config_bytes); + return out; +} + +// ── Top-level encoder ────────────────────────────────────────────────────────── + +/// 2-byte schema id (fork byte from `chain_config.active_fork_idx` + revision byte 0x01), followed by +/// the 16-byte all-variable v0.4.1 fixed head: new_payload_request offset(4) + witness offset(4) + +/// chain_config offset(4) + public_keys offset(4). +const SCHEMA_SIZE: usize = 2; +const BODY_FIXED_SIZE: usize = 16; + +/// Public keys are packed ByteVector[65] elements (uncompressed secp256k1, 0x04 prefix retained) — no +/// offset table, just concatenation. +const PUBKEY_SIZE: usize = 65; + +/// 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 npr_bytes = try encodeNewPayloadRequest(alloc, si.new_payload_request); + const witness_bytes = try encodeExecutionWitness(alloc, si.witness); + const chain_config_bytes = try encodeChainConfig(alloc, si.chain_config); + + const pubkeys_bytes = try alloc.alloc(u8, si.public_keys.len * PUBKEY_SIZE); + for (si.public_keys, 0..) |key, i| { + if (key.len != PUBKEY_SIZE) return error.InvalidPublicKeySize; + @memcpy(pubkeys_bytes[i * PUBKEY_SIZE ..][0..PUBKEY_SIZE], key); + } + + const off_npr: usize = BODY_FIXED_SIZE; + const off_witness: usize = off_npr + npr_bytes.len; + const off_chain_config: usize = off_witness + witness_bytes.len; + const off_pubkeys: usize = off_chain_config + chain_config_bytes.len; + const total: usize = SCHEMA_SIZE + off_pubkeys + pubkeys_bytes.len; + + const out = try alloc.alloc(u8, total); + out[0] = @intCast(si.chain_config.active_fork_idx); + out[1] = 0x01; + + const body = out[SCHEMA_SIZE..]; + writeU32(body, 0, @intCast(off_npr)); + writeU32(body, 4, @intCast(off_witness)); + writeU32(body, 8, @intCast(off_chain_config)); + writeU32(body, 12, @intCast(off_pubkeys)); + + @memcpy(body[off_npr..off_witness], npr_bytes); + @memcpy(body[off_witness..off_chain_config], witness_bytes); + @memcpy(body[off_chain_config..off_pubkeys], chain_config_bytes); + @memcpy(body[off_pubkeys..], pubkeys_bytes); + + return out; +} 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..78c7d6bea5 --- /dev/null +++ b/riscv-guests/l2-execution/test/stateless_input_encode_test.zig @@ -0,0 +1,254 @@ +//! 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"); + +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); +} + +// ─── Hand-crafted legacy transactions ────────────────────────────────────────────────────────────── +// Two standalone legacy-RLP transactions (`raw[0] >= 0xc0`, the decoder's legacy-tx branch), built and +// checked field-by-field against the decoder's own parsing formulas before being frozen here as +// literals. 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_RLP = [_]u8{ + 0xf8, 0x67, 0x07, 0x84, 0x3b, 0x9a, 0xca, 0x00, 0x82, 0x52, 0x08, 0x94, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0x82, 0x03, 0xe8, 0x80, 0x83, 0x01, 0xce, 0x34, 0xa0, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, + 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, + 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x9f, 0xfe, 0xdc, 0xba, 0x09, 0x87, 0x65, + 0x43, 0x21, 0xfe, 0xdc, 0xba, 0x09, 0x87, 0x65, 0x43, 0x21, 0xfe, 0xdc, 0xba, 0x09, 0x87, 0x65, + 0x43, 0x21, 0xfe, 0xdc, 0xba, 0x09, 0x87, 0x65, 0x43, +}; +const TX_A_TO = repeat(20, 0xaa); +const TX_A_R: u256 = 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef; +const TX_A_S: u256 = 0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba09876543; + +// Tx B: nonce=0, gasPrice=2e9, gas=100000, to=None (creation), value=0, data=6 bytes, v=28 (pre-155). +const TX_B_RLP = [_]u8{ + 0xf8, 0x55, 0x80, 0x84, 0x77, 0x35, 0x94, 0x00, 0x83, 0x01, 0x86, 0xa0, 0x80, 0x80, 0x86, 0x60, + 0x80, 0x60, 0x40, 0x52, 0x00, 0x1c, 0xa0, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x9f, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, + 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, + 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, +}; +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: the RLP bytes below strip a leading zero byte +// off this value, so it decodes to a 31-byte (not 32-byte) big-endian integer. +const TX_B_S: u256 = 90462569716653277674664832038037428010367175520031690655826237506182132531; + +// ─── 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() 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 = &.{ &TX_A_RLP, &TX_B_RLP }, + .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 value = sampleInput(); + 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); +} From ebcea36ac450f98adcc14ba172fd6aa76048cb90 Mon Sep 17 00:00:00 2001 From: Roman <4833306+Filter94@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:53:56 +0200 Subject: [PATCH 2/3] chore(riscv-guest): Testing harness for the L2 execution guest program --- riscv-guests/l2-execution/build.zig | 53 ++ riscv-guests/l2-execution/build.zig.zon | 8 + riscv-guests/l2-execution/src/execution.zig | 11 +- .../l2-execution/src/l2_execution.zig | 21 +- .../l2-execution/test/conflation_plan.zig | 25 +- .../test/conflation_plan_parity_test.zig | 60 ++- .../test/l2_execution_range_test.zig | 220 ++++---- .../l2-execution/test/legacy_tx_rlp.zig | 39 ++ .../test/stateless_input_encode.zig | 474 +++++++----------- .../test/stateless_input_encode_test.zig | 55 +- 10 files changed, 512 insertions(+), 454 deletions(-) create mode 100644 riscv-guests/l2-execution/test/legacy_tx_rlp.zig diff --git a/riscv-guests/l2-execution/build.zig b/riscv-guests/l2-execution/build.zig index cb6f3e5bb1..389fc0a7bf 100644 --- a/riscv-guests/l2-execution/build.zig +++ b/riscv-guests/l2-execution/build.zig @@ -279,6 +279,13 @@ pub fn build(b: *std.Build) void { .optimize = host_optimize, }); stateless_input_encode_mod.addImport("zesu_input", native_imports.input); + // A generic SSZ serialize/deserialize library over plain Zig structs. Test-only, + // native-target-only: the guest never imports this module (it has no relative or named import + // path to `stateless_input_encode.zig`), so `.ssz` never enters the freestanding riscv64 compile + // graph. The library's own build.zig exposes its module under the name "ssz.zig" (its literal + // `b.addModule` argument); "ssz" here is only this file's own local import name for it. + const ssz_dep = b.dependency("ssz", .{ .target = native_target, .optimize = host_optimize }); + stateless_input_encode_mod.addImport("ssz", ssz_dep.module("ssz.zig")); // ── `l2-execution-wrap` native host tool ──────────────────────────────────────────────────────── // Wraps a vanilla EF stateless-input .ssz into an extended L2ExecutionProofPrivateInput .ssz @@ -367,6 +374,16 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&b.addRunArtifact(tests).step); + // ── Shared legacy-tx RLP encoder (test/legacy_tx_rlp.zig) ─────────────────────────────────── + // One RLP encoder for a legacy transaction's fixed field list, shared by every test fixture + // that builds one from named fields rather than a byte literal. + const legacy_tx_rlp_mod = b.createModule(.{ + .root_source_file = b.path("test/legacy_tx_rlp.zig"), + .target = native_target, + .optimize = host_optimize, + }); + legacy_tx_rlp_mod.addImport("zesu_executor", native_imports.executor); + // ── Vanilla StatelessInput SSZ encoder (test/stateless_input_encode.zig) unit tests ──────── // Test-only SSZ encoder for zesu's vanilla StatelessInput — the byte-level inverse of // zesu_ssz_decode's decode, which ships with no matching encoder of its own. A round-trip @@ -385,6 +402,7 @@ pub fn build(b: *std.Build) void { 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); @@ -424,6 +442,39 @@ pub fn build(b: *std.Build) void { // through `StubEngine` instead of live EVM execution. Needs exactly the import set // `conflation_plan.zig` itself needs, since it's pulled in by relative import from the // test root, same as the parity test above. + // + // secp256k1 signing primitive (test-only): the scenario's T1-T4 fixtures are built and + // signed live instead of loaded from pre-minted files, using zesu's own real backend. + // Cannot be wired by rooting a module directly at zesu's vendored + // src/crypto/backends/secp256k1_wrapper.zig the way modexp_impl_mod/ripemd160_impl_mod + // above root modules at their own backend files: unlike those two (which nothing else in + // the graph reaches), this file is ALSO relatively-imported by zesu's own native + // accel_impl root (src/crypto/default.zig, for its ecrecover/verify implementation), and + // accel_impl is always in this graph already (accelerators, needed transitively by + // zesu_executor/zesu_mpt for real EVM ecrecover). Rooting a second module at the same + // path double-claims it — Zig rejects a file belonging to two modules at once — and the + // exposed accelerators/accel_impl surface has no path to `sign`/`getContext` themselves + // (that surface only exposes verify/ecrecover; zesu itself never signs). A `WriteFile` + // step first copies the vendored file byte-for-byte to a fresh path outside zesu's own + // module tree, so the copy — the same real code, just reachable from a path nothing else + // claims — can root its own module. This file's `sign`/`getContext` reach a real + // `@cImport`'d secp256k1.h, and C include paths are per-module and don't cross a + // dependency boundary (zesu's own build.zig hits the same constraint wiring its + // `accel_impl` module: see its `addIncludePath` call there) — so this module needs its + // own include path even though `linkNativeZesuCrypto` below already links libsecp256k1 + // into this test binary for `recoverFixtureSender`'s ecrecover path. + const secp256k1_wrapper_copy = b.addWriteFiles(); + const secp256k1_wrapper_copy_path = secp256k1_wrapper_copy.addCopyFile( + zesu_native.path("src/crypto/backends/secp256k1_wrapper.zig"), + "secp256k1_wrapper.zig", + ); + const secp256k1_wrapper_mod = b.createModule(.{ + .root_source_file = secp256k1_wrapper_copy_path, + .target = native_target, + .optimize = host_optimize, + }); + secp256k1_wrapper_mod.addIncludePath(.{ .cwd_relative = native_crypto.include_path }); + const l2_execution_range_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("test/l2_execution_range_test.zig"), @@ -438,7 +489,9 @@ pub fn build(b: *std.Build) void { 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); diff --git a/riscv-guests/l2-execution/build.zig.zon b/riscv-guests/l2-execution/build.zig.zon index 45c538d355..244b1737cc 100644 --- a/riscv-guests/l2-execution/build.zig.zon +++ b/riscv-guests/l2-execution/build.zig.zon @@ -25,6 +25,14 @@ .build_common = .{ .path = "../build_common" }, // Linea zkVM accelerator wrappers (sibling path dependency). .lineth_accelerators = .{ .path = "../lineth-accelerators" }, + // A generic SSZ serialize/deserialize library over plain Zig structs, targeting the same + // Zig version this package pins. Test-only: gives the test-side vanilla StatelessInput + // encoder a real implementation to serialize against instead of a hand-rolled + // offset-table encoder. Pinned to the v0.0.11 tag. + .ssz = .{ + .url = "https://github.com/blockblaz/ssz.zig/archive/refs/tags/v0.0.11.tar.gz", + .hash = "ssz-0.0.9-Lfwd68m_AwAFGpz2g1kHvokWjp_UsvqDb3Z2D46w5s_-", + }, }, .paths = .{ "build.zig", 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 be37c3cfb7..2d1a9a342e 100644 --- a/riscv-guests/l2-execution/src/l2_execution.zig +++ b/riscv-guests/l2-execution/src/l2_execution.zig @@ -358,16 +358,17 @@ pub fn runL2ExecutionWithEngine(comptime Engine: type, alloc: std.mem.Allocator, // 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) { diff --git a/riscv-guests/l2-execution/test/conflation_plan.zig b/riscv-guests/l2-execution/test/conflation_plan.zig index 12753510c3..dcc90965ef 100644 --- a/riscv-guests/l2-execution/test/conflation_plan.zig +++ b/riscv-guests/l2-execution/test/conflation_plan.zig @@ -37,9 +37,11 @@ const ZERO_HASH: [32]u8 = @splat(0); /// by default, satisfying the guest's own `FeeRecipientMismatch` check with no per-block effort. const DEFAULT_COINBASE: [20]u8 = @splat(0xc0); -/// The address `bridgeStorage` switches on automatically once a plan declares real bridge values -/// (a declared value is only observable once the guest's own zero-address suppression is off). -const DEFAULT_L2_MESSAGE_SERVICE_ADDRESS: [20]u8 = @splat(0xee); +/// 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; @@ -173,7 +175,11 @@ pub const StubEngine = struct { const call_index = next_index; next_index += 1; - const pre_state_root = rlp_decode.findPreStateRoot(si.witness.headers, ep.block_number) orelse ep.state_root; + 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| { @@ -324,7 +330,7 @@ pub const ConflationPlan = struct { 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. `bridgeStorage` switches this on automatically. + /// 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, @@ -352,17 +358,14 @@ pub const ConflationPlan = struct { /// 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()`. Also switches `l2_message_service_address` on (a fixed test constant) if it is - /// still the suppressed zero address, since a declared bridge value is only observable once - /// the guest actually reads it. + /// `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, } - if (isZeroAddress(self.l2_message_service_address)) { - self.l2_message_service_address = DEFAULT_L2_MESSAGE_SERVICE_ADDRESS; - } } /// Derives a fully self-consistent, real-MPT/real-header multi-block input, applies any diff --git a/riscv-guests/l2-execution/test/conflation_plan_parity_test.zig b/riscv-guests/l2-execution/test/conflation_plan_parity_test.zig index 69329c3212..f1f3ff04da 100644 --- a/riscv-guests/l2-execution/test/conflation_plan_parity_test.zig +++ b/riscv-guests/l2-execution/test/conflation_plan_parity_test.zig @@ -1,10 +1,12 @@ -//! Stub-realism guard for `conflation_plan.zig`'s `StubEngine`. +//! 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. +//! 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; @@ -13,6 +15,7 @@ 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"); @@ -55,3 +58,56 @@ test "a default 2-block ConflationPlan runs through StubEngine end to end" { 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 index a8eacc5e15..aacc2ecac6 100644 --- a/riscv-guests/l2-execution/test/l2_execution_range_test.zig +++ b/riscv-guests/l2-execution/test/l2_execution_range_test.zig @@ -3,17 +3,19 @@ //! 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 (Python-minted where the guest's own formula would otherwise only be checked against -//! itself). Twelve one-mutation scenarios each drift a single field or hook away from a realistic +//! 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; @@ -27,73 +29,60 @@ const RANGE_BASE_FEE: u64 = 1_000_000_000; // ─── A realistic 2-block range exercising every public-input field at once ───────────────────── // -// Four real secp256k1-signed legacy (type-0) transactions for chain_id 59144, minted with -// rollup_spec/.venv/bin/python (coincurve): nonce 0-3, gasPrice=1e9, gas=21000, to=0xbb*20, -// value=1000/2000/3000/4000, data=b"", one distinct private key per tx -// (keccak256(b"l2exec-range-fixture/T")). 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. Generating snippet (an -// EIP-155 legacy tx: r/s/recid from `PrivateKey(priv).sign_recoverable(keccak(unsigned_rlp), -// hasher=None)`, v = chainId*2+35+recid, sender = keccak(pubkey_uncompressed[1:])[-20:]): +// 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. // -// from eth_utils import keccak -// from coincurve import PrivateKey -// priv = keccak(b"l2exec-range-fixture/T1") # one label per tx -// fields = [nonce, 1_000_000_000, 21000, TO, value, b""] # each RLP-encoded individually -// unsigned = rlp_list(fields + [59144, b"", b""]) # EIP-155 signing preimage -// sig = PrivateKey(priv).sign_recoverable(keccak(unsigned), hasher=None) # r(32) s(32) recid(1) -// signed = rlp_list(fields + [v, r, s]) # v = 59144*2 + 35 + recid -const T1_RLP = [_]u8{ - 0xf8, 0x68, 0x80, 0x84, 0x3b, 0x9a, 0xca, 0x00, 0x82, 0x52, 0x08, 0x94, 0xbb, 0xbb, 0xbb, 0xbb, - 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, - 0x82, 0x03, 0xe8, 0x80, 0x83, 0x01, 0xce, 0x33, 0xa0, 0x68, 0x1e, 0x7a, 0x01, 0xf9, 0x5d, 0x52, - 0x59, 0xdb, 0xb4, 0x5e, 0x5f, 0xdb, 0xa5, 0xe6, 0xa3, 0xc3, 0xc2, 0xe1, 0x0a, 0x55, 0xb7, 0x2f, - 0xb3, 0x09, 0x71, 0x5c, 0xaf, 0xca, 0x80, 0x81, 0x82, 0xa0, 0x05, 0x30, 0xf5, 0xe7, 0x87, 0x58, - 0xbd, 0x76, 0x4c, 0x9b, 0x88, 0x5b, 0x1a, 0x0e, 0x7c, 0x11, 0xeb, 0x99, 0x09, 0xf5, 0x4d, 0x4b, - 0xa5, 0x3a, 0x62, 0x83, 0xf4, 0xe9, 0xc0, 0xb2, 0x40, 0x06, -}; -const T1_SENDER = [_]u8{ - 0x84, 0x30, 0x35, 0xbd, 0xa9, 0x0a, 0x1b, 0xa3, 0x7b, 0x23, 0xa1, 0xfd, 0xbe, 0x62, 0xda, 0x52, - 0x4e, 0xf3, 0xe2, 0xa3, -}; -const T2_RLP = [_]u8{ - 0xf8, 0x68, 0x01, 0x84, 0x3b, 0x9a, 0xca, 0x00, 0x82, 0x52, 0x08, 0x94, 0xbb, 0xbb, 0xbb, 0xbb, - 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, - 0x82, 0x07, 0xd0, 0x80, 0x83, 0x01, 0xce, 0x34, 0xa0, 0x84, 0xb1, 0x4a, 0xe2, 0x25, 0xe0, 0x3c, - 0xbc, 0xb7, 0x5a, 0x9a, 0x69, 0x09, 0x65, 0xc1, 0x32, 0xc8, 0xd6, 0xf5, 0xfa, 0x19, 0xd7, 0xb5, - 0x2e, 0x97, 0x5b, 0xa4, 0x3d, 0xde, 0xda, 0x4f, 0x5f, 0xa0, 0x63, 0x81, 0xf7, 0xf3, 0x3c, 0xcc, - 0x44, 0x93, 0x56, 0x64, 0x44, 0x8b, 0x8a, 0x7d, 0xa0, 0xe4, 0x89, 0x04, 0x52, 0x78, 0x74, 0xb5, - 0xa3, 0xff, 0xe8, 0xaa, 0x36, 0x39, 0xab, 0xdb, 0xe9, 0xf7, -}; -const T2_SENDER = [_]u8{ - 0x58, 0x30, 0x5d, 0x39, 0xef, 0xe2, 0xb0, 0xb9, 0xde, 0x41, 0x26, 0x54, 0x9e, 0x6f, 0xd3, 0x73, - 0x2e, 0xe7, 0xce, 0xff, -}; -const T3_RLP = [_]u8{ - 0xf8, 0x68, 0x02, 0x84, 0x3b, 0x9a, 0xca, 0x00, 0x82, 0x52, 0x08, 0x94, 0xbb, 0xbb, 0xbb, 0xbb, - 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, - 0x82, 0x0b, 0xb8, 0x80, 0x83, 0x01, 0xce, 0x34, 0xa0, 0x78, 0x49, 0x97, 0x26, 0x8f, 0x3f, 0x1d, - 0x29, 0xbd, 0x75, 0x1b, 0x21, 0x4a, 0x96, 0x43, 0x96, 0x66, 0x20, 0x33, 0x9f, 0x69, 0x05, 0xd6, - 0x15, 0xb2, 0x3a, 0xbd, 0x3e, 0xfd, 0x10, 0xfd, 0x58, 0xa0, 0x61, 0xd2, 0xce, 0x00, 0x0a, 0x5f, - 0x22, 0xed, 0xcf, 0x1b, 0x22, 0x92, 0xf2, 0xb9, 0xd5, 0x92, 0x44, 0xa1, 0x84, 0x61, 0xe4, 0xdf, - 0x9f, 0xf7, 0xcc, 0x78, 0xf9, 0x1b, 0xd1, 0x77, 0x87, 0xbe, -}; -const T3_SENDER = [_]u8{ - 0x26, 0xcb, 0x46, 0x99, 0x5a, 0x42, 0x7f, 0xb9, 0x76, 0xa9, 0x6c, 0x58, 0x8b, 0x09, 0xc5, 0x87, - 0x01, 0xf1, 0x47, 0xff, -}; -const T4_RLP = [_]u8{ - 0xf8, 0x68, 0x03, 0x84, 0x3b, 0x9a, 0xca, 0x00, 0x82, 0x52, 0x08, 0x94, 0xbb, 0xbb, 0xbb, 0xbb, - 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, - 0x82, 0x0f, 0xa0, 0x80, 0x83, 0x01, 0xce, 0x34, 0xa0, 0x0a, 0x0e, 0xef, 0x62, 0x1d, 0xd9, 0xca, - 0x64, 0xbe, 0x53, 0x3f, 0x02, 0x04, 0xea, 0xca, 0xb6, 0x93, 0xb3, 0x85, 0x34, 0xb6, 0x2a, 0xa2, - 0xd2, 0xb9, 0x36, 0x70, 0xd1, 0x8d, 0xda, 0x6a, 0x84, 0xa0, 0x44, 0xd3, 0xaf, 0x85, 0x4d, 0xc0, - 0x79, 0xda, 0x86, 0x95, 0xa3, 0x03, 0x2a, 0x0b, 0xc9, 0x79, 0xab, 0x5a, 0xcf, 0xdf, 0x70, 0x40, - 0x07, 0x54, 0xbd, 0xc7, 0x85, 0xa9, 0x41, 0x58, 0xdf, 0x22, -}; -const T4_SENDER = [_]u8{ - 0x90, 0x9c, 0x96, 0x32, 0x2d, 0xbe, 0x7c, 0x94, 0xb7, 0xcf, 0x16, 0x80, 0x21, 0xb3, 0x2a, 0x9c, - 0x2b, 0x53, 0x63, 0x46, -}; +// 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). @@ -107,10 +96,6 @@ const NON_BRIDGE_TOPIC0: [32]u8 = @splat(0x01); const MESSAGE_HASH_1: [32]u8 = @splat(0x51); const MESSAGE_HASH_2: [32]u8 = @splat(0x52); -/// The bridge address `bridgeStorage` switches on automatically, copied from the plan DSL's own -/// default of the same value — every MessageSent log below is emitted at this address. -const L2_MESSAGE_SERVICE_ADDRESS: [20]u8 = @splat(0xee); - const PARENT_BRIDGE_HASH: [32]u8 = @splat(0x11); const END_BRIDGE_HASH: [32]u8 = @splat(0x22); @@ -119,18 +104,6 @@ const END_BRIDGE_HASH: [32]u8 = @splat(0x22); /// them. const FTX_DEADLINE: u64 = 2_000_000; -/// end_ftx_rolling_hash, independently computed over the same two steps with the same python -/// venv — mirrors the guest's own keccak(prev || txHash || deadline_be32 || from) chain, starting -/// from zero32, chained through FTX1 (T1) then FTX2 (T4): -// -// def step(prev, tx_hash, deadline, sender): -// return keccak(prev + tx_hash + deadline.to_bytes(32, "big") + sender) -// end = step(step(b"\x00" * 32, T1_hash, FTX_DEADLINE, T1_sender), T4_hash, FTX_DEADLINE, T4_sender) -const EXPECTED_END_FTX_ROLLING_HASH = [_]u8{ - 0x08, 0x0d, 0x38, 0xc0, 0x6e, 0x38, 0xf3, 0xeb, 0xbf, 0x36, 0xdf, 0x77, 0xc4, 0x27, 0xdc, 0x83, - 0x94, 0x31, 0x51, 0x73, 0xe4, 0xbf, 0x79, 0x5a, 0x73, 0x10, 0x6a, 0x9e, 0xc0, 0x5b, 0x6d, 0xdf, -}; - /// 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; @@ -155,19 +128,46 @@ test "a realistic 2-block range produces every public-input field exactly" { defer arena.deinit(); const alloc = arena.allocator(); - const msg_log_1 = try testLog(alloc, L2_MESSAGE_SERVICE_ADDRESS, &.{ BRIDGE_MESSAGE_SENT_TOPIC0, ZERO_HASH, ZERO_HASH, MESSAGE_HASH_1 }); - const non_matching_log = try testLog(alloc, L2_MESSAGE_SERVICE_ADDRESS, &.{ NON_BRIDGE_TOPIC0, ZERO_HASH, ZERO_HASH, ZERO_HASH }); - const msg_log_2 = try testLog(alloc, L2_MESSAGE_SERVICE_ADDRESS, &.{ BRIDGE_MESSAGE_SENT_TOPIC0, ZERO_HASH, ZERO_HASH, MESSAGE_HASH_2 }); + // 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, + .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, + .signed_tx_rlp = t4_rlp, .acceptance = api.Acceptance.FILTERED_ADDRESS_FROM, .deadline = FTX_DEADLINE, }; @@ -176,17 +176,21 @@ test "a realistic 2-block range produces every public-input field exactly" { const block1_logs = [_][]const types.Log{&.{msg_log_2}}; const blocks = [_]conflation_plan.BlockPlan{ .{ - .signed_tx_rlps = &.{ &T1_RLP, &T2_RLP }, + .signed_tx_rlps = &.{ t1_rlp, t2_rlp }, .tx_logs = &block0_logs, .forced_transactions = &.{ftx1}, }, .{ - .signed_tx_rlps = &.{&T3_RLP}, + .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 }; + 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 }); @@ -229,21 +233,21 @@ test "a realistic 2-block range produces every public-input field exactly" { 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); + 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}); + 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]); + 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 = [_][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]); + 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 ────────────────────────────────────────── @@ -353,3 +357,21 @@ test "an unsupported fork is rejected" { 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 index fd503f91d7..705d65d980 100644 --- a/riscv-guests/l2-execution/test/stateless_input_encode.zig +++ b/riscv-guests/l2-execution/test/stateless_input_encode.zig @@ -1,297 +1,139 @@ //! 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 section below -//! mirrors that decoder's corresponding section, in the same order, so a schema change is a +//! 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 — see EP_FIXED_SIZE) +//! 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. 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. +//! 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, +}; -// ── Primitive writes (little-endian) ───────────────────────────────────────── - -inline fn writeU32(out: []u8, off: usize, value: u32) void { - std.mem.writeInt(u32, out[off..][0..4], value, .little); -} - -inline fn writeU64(out: []u8, off: usize, value: u64) void { - std.mem.writeInt(u64, out[off..][0..8], value, .little); -} - -// ── List[ByteList] encoder ──────────────────────────────────────────────────── - -/// Encode SSZ `List[ByteList[...], N]` from a slice of opaque byte blobs. The exact inverse of the -/// decoder's byte-list-list reader: N×4-byte LE offsets (each the size of the offset table plus -/// every preceding element's length) followed by the concatenated element data, tightly packed. -fn encodeByteListList(alloc: std.mem.Allocator, items: []const []const u8) ![]u8 { - const head = items.len * 4; - var total: usize = head; - for (items) |item| total += item.len; - - const out = try alloc.alloc(u8, total); - var offset: u32 = @intCast(head); - for (items, 0..) |item, i| { - writeU32(out, i * 4, offset); - offset += @intCast(item.len); - } - var pos: usize = head; - for (items) |item| { - @memcpy(out[pos..][0..item.len], item); - pos += item.len; - } - return out; -} - -// ── SszWithdrawal encoder ───────────────────────────────────────────────────── - -/// SszWithdrawal fixed size: index(8) + validator_index(8) + address(20) + amount(uint64=8) = 44. -const WITHDRAWAL_SIZE: usize = 44; - -fn encodeWithdrawal(out: *[WITHDRAWAL_SIZE]u8, w: input.Withdrawal) void { - writeU64(out, 0, w.index); - writeU64(out, 8, w.validator_index); - @memcpy(out[16..36], &w.address); - writeU64(out, 36, w.amount); -} - -/// Withdrawals are a packed list of fixed-size items — no offset table, just concatenation. -fn encodeWithdrawals(alloc: std.mem.Allocator, withdrawals: []const input.Withdrawal) ![]u8 { - const out = try alloc.alloc(u8, withdrawals.len * WITHDRAWAL_SIZE); - for (withdrawals, 0..) |w, i| { - encodeWithdrawal(out[i * WITHDRAWAL_SIZE ..][0..WITHDRAWAL_SIZE], w); - } - return out; -} - -// ── SszExecutionRequests encoder ────────────────────────────────────────────── - -/// Fixed head: one 4-byte offset per request type — deposits, withdrawals, consolidations, -/// builder_deposits, builder_exits (EIP-8282 / zkevm@v0.6.2) — in that order. -const ER_TYPE_COUNT: usize = 5; -const ER_FIXED_SIZE: usize = ER_TYPE_COUNT * 4; - -fn encodeExecutionRequests(alloc: std.mem.Allocator, er: input.ExecutionRequests) ![]u8 { - const parts = [_][]const u8{ er.deposits, er.withdrawals, er.consolidations, er.builder_deposits, er.builder_exits }; - comptime std.debug.assert(parts.len == ER_TYPE_COUNT); - - var total: usize = ER_FIXED_SIZE; - for (parts) |p| total += p.len; +const PUBKEY_SIZE: usize = 65; - const out = try alloc.alloc(u8, total); - var offset: u32 = @intCast(ER_FIXED_SIZE); - for (parts, 0..) |p, i| { - writeU32(out, i * 4, offset); - offset += @intCast(p.len); +/// 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]; } - var pos: usize = ER_FIXED_SIZE; - for (parts) |p| { - @memcpy(out[pos..][0..p.len], p); - pos += p.len; - } - return out; -} - -// ── SszExecutionPayload encoder (Amsterdam / V4) ────────────────────────────── - -/// Fixed region byte offsets, matching the decoder's table exactly: -/// [0..32] parent_hash -/// [32..52] fee_recipient -/// [52..84] state_root -/// [84..116] receipts_root -/// [116..372] logs_bloom -/// [372..404] prev_randao -/// [404..412] block_number -/// [412..420] gas_limit -/// [420..428] gas_used -/// [428..436] timestamp -/// [436..440] → extra_data (variable offset) -/// [440..472] base_fee_per_gas (uint256 LE — only the low 8 bytes carry a value, since the decoded -/// struct field is a u64; the high 24 bytes are always written zero) -/// [472..504] block_hash -/// [504..508] → transactions (variable offset) -/// [508..512] → withdrawals (variable offset) -/// [512..520] blob_gas_used -/// [520..528] excess_blob_gas -/// [528..532] → block_access_list (variable offset) -/// [532..540] slot_number -const EP_FIXED_SIZE: usize = 540; - -fn encodeExecutionPayload(alloc: std.mem.Allocator, ep: input.ExecutionPayload) ![]u8 { - // 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. - const txs_bytes = try encodeByteListList(alloc, ep.raw_transactions); - const wd_bytes = try encodeWithdrawals(alloc, ep.withdrawals); - - const off_extra_data: usize = EP_FIXED_SIZE; - const off_transactions: usize = off_extra_data + ep.extra_data.len; - const off_withdrawals: usize = off_transactions + txs_bytes.len; - const off_bal: usize = off_withdrawals + wd_bytes.len; - const total: usize = off_bal + ep.block_access_list.len; - - const out = try alloc.alloc(u8, total); - @memcpy(out[0..32], &ep.parent_hash); - @memcpy(out[32..52], &ep.fee_recipient); - @memcpy(out[52..84], &ep.state_root); - @memcpy(out[84..116], &ep.receipts_root); - @memcpy(out[116..372], &ep.logs_bloom); - @memcpy(out[372..404], &ep.prev_randao); - writeU64(out, 404, ep.block_number); - writeU64(out, 412, ep.gas_limit); - writeU64(out, 420, ep.gas_used); - writeU64(out, 428, ep.timestamp); - writeU32(out, 436, @intCast(off_extra_data)); - @memset(out[440..472], 0); - writeU64(out, 440, ep.base_fee_per_gas); - @memcpy(out[472..504], &ep.block_hash); - writeU32(out, 504, @intCast(off_transactions)); - writeU32(out, 508, @intCast(off_withdrawals)); - writeU64(out, 512, ep.blob_gas_used); - writeU64(out, 520, ep.excess_blob_gas); - writeU32(out, 528, @intCast(off_bal)); - writeU64(out, 532, ep.slot_number orelse 0); - - @memcpy(out[off_extra_data..off_transactions], ep.extra_data); - @memcpy(out[off_transactions..off_withdrawals], txs_bytes); - @memcpy(out[off_withdrawals..off_bal], wd_bytes); - @memcpy(out[off_bal..], ep.block_access_list); - - return out; + return &.{}; } -// ── SszNewPayloadRequest encoder ────────────────────────────────────────────── - -/// Fixed head: execution_payload offset(4) + versioned_hashes offset(4) + -/// parent_beacon_block_root(32) + execution_requests offset(4) = 44. -const NPR_FIXED_SIZE: usize = 44; - -fn encodeNewPayloadRequest(alloc: std.mem.Allocator, npr: input.NewPayloadRequest) ![]u8 { - const ep_bytes = try encodeExecutionPayload(alloc, npr.execution_payload); - - // versioned_hashes: List[Bytes32, 4096] — packed 32-byte elements, no offset table. - const vh_bytes = try alloc.alloc(u8, npr.versioned_hashes.len * 32); - for (npr.versioned_hashes, 0..) |h, i| @memcpy(vh_bytes[i * 32 ..][0..32], &h); - - const er_bytes = try encodeExecutionRequests(alloc, npr.execution_requests); - - const off_ep: usize = NPR_FIXED_SIZE; - const off_vh: usize = off_ep + ep_bytes.len; - const off_er: usize = off_vh + vh_bytes.len; - const total: usize = off_er + er_bytes.len; - - const out = try alloc.alloc(u8, total); - writeU32(out, 0, @intCast(off_ep)); - writeU32(out, 4, @intCast(off_vh)); - @memcpy(out[8..40], &npr.parent_beacon_block_root); - writeU32(out, 40, @intCast(off_er)); - - @memcpy(out[off_ep..off_vh], ep_bytes); - @memcpy(out[off_vh..off_er], vh_bytes); - @memcpy(out[off_er..], er_bytes); - - return out; -} - -// ── SszExecutionWitness encoder ─────────────────────────────────────────────── - -/// Fixed head: state(nodes) offset(4) + codes offset(4) + headers offset(4) = 12. -const WITNESS_FIXED_SIZE: usize = 12; - -fn encodeExecutionWitness(alloc: std.mem.Allocator, w: input.ExecutionWitness) ![]u8 { - const nodes_bytes = try encodeByteListList(alloc, w.nodes); - const codes_bytes = try encodeByteListList(alloc, w.codes); - const headers_bytes = try encodeByteListList(alloc, w.headers); - - const off_state: usize = WITNESS_FIXED_SIZE; - const off_codes: usize = off_state + nodes_bytes.len; - const off_headers: usize = off_codes + codes_bytes.len; - const total: usize = off_headers + headers_bytes.len; - - const out = try alloc.alloc(u8, total); - writeU32(out, 0, @intCast(off_state)); - writeU32(out, 4, @intCast(off_codes)); - writeU32(out, 8, @intCast(off_headers)); - - @memcpy(out[off_state..off_codes], nodes_bytes); - @memcpy(out[off_codes..off_headers], codes_bytes); - @memcpy(out[off_headers..], headers_bytes); - - return out; -} - -// ── SszForkActivation encoder ───────────────────────────────────────────────── - -/// Fixed head: block_number-list offset(4) + timestamp-list offset(4) = 8. Each list holds 0 or 1 -/// uint64 — presence is entirely conveyed by the encoded length, exactly like the decoder reads it. -const ACTIVATION_FIXED_SIZE: usize = 8; - -fn encodeForkActivation(alloc: std.mem.Allocator, activation_block: ?u64, activation_timestamp: ?u64) ![]u8 { - const off_bn: usize = ACTIVATION_FIXED_SIZE; - const off_ts: usize = off_bn + @as(usize, if (activation_block != null) 8 else 0); - const total: usize = off_ts + @as(usize, if (activation_timestamp != null) 8 else 0); - - const out = try alloc.alloc(u8, total); - writeU32(out, 0, @intCast(off_bn)); - writeU32(out, 4, @intCast(off_ts)); - if (activation_block) |b| writeU64(out, off_bn, b); - if (activation_timestamp) |t| writeU64(out, off_ts, t); - return out; -} - -// ── SszForkConfig encoder ───────────────────────────────────────────────────── - -/// Fixed head: activation_offset(4) = 4 (only field — fork identity travels in the schema prefix, not -/// here; zkevm@v0.6.2 dropped the fork/blob_schedule fields this container used to carry). -const FORK_CONFIG_FIXED_SIZE: usize = 4; - -fn encodeForkConfig(alloc: std.mem.Allocator, activation_block: ?u64, activation_timestamp: ?u64) ![]u8 { - const activation_bytes = try encodeForkActivation(alloc, activation_block, activation_timestamp); - const out = try alloc.alloc(u8, FORK_CONFIG_FIXED_SIZE + activation_bytes.len); - writeU32(out, 0, @intCast(FORK_CONFIG_FIXED_SIZE)); - @memcpy(out[FORK_CONFIG_FIXED_SIZE..], activation_bytes); - return out; -} - -// ── SszChainConfig encoder ──────────────────────────────────────────────────── - -/// Fixed head: chain_id(8) + active_fork offset(4) = 12. -const CHAIN_CONFIG_FIXED_SIZE: usize = 12; - -fn encodeChainConfig(alloc: std.mem.Allocator, cc: input.ChainConfig) ![]u8 { - const fork_config_bytes = try encodeForkConfig(alloc, cc.activation_block, cc.activation_timestamp); - const out = try alloc.alloc(u8, CHAIN_CONFIG_FIXED_SIZE + fork_config_bytes.len); - writeU64(out, 0, cc.chain_id); - writeU32(out, 8, @intCast(CHAIN_CONFIG_FIXED_SIZE)); - @memcpy(out[CHAIN_CONFIG_FIXED_SIZE..], fork_config_bytes); - return out; -} - -// ── Top-level encoder ────────────────────────────────────────────────────────── - -/// 2-byte schema id (fork byte from `chain_config.active_fork_idx` + revision byte 0x01), followed by -/// the 16-byte all-variable v0.4.1 fixed head: new_payload_request offset(4) + witness offset(4) + -/// chain_config offset(4) + public_keys offset(4). -const SCHEMA_SIZE: usize = 2; -const BODY_FIXED_SIZE: usize = 16; - -/// Public keys are packed ByteVector[65] elements (uncompressed secp256k1, 0x04 prefix retained) — no -/// offset table, just concatenation. -const PUBKEY_SIZE: usize = 65; - /// 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`. /// @@ -299,36 +141,68 @@ const PUBKEY_SIZE: usize = 65; /// 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 npr_bytes = try encodeNewPayloadRequest(alloc, si.new_payload_request); - const witness_bytes = try encodeExecutionWitness(alloc, si.witness); - const chain_config_bytes = try encodeChainConfig(alloc, si.chain_config); + const ep = si.new_payload_request.execution_payload; - const pubkeys_bytes = try alloc.alloc(u8, si.public_keys.len * PUBKEY_SIZE); + const public_keys = try alloc.alloc([PUBKEY_SIZE]u8, si.public_keys.len); for (si.public_keys, 0..) |key, i| { if (key.len != PUBKEY_SIZE) return error.InvalidPublicKeySize; - @memcpy(pubkeys_bytes[i * PUBKEY_SIZE ..][0..PUBKEY_SIZE], key); + @memcpy(&public_keys[i], key); } - const off_npr: usize = BODY_FIXED_SIZE; - const off_witness: usize = off_npr + npr_bytes.len; - const off_chain_config: usize = off_witness + witness_bytes.len; - const off_pubkeys: usize = off_chain_config + chain_config_bytes.len; - const total: usize = SCHEMA_SIZE + off_pubkeys + pubkeys_bytes.len; - - const out = try alloc.alloc(u8, total); - out[0] = @intCast(si.chain_config.active_fork_idx); - out[1] = 0x01; - - const body = out[SCHEMA_SIZE..]; - writeU32(body, 0, @intCast(off_npr)); - writeU32(body, 4, @intCast(off_witness)); - writeU32(body, 8, @intCast(off_chain_config)); - writeU32(body, 12, @intCast(off_pubkeys)); - - @memcpy(body[off_npr..off_witness], npr_bytes); - @memcpy(body[off_witness..off_chain_config], witness_bytes); - @memcpy(body[off_chain_config..off_pubkeys], chain_config_bytes); - @memcpy(body[off_pubkeys..], pubkeys_bytes); - - return out; + 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 index 78c7d6bea5..b3d642567d 100644 --- a/riscv-guests/l2-execution/test/stateless_input_encode_test.zig +++ b/riscv-guests/l2-execution/test/stateless_input_encode_test.zig @@ -11,6 +11,7 @@ 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; @@ -23,41 +24,28 @@ fn expectByteListListEqual(want: []const []const u8, got: []const []const u8) !v for (want, got) |w, g| try std.testing.expectEqualSlices(u8, w, g); } -// ─── Hand-crafted legacy transactions ────────────────────────────────────────────────────────────── -// Two standalone legacy-RLP transactions (`raw[0] >= 0xc0`, the decoder's legacy-tx branch), built and -// checked field-by-field against the decoder's own parsing formulas before being frozen here as -// literals. 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`. +// ─── 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_RLP = [_]u8{ - 0xf8, 0x67, 0x07, 0x84, 0x3b, 0x9a, 0xca, 0x00, 0x82, 0x52, 0x08, 0x94, 0xaa, 0xaa, 0xaa, 0xaa, - 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, - 0x82, 0x03, 0xe8, 0x80, 0x83, 0x01, 0xce, 0x34, 0xa0, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, - 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, - 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x9f, 0xfe, 0xdc, 0xba, 0x09, 0x87, 0x65, - 0x43, 0x21, 0xfe, 0xdc, 0xba, 0x09, 0x87, 0x65, 0x43, 0x21, 0xfe, 0xdc, 0xba, 0x09, 0x87, 0x65, - 0x43, 0x21, 0xfe, 0xdc, 0xba, 0x09, 0x87, 0x65, 0x43, -}; 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_RLP = [_]u8{ - 0xf8, 0x55, 0x80, 0x84, 0x77, 0x35, 0x94, 0x00, 0x83, 0x01, 0x86, 0xa0, 0x80, 0x80, 0x86, 0x60, - 0x80, 0x60, 0x40, 0x52, 0x00, 0x1c, 0xa0, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, - 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, - 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x9f, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, - 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, - 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, -}; 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: the RLP bytes below strip a leading zero byte -// off this value, so it decodes to a 31-byte (not 32-byte) big-endian integer. +// 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 ──────────────────────────────────────────────────────────── @@ -95,7 +83,7 @@ const BLOCK_ACCESS_LIST_BYTES = repeat(16, 0x99); /// 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() input.StatelessInput { +fn sampleInput(raw_transactions: []const []const u8) input.StatelessInput { return .{ .new_payload_request = .{ .execution_payload = .{ @@ -115,7 +103,7 @@ fn sampleInput() input.StatelessInput { // The encoder reads `raw_transactions`, the wire-format field; this decoded // convenience view stays empty here and is repopulated by decode. .transactions = &.{}, - .raw_transactions = &.{ &TX_A_RLP, &TX_B_RLP }, + .raw_transactions = raw_transactions, .withdrawals = &WITHDRAWALS, .blob_gas_used = 131_072, .excess_blob_gas = 0, @@ -152,7 +140,14 @@ test "encode then decode round-trips every field, covering every variable-length defer arena.deinit(); const alloc = arena.allocator(); - const value = sampleInput(); + 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); @@ -180,8 +175,8 @@ test "encode then decode round-trips every field, covering every variable-length // 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.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]; From 48bb50d5866a26cb34e2e7c1c320b67ce6de5df1 Mon Sep 17 00:00:00 2001 From: Roman <4833306+Filter94@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:36:49 +0200 Subject: [PATCH 3/3] fix(riscv-guest): Fixing the comments Signed-off-by: Roman <4833306+Filter94@users.noreply.github.com> --- riscv-guests/l2-execution/build.zig | 67 ++++++------------- riscv-guests/l2-execution/build.zig.zon | 1 + .../test/stateless_input_encode.zig | 1 + 3 files changed, 23 insertions(+), 46 deletions(-) diff --git a/riscv-guests/l2-execution/build.zig b/riscv-guests/l2-execution/build.zig index 389fc0a7bf..cbbe28c102 100644 --- a/riscv-guests/l2-execution/build.zig +++ b/riscv-guests/l2-execution/build.zig @@ -279,13 +279,11 @@ pub fn build(b: *std.Build) void { .optimize = host_optimize, }); stateless_input_encode_mod.addImport("zesu_input", native_imports.input); - // A generic SSZ serialize/deserialize library over plain Zig structs. Test-only, - // native-target-only: the guest never imports this module (it has no relative or named import - // path to `stateless_input_encode.zig`), so `.ssz` never enters the freestanding riscv64 compile - // graph. The library's own build.zig exposes its module under the name "ssz.zig" (its literal - // `b.addModule` argument); "ssz" here is only this file's own local import name for it. - const ssz_dep = b.dependency("ssz", .{ .target = native_target, .optimize = host_optimize }); - stateless_input_encode_mod.addImport("ssz", ssz_dep.module("ssz.zig")); + // 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 @@ -385,12 +383,8 @@ pub fn build(b: *std.Build) void { legacy_tx_rlp_mod.addImport("zesu_executor", native_imports.executor); // ── Vanilla StatelessInput SSZ encoder (test/stateless_input_encode.zig) unit tests ──────── - // Test-only SSZ encoder for zesu's vanilla StatelessInput — the byte-level inverse of - // zesu_ssz_decode's decode, which ships with no matching encoder of its own. A round-trip - // test builds a StatelessInput as readable Zig and checks it survives encode-then-decode; a - // golden test decodes this same fixture and checks re-encoding it reproduces the original - // bytes exactly. Needs the same zesu_input/zesu_ssz_decode imports as the vanilla-wrap - // wiring, plus the fixtures module already built for the guest smoke test above. + // 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"), @@ -407,13 +401,8 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&b.addRunArtifact(stateless_input_encode_tests).step); // ── Conflation-plan test DSL parity guard (test/conflation_plan_parity_test.zig) ──────────── - // Proves `conflation_plan.zig`'s `StubEngine` (the l2-execution guest's execution-seam stub) - // is a faithful stand-in for the real per-block execution seam on real data, and smoke-tests - // the `ConflationPlan` DSL itself end to end through the guest's real conflation logic. - // `conflation_plan.zig` is pulled in by relative import from the test file, so every import - // it needs (the full zesu set its real-MPT/real-header derivation uses, `l2_execution` for - // the seam, `l2_execution_ssz` for the envelope codec, and the shared - // `stateless_input_encode` module) is wired directly on this root module. + // 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"), @@ -436,33 +425,19 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&b.addRunArtifact(conflation_plan_parity_tests).step); // ── Conflation-plan range scenario suite (test/l2_execution_range_test.zig) ─────────────── - // One rich happy-path scenario asserting the full 16-field public input plus preimages, - // and twelve one-mutation rejection scenarios, both built on the `ConflationPlan` DSL — - // driving the guest's real conflation logic (`l2_execution.runL2ExecutionWithEngine`) - // through `StubEngine` instead of live EVM execution. Needs exactly the import set - // `conflation_plan.zig` itself needs, since it's pulled in by relative import from the - // test root, same as the parity test above. + // Same relative-import reasoning as the parity test above: needs conflation_plan.zig's own + // import set wired directly here. // - // secp256k1 signing primitive (test-only): the scenario's T1-T4 fixtures are built and - // signed live instead of loaded from pre-minted files, using zesu's own real backend. - // Cannot be wired by rooting a module directly at zesu's vendored - // src/crypto/backends/secp256k1_wrapper.zig the way modexp_impl_mod/ripemd160_impl_mod - // above root modules at their own backend files: unlike those two (which nothing else in - // the graph reaches), this file is ALSO relatively-imported by zesu's own native - // accel_impl root (src/crypto/default.zig, for its ecrecover/verify implementation), and - // accel_impl is always in this graph already (accelerators, needed transitively by - // zesu_executor/zesu_mpt for real EVM ecrecover). Rooting a second module at the same - // path double-claims it — Zig rejects a file belonging to two modules at once — and the - // exposed accelerators/accel_impl surface has no path to `sign`/`getContext` themselves - // (that surface only exposes verify/ecrecover; zesu itself never signs). A `WriteFile` - // step first copies the vendored file byte-for-byte to a fresh path outside zesu's own - // module tree, so the copy — the same real code, just reachable from a path nothing else - // claims — can root its own module. This file's `sign`/`getContext` reach a real - // `@cImport`'d secp256k1.h, and C include paths are per-module and don't cross a - // dependency boundary (zesu's own build.zig hits the same constraint wiring its - // `accel_impl` module: see its `addIncludePath` call there) — so this module needs its - // own include path even though `linkNativeZesuCrypto` below already links libsecp256k1 - // into this test binary for `recoverFixtureSender`'s ecrecover path. + // 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"), diff --git a/riscv-guests/l2-execution/build.zig.zon b/riscv-guests/l2-execution/build.zig.zon index 244b1737cc..6c9b886188 100644 --- a/riscv-guests/l2-execution/build.zig.zon +++ b/riscv-guests/l2-execution/build.zig.zon @@ -32,6 +32,7 @@ .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 = .{ diff --git a/riscv-guests/l2-execution/test/stateless_input_encode.zig b/riscv-guests/l2-execution/test/stateless_input_encode.zig index 705d65d980..e533c5576b 100644 --- a/riscv-guests/l2-execution/test/stateless_input_encode.zig +++ b/riscv-guests/l2-execution/test/stateless_input_encode.zig @@ -144,6 +144,7 @@ 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);