From 1c0563969bf9342e5745bd5af194925ebd9d675e Mon Sep 17 00:00:00 2001 From: Roman <4833306+Filter94@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:01:41 +0200 Subject: [PATCH 1/2] feat(riscv-guest): more l2-execution guest tests (pr2-v2) Introduce l2_execution_test.zig here (relocated from the base branch, finalized to "Python reference implementation" wording from the start) alongside this PR's own unit-gap and adversarial-SSZ coverage: FTX dispatch edge cases (unknown acceptance, contract-creation filtering, absent-sender, in-block BAD_NONCE, deadline boundary, type-2/3/4 BAD_BALANCE), malformed message-log rejection, and composed L1<->L2 bridge-state reads. Generalize the shared tx fixture builder (legacy-only -> legacy/EIP-1559/EIP-4844/EIP-7702) and collapse its four near-identical per-type doc comments into one shared explanation at the module level. --- riscv-guests/l2-execution/build.zig | 115 ++- .../l2-execution/src/l2_execution.zig | 15 +- .../test/l2_execution_range_test.zig | 83 +- .../l2-execution/test/l2_execution_test.zig | 815 ++++++++++++++++++ .../l2-execution/test/legacy_tx_rlp.zig | 39 - .../test/stateless_input_encode_test.zig | 6 +- .../l2-execution/test/tx_fixtures.zig | 261 ++++++ 7 files changed, 1221 insertions(+), 113 deletions(-) create mode 100644 riscv-guests/l2-execution/test/l2_execution_test.zig delete mode 100644 riscv-guests/l2-execution/test/legacy_tx_rlp.zig create mode 100644 riscv-guests/l2-execution/test/tx_fixtures.zig diff --git a/riscv-guests/l2-execution/build.zig b/riscv-guests/l2-execution/build.zig index 7795e876cb..d9076a8308 100644 --- a/riscv-guests/l2-execution/build.zig +++ b/riscv-guests/l2-execution/build.zig @@ -192,12 +192,58 @@ pub fn build(b: *std.Build) void { l2_execution_ssz_tests.root_module.addImport("l2_execution_ssz", l2_execution_ssz_mod); test_step.dependOn(&b.addRunArtifact(l2_execution_ssz_tests).step); - // ── l2-execution guest logic (src/l2_execution.zig), native build ─────────────────────────────── - // Built for the native target so `extended-vanilla` below can link the SAME Linea-layer logic the - // riscv64 guest ELF runs (reached there via `evm_execution_guest.zig`'s relative import inside - // `guestMain` — see the module-wiring comment near `l2_execution_ssz_guest_mod` above). Needs the - // full zesu import set (MPT, executor types/tx-decode, primitives, accelerators for ecrecover) - // plus the sibling `l2_execution_ssz` module. + // ── Shared signed-tx fixture builder (test/tx_fixtures.zig) ───────────────────────────────────── + // RLP encoders for legacy/EIP-1559/EIP-4844 wire shapes plus real secp256k1 signing, shared by + // every test root that needs a genuinely signed, sender-recoverable transaction rather than a + // byte literal. Defined here (not inside the lazy execution-spec-tests block below): neither + // this module nor secp256k1_wrapper_mod depends on that lazy dependency, and l2_execution_tests + // (just below) is the first consumer needing them that lives outside that block. + const tx_fixtures_mod = b.createModule(.{ + .root_source_file = b.path("test/tx_fixtures.zig"), + .target = native_target, + .optimize = host_optimize, + }); + tx_fixtures_mod.addImport("zesu_executor", native_imports.executor); + tx_fixtures_mod.addImport("zesu_mpt", native_imports.mpt); + + // secp256k1_wrapper.zig can't be rooted directly as its own module the way + // modexp_impl_mod/ripemd160_impl_mod are: unlike those two, this file is ALSO + // relatively-imported by zesu's own accel_impl root (already in this graph via + // accelerators), and Zig rejects one file belonging to two modules at once. The exposed + // accelerators surface has no path to `sign`/`getContext` either (it only exposes + // verify/ecrecover). A WriteFile step copies the file byte-for-byte to a fresh path + // nothing else claims, so the copy can root its own module. That module needs its own C + // include path for its `@cImport`'d secp256k1.h — C include paths are per-module and don't + // inherit from linkNativeZesuCrypto below (zesu's own build.zig hits the same constraint + // wiring accel_impl). + const secp256k1_wrapper_copy = b.addWriteFiles(); + const secp256k1_wrapper_copy_path = secp256k1_wrapper_copy.addCopyFile( + zesu_native.path("src/crypto/backends/secp256k1_wrapper.zig"), + "secp256k1_wrapper.zig", + ); + const secp256k1_wrapper_mod = b.createModule(.{ + .root_source_file = secp256k1_wrapper_copy_path, + .target = native_target, + .optimize = host_optimize, + }); + secp256k1_wrapper_mod.addIncludePath(.{ .cwd_relative = native_crypto.include_path }); + tx_fixtures_mod.addImport("zesu_secp256k1", secp256k1_wrapper_mod); + + // ── l2-execution guest logic (src/l2_execution.zig) unit tests ────────────────────────────────── + // These `zig build test` UNIT TESTS run only on the native target — like the l2_execution_ssz + // codec tests above, and like every other `b.addTest` artifact in this file. That's a standard + // Zig constraint, not specific to this module: a freestanding riscv64 target has no OS to run a + // `std.testing` binary against, so test binaries are always compiled and run for the native host. + // The `l2_execution.zig` LOGIC ITSELF is not native-only — it's compiled into the riscv64 guest + // ELF too, reached via `evm_execution_guest.zig`'s relative import inside `guestMain` (see the + // module-wiring comment near `l2_execution_ssz_guest_mod` above). + // + // These tests exercise the Linea-layer logic — the FTX rolling hash, dynamicChainConfigHash, + // hashAddressList/hashDigestList, the L1->L2 bridge storage-slot math, L2->L1 message + // extraction, forced-transaction dispatch, and the witness-backed MPT account/storage reads — + // against hand-built fixtures and Python-computed expected values (see Readme.md §6.3/§6.5/§2.1). + // Needs the full zesu import set (MPT, executor types/tx-decode, primitives, accelerators for + // ecrecover) plus the sibling `l2_execution_ssz` module. const l2_execution_mod = b.createModule(.{ .root_source_file = b.path("src/l2_execution.zig"), .target = native_target, @@ -206,6 +252,25 @@ pub fn build(b: *std.Build) void { addExecutionImports(l2_execution_mod, native_imports); l2_execution_mod.addImport("l2_execution_ssz", l2_execution_ssz_mod); + const l2_execution_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("test/l2_execution_test.zig"), + .target = native_target, + .optimize = host_optimize, + }), + }); + l2_execution_tests.root_module.addImport("l2_execution", l2_execution_mod); + l2_execution_tests.root_module.addImport("l2_execution_ssz", l2_execution_ssz_mod); + l2_execution_tests.root_module.addImport("zesu_executor", native_imports.executor); + l2_execution_tests.root_module.addImport("zesu_mpt", native_imports.mpt); + l2_execution_tests.root_module.addImport("zesu_input", native_imports.input); + l2_execution_tests.root_module.addImport("zesu_primitives", native_imports.primitives); + l2_execution_tests.root_module.addImport("zesu_allocator", native_imports.allocator); + l2_execution_tests.root_module.addImport("zesu_accelerators", native_imports.accelerators); + l2_execution_tests.root_module.addImport("tx_fixtures", tx_fixtures_mod); + linkNativeZesuCrypto(l2_execution_tests, native_target, native_crypto); + test_step.dependOn(&b.addRunArtifact(l2_execution_tests).step); + // ── l2-execution JSON output shape (test/l2_execution_json.zig) ───────────────────────────────── // Native-only, pure std + the sibling `l2_execution_ssz` module (no zesu dependency): asserts // `encodeOutputJson`'s field names/order/hex format agree byte-for-byte with the Python @@ -352,16 +417,6 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&b.addRunArtifact(tests).step); - // ── Shared legacy-tx RLP encoder (test/legacy_tx_rlp.zig) ─────────────────────────────────── - // One RLP encoder for a legacy transaction's fixed field list, shared by every test fixture - // that builds one from named fields rather than a byte literal. - const legacy_tx_rlp_mod = b.createModule(.{ - .root_source_file = b.path("test/legacy_tx_rlp.zig"), - .target = native_target, - .optimize = host_optimize, - }); - legacy_tx_rlp_mod.addImport("zesu_executor", native_imports.executor); - // ── Vanilla StatelessInput SSZ encoder (test/stateless_input_encode.zig) unit tests ──────── // Reuses the zesu_input/zesu_ssz_decode imports already resolved above for vanilla_wrap_mod, // plus the fixtures module already built for the guest smoke test above. @@ -376,7 +431,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); + stateless_input_encode_tests.root_module.addImport("tx_fixtures", tx_fixtures_mod); linkNativeZesuCrypto(stateless_input_encode_tests, native_target, native_crypto); test_step.dependOn(&b.addRunArtifact(stateless_input_encode_tests).step); @@ -407,29 +462,6 @@ pub fn build(b: *std.Build) void { // ── Conflation-plan range scenario suite (test/l2_execution_range_test.zig) ─────────────── // Same relative-import reasoning as the parity test above: needs conflation_plan.zig's own // import set wired directly here. - // - // secp256k1_wrapper.zig can't be rooted directly as its own module the way - // modexp_impl_mod/ripemd160_impl_mod are: unlike those two, this file is ALSO - // relatively-imported by zesu's own accel_impl root (already in this graph via - // accelerators), and Zig rejects one file belonging to two modules at once. The exposed - // accelerators surface has no path to `sign`/`getContext` either (it only exposes - // verify/ecrecover). A WriteFile step copies the file byte-for-byte to a fresh path - // nothing else claims, so the copy can root its own module. That module needs its own C - // include path for its `@cImport`'d secp256k1.h — C include paths are per-module and don't - // inherit from linkNativeZesuCrypto below (zesu's own build.zig hits the same constraint - // wiring accel_impl). - const secp256k1_wrapper_copy = b.addWriteFiles(); - const secp256k1_wrapper_copy_path = secp256k1_wrapper_copy.addCopyFile( - zesu_native.path("src/crypto/backends/secp256k1_wrapper.zig"), - "secp256k1_wrapper.zig", - ); - const secp256k1_wrapper_mod = b.createModule(.{ - .root_source_file = secp256k1_wrapper_copy_path, - .target = native_target, - .optimize = host_optimize, - }); - secp256k1_wrapper_mod.addIncludePath(.{ .cwd_relative = native_crypto.include_path }); - const l2_execution_range_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("test/l2_execution_range_test.zig"), @@ -444,9 +476,8 @@ 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); + l2_execution_range_tests.root_module.addImport("tx_fixtures", tx_fixtures_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/src/l2_execution.zig b/riscv-guests/l2-execution/src/l2_execution.zig index 17c71b8032..9e9c75d95a 100644 --- a/riscv-guests/l2-execution/src/l2_execution.zig +++ b/riscv-guests/l2-execution/src/l2_execution.zig @@ -49,7 +49,15 @@ const BRIDGE_L2L1_MESSAGE_SENT_TOPIC_0: [32]u8 = .{ 0xbd, 0x80, 0xa1, 0xcf, 0x8d, 0xb7, 0x2e, 0x6c, }; -/// Storage layout of L2MessageService (see the Python reference implementation's docstring for provenance). +/// Storage layout of the L2MessageService contract: `lastAnchoredL1MessageNumber` at the fixed +/// slot below, `l1RollingHashes` (a mapping keyed by message number) at the mapping base slot +/// below. Solidity assigns storage slots by state-variable declaration order across the whole +/// inheritance chain — a property of the contract's compiled bytecode, independent of chain id or +/// deployment address, so the same slots hold on every deployment of the same contract version. +/// These two numbers are extracted from the compiled storage layout of +/// contracts/src/messaging/l2/L2MessageService.sol; if that layout ever changes (including a +/// `__gap` slot in an ancestor), they must be re-extracted, or these reads return wrong values and +/// the L1 finalization check fails. const LAST_ANCHORED_L1_MESSAGE_NUMBER_SLOT: u64 = 280; const L1_ROLLING_HASHES_MAPPING_BASE_SLOT: u64 = 281; @@ -124,7 +132,7 @@ fn hashAddressList(alloc: std.mem.Allocator, values: []const [20]u8) ![32]u8 { // ─── Witness-backed MPT state reads (mirrors state_transition.py's L2State) ─────────────────────── // -// Semantics (must match the Python reference implementation exactly — see Readme.md's state_transition.py docstrings): +// Semantics must match Readme.md's specification exactly (state_transition.py is its reference implementation): // - account/slot proven absent from the trie -> `null` / `0` (NOT an error); // - a witness node needed to resolve the path is missing from the pool -> `error.InvalidProof` // propagates (guest rejection). `verifyAccountIndexed`/`verifyStorageIndexed` already draw this @@ -133,7 +141,8 @@ fn hashAddressList(alloc: std.mem.Allocator, values: []const [20]u8) ![32]u8 { // This is DELIBERATELY NOT `zesu_db.WitnessDatabase`: its `basic()`/`storage()` catch // `error.InvalidProof` and silently treat it as absence (a leniency WitnessDatabase needs for // precompile addresses that have no witness proof during live EVM execution) — that would mask a -// genuinely incomplete witness here, where the Python spec's `_mpt_lookup` raises instead. +// genuinely incomplete witness here, where the Python reference implementation's `_mpt_lookup` +// raises instead. /// Account at `address` proven against `state_root`, or `null` if proven absent. fn readAccount(state_root: [32]u8, address: [20]u8, node_index: *const mpt.NodeIndex) !?mpt.AccountState { 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 aacc2ecac6..d0256dbbb7 100644 --- a/riscv-guests/l2-execution/test/l2_execution_range_test.zig +++ b/riscv-guests/l2-execution/test/l2_execution_range_test.zig @@ -3,19 +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. 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. +//! values. Fifteen one-mutation scenarios each drift a single field or hook away from a realistic +//! default range, one per rejection (or, for a handful, one per accepted/observed edge case) 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 tx_fixtures = @import("tx_fixtures"); const types = executor.executor_types; const api = l2_execution.test_api; @@ -53,29 +53,18 @@ 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. +/// Thin wrapper baking this file's own RANGE_* constants onto the shared signed-legacy-tx +/// builder — the same label/nonce/value triple derives the same private key and signs the same +/// transaction bytes as before, so T1-T4's senders and signatures are unchanged. 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); + return tx_fixtures.buildSignedLegacyTx(alloc, label, .{ + .nonce = nonce, + .gas_price = RANGE_TX_GAS_PRICE, + .gas = RANGE_TX_GAS, + .to = RANGE_TX_TO, + .value = value, + .chain_id = RANGE_CHAIN_ID, + }); } fn recoverFixtureSender(alloc: std.mem.Allocator, signed_tx_rlp: []const u8, chain_id: u64) ![20]u8 { @@ -375,3 +364,45 @@ test "bridge storage declared while the address stays at its suppressed zero def 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); } + +test "a decreasing bridge message number across the range is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var plan = conflation_plan.ConflationPlan{ .l2_message_service_address = conflation_plan.DEFAULT_L2_MESSAGE_SERVICE_ADDRESS }; + plan.bridgeStorage(.parent, .{ .number = 7, .hash = PARENT_BRIDGE_HASH }); + plan.bridgeStorage(.end, .{ .number = 5, .hash = END_BRIDGE_HASH }); + + try plan.expectReject(arena.allocator(), error.RollingHashNumberDecreased); +} + +test "a matching bridge log is ignored while the address stays at its suppressed zero default" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // A realistic non-zero contract address for the log's own emitting address; the field that + // actually stays suppressed at its zero default below is l2_message_service_address. + const placeholder_log_address: [20]u8 = @splat(0xdd); + // StubEngine attaches declared logs per DECODED transaction, so a real tx must ride in the + // block for this log to have anything to attach to. + const t_rlp = try buildSignedFixtureTx(alloc, "T12", 0, 1000); + const bridge_log = try testLog(alloc, placeholder_log_address, &.{ BRIDGE_MESSAGE_SENT_TOPIC0, ZERO_HASH, ZERO_HASH, MESSAGE_HASH_1 }); + + const block0_logs = [_][]const types.Log{&.{bridge_log}}; + const blocks = [_]conflation_plan.BlockPlan{ + .{ .signed_tx_rlps = &.{t_rlp}, .tx_logs = &block0_logs }, + .{}, + }; + // l2_message_service_address is left at the DSL's own suppressed zero default. + const plan = conflation_plan.ConflationPlan{ .blocks = &blocks }; + + const output = try plan.run(alloc); + + try testing.expectEqual(@as(usize, 0), output.l2_l1_messages.len); + const expected_empty_hash = try api.hashDigestListFn(alloc, &.{}); + try testing.expectEqualSlices(u8, &expected_empty_hash, &output.public_inputs.l2_l1_messages_hash); + 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/l2_execution_test.zig b/riscv-guests/l2-execution/test/l2_execution_test.zig new file mode 100644 index 0000000000..a17e8d0695 --- /dev/null +++ b/riscv-guests/l2-execution/test/l2_execution_test.zig @@ -0,0 +1,815 @@ +//! Unit tests for src/l2_execution.zig. +//! +//! The golden `getZkL2ExecutionProofV1.{input,output}.ssz` fixtures (exercised by +//! test/l2_execution_ssz_test.zig) are hand-authored codec vectors with dummy witnesses +//! (`stateRoot=0x1111...`) — real execution cannot reproduce them, so `runL2Execution` is verified +//! here with focused unit tests instead: deterministic helpers against Python-computed expected +//! values, message extraction, forced-transaction dispatch, and witness-backed MPT reads against a +//! hand-built trie. + +const std = @import("std"); +const testing = std.testing; + +const l2_execution = @import("l2_execution"); +const l2_execution_ssz = @import("l2_execution_ssz"); +const executor = @import("zesu_executor"); +const mpt = @import("zesu_mpt"); +const primitives = @import("zesu_primitives"); +const tx_fixtures = @import("tx_fixtures"); + +const types = executor.executor_types; +const rlp = executor.executor_rlp_encode; +const api = l2_execution.test_api; + +fn repeat(comptime n: usize, byte: u8) [n]u8 { + var out: [n]u8 = undefined; + for (&out) |*b| b.* = byte; + return out; +} + +// ─── Deterministic helpers vs Python-computed expected values ──────────────────────────────────── +// Expected bytes computed with `rollup_spec/.venv/bin/python` against the same formulas in +// `rollup_spec/src/rollup_spec/l2_execution.py` / `block.py`. + +test "chainConfigHash matches Readme.md's §2.1 dynamicChainConfigHash formula" { + const chain_config = l2_execution_ssz.ChainConfig{ + .l2_message_service_address = repeat(20, 0x11), + .coinbase = repeat(20, 0x00), + .chain_id = 59144, + }; + const got = api.chainConfigHashFn(chain_config, 1_000_000_000); + const want = [_]u8{ 0xeb, 0x9a, 0xbc, 0xa2, 0x92, 0x7e, 0x7d, 0x36, 0x99, 0x9c, 0x8d, 0x0a, 0xe3, 0xf4, 0x94, 0xf7, 0xb0, 0x12, 0x0a, 0xde, 0xc4, 0x1f, 0x5c, 0xe1, 0x3a, 0x2b, 0x98, 0xdd, 0xa4, 0x38, 0x50, 0x06 }; + try testing.expectEqualSlices(u8, &want, &got); +} + +test "addToForcedTxRollingHash matches Readme.md's §6.3 forced-tx rolling-hash formula" { + const prev = repeat(32, 0x22); + const tx_hash = repeat(32, 0x33); + const from_address = repeat(20, 0x44); + const got = api.addToForcedTxRollingHashFn(prev, tx_hash, 12345, from_address); + const want = [_]u8{ 0x9e, 0xc6, 0xd4, 0x57, 0x32, 0x98, 0x0b, 0x86, 0xb3, 0x7d, 0xc1, 0xbd, 0x7e, 0xaa, 0xfd, 0xf6, 0x6b, 0xd5, 0xbf, 0xdd, 0x7f, 0x8d, 0x04, 0x3e, 0xfb, 0x2f, 0x94, 0x54, 0x65, 0xb2, 0x89, 0x95 }; + try testing.expectEqualSlices(u8, &want, &got); +} + +test "hashAddressList / hashDigestList match the Python reference implementation, including the empty-list case" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const empty_want = [_]u8{ 0xc5, 0xd2, 0x46, 0x01, 0x86, 0xf7, 0x23, 0x3c, 0x92, 0x7e, 0x7d, 0xb2, 0xdc, 0xc7, 0x03, 0xc0, 0xe5, 0x00, 0xb6, 0x53, 0xca, 0x82, 0x27, 0x3b, 0x7b, 0xfa, 0xd8, 0x04, 0x5d, 0x85, 0xa4, 0x70 }; + const empty_addrs = try api.hashAddressListFn(alloc, &.{}); + try testing.expectEqualSlices(u8, &empty_want, &empty_addrs); + const empty_hashes = try api.hashDigestListFn(alloc, &.{}); + try testing.expectEqualSlices(u8, &empty_want, &empty_hashes); + + const addr1 = repeat(20, 0x01); + const addr2 = repeat(20, 0x02); + const addr_want = [_]u8{ 0xce, 0x6c, 0x5b, 0x9e, 0x2c, 0xfc, 0x57, 0x0f, 0x35, 0x9b, 0x4c, 0xdc, 0x1f, 0x7f, 0x7c, 0x88, 0x07, 0x72, 0x48, 0x38, 0x8f, 0xbb, 0x0c, 0xf6, 0xc8, 0x29, 0xef, 0x8d, 0xa0, 0x29, 0x61, 0x5e }; + const got_addr = try api.hashAddressListFn(alloc, &.{ addr1, addr2 }); + try testing.expectEqualSlices(u8, &addr_want, &got_addr); + + const h1 = repeat(32, 0xaa); + const h2 = repeat(32, 0xbb); + const hash_want = [_]u8{ 0x9f, 0x89, 0xfa, 0xaf, 0x14, 0x95, 0x29, 0x83, 0x00, 0xca, 0x41, 0xed, 0xde, 0x79, 0xc5, 0xcc, 0x9c, 0xb9, 0xbf, 0x17, 0xe1, 0xc9, 0xef, 0x97, 0xac, 0xfd, 0xc5, 0x31, 0x94, 0xf9, 0x01, 0xe1 }; + const got_hash = try api.hashDigestListFn(alloc, &.{ h1, h2 }); + try testing.expectEqualSlices(u8, &hash_want, &got_hash); +} + +test "mappingSlot matches the Python reference implementation's Solidity mapping-slot formula" { + const base_slot = api.u64ToSlot32Fn(281); + const key = api.u64ToSlot32Fn(7); + const got = api.mappingSlotFn(base_slot, key); + const want = [_]u8{ 0x41, 0x92, 0x5a, 0x5f, 0x3a, 0xee, 0x45, 0x64, 0x13, 0x08, 0x42, 0xc8, 0xb6, 0x49, 0x61, 0xf2, 0x2a, 0x92, 0xfe, 0x31, 0x47, 0x84, 0x8f, 0xf9, 0xbf, 0xb3, 0xb4, 0x9b, 0x11, 0x55, 0x63, 0x13 }; + try testing.expectEqualSlices(u8, &want, &got); +} + +// ─── L2->L1 message extraction ───────────────────────────────────────────────────────────────────── + +fn makeLog(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 = 1, + .tx_hash = repeat(32, 0), + .tx_index = 0, + .block_hash = repeat(32, 0), + .log_index = 0, + }; +} + +fn makeReceiptWithLogs(alloc: std.mem.Allocator, logs: []const types.Log) !types.Receipt { + return .{ + .type = 0, + .tx_hash = repeat(32, 0), + .tx_index = 0, + .block_hash = repeat(32, 0), + .block_number = 1, + .from = repeat(20, 0), + .to = null, + .cumulative_gas_used = 21000, + .gas_used = 21000, + .contract_address = null, + .logs = try alloc.dupe(types.Log, logs), + .logs_bloom = @splat(0), + .status = 1, + .effective_gas_price = 0, + }; +} + +test "extractL2L1Messages collects topics[3] only for matching address+topic0" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const l2_ms_address = repeat(20, 0x11); + const other_address = repeat(20, 0x22); + const message_hash = repeat(32, 0x99); + const other_topic0 = repeat(32, 0x01); + const bridge_topic0 = [_]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 matching_log = try makeLog(alloc, l2_ms_address, &.{ bridge_topic0, repeat(32, 0), repeat(32, 0), message_hash }); + const wrong_address_log = try makeLog(alloc, other_address, &.{ bridge_topic0, repeat(32, 0), repeat(32, 0), repeat(32, 0xff) }); + const wrong_topic_log = try makeLog(alloc, l2_ms_address, &.{ other_topic0, repeat(32, 0), repeat(32, 0), repeat(32, 0xff) }); + + const receipts = [_]types.Receipt{ + try makeReceiptWithLogs(alloc, &.{ wrong_address_log, matching_log, wrong_topic_log }), + }; + + var out = std.ArrayListUnmanaged([32]u8).empty; + try api.extractL2L1MessagesFn(alloc, &out, &receipts, l2_ms_address); + + try testing.expectEqual(@as(usize, 1), out.items.len); + try testing.expectEqualSlices(u8, &message_hash, &out.items[0]); +} + +// ─── Witness-backed MPT read (hand-built single-account, single-slot trie) ──────────────────────── + +const KECCAK_EMPTY = primitives.KECCAK_EMPTY; +const EMPTY_TRIE_HASH = mpt.builder.EMPTY_TRIE_HASH; + +/// Hex-prefix "leaf, even length" compact path for a full 32-byte key hash: 0x20 prefix nibble byte +/// followed by the 32 hash bytes verbatim (odd flag unset since 64 nibbles is even). +fn leafCompactPath(alloc: std.mem.Allocator, key_hash: [32]u8) ![]u8 { + const out = try alloc.alloc(u8, 33); + out[0] = 0x20; + @memcpy(out[1..], &key_hash); + return out; +} + +fn buildSingleLeafTrie(alloc: std.mem.Allocator, key: []const u8, value_rlp: []const u8) !struct { root: [32]u8, node_rlp: []const u8 } { + const key_hash = mpt.keccak256(key); + const compact_path = try leafCompactPath(alloc, key_hash); + const items = [_][]const u8{ + try rlp.encodeBytes(alloc, compact_path), + try rlp.encodeBytes(alloc, value_rlp), + }; + const node_rlp = try rlp.encodeList(alloc, &items); + return .{ .root = mpt.keccak256(node_rlp), .node_rlp = node_rlp }; +} + +fn accountLeafValue(alloc: std.mem.Allocator, nonce: u64, balance: u256, storage_root: [32]u8, code_hash: [32]u8) ![]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); +} + +test "witness MPT read: account+storage present, absent address, missing node errors" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const address = repeat(20, 0xaa); + const absent_address = repeat(20, 0xbb); + const slot = api.u64ToSlot32Fn(5); + const slot_value: u256 = 0x1234; + + const storage_leaf = try buildSingleLeafTrie(alloc, &slot, try rlp.encodeU256(alloc, slot_value)); + + const account_value = try accountLeafValue(alloc, 7, 1_000_000, storage_leaf.root, KECCAK_EMPTY); + const account_leaf = try buildSingleLeafTrie(alloc, &address, account_value); + + var node_index = try mpt.buildNodeIndex(alloc, &.{ account_leaf.node_rlp, storage_leaf.node_rlp }); + defer node_index.deinit(); + + // Present account: nonce/balance/storage_root round-trip. + const account = (try api.readAccountFn(account_leaf.root, address, &node_index)).?; + try testing.expectEqual(@as(u64, 7), account.nonce); + try testing.expectEqual(@as(u256, 1_000_000), account.balance); + try testing.expectEqualSlices(u8, &storage_leaf.root, &account.storage_root); + + // Present storage slot, reached through the account's storage_root. + const value = try api.readStorageFn(account_leaf.root, address, slot, &node_index); + try testing.expectEqual(slot_value, value); + + // Proven absence: a different address is NOT an error, just null / zero. + const absent = try api.readAccountFn(account_leaf.root, absent_address, &node_index); + try testing.expect(absent == null); + const absent_storage = try api.readStorageFn(account_leaf.root, absent_address, slot, &node_index); + try testing.expectEqual(@as(u256, 0), absent_storage); + + // Missing witness node: the SAME root hash, but the node pool doesn't contain it -> error, not + // absence (this is the semantic WitnessDatabase deliberately does NOT have — see l2_execution.zig). + var empty_index = try mpt.buildNodeIndex(alloc, &.{}); + defer empty_index.deinit(); + try testing.expectError(error.InvalidProof, api.readAccountFn(account_leaf.root, address, &empty_index)); +} + +// ─── Forced-transaction dispatch (§6.5) ──────────────────────────────────────────────────────────── +// +// Fixture: a real secp256k1-signed legacy (type-0) transaction, generated with +// `rollup_spec/.venv/bin/python` (coincurve) so `recoverSender` has a genuine signature to recover, +// exactly like a real forced transaction witness. +// nonce=5, gasPrice=1e9, gas=21000, to=0xbb*20, value=1000, data=b"", chainId=59144. + +const CHAIN_ID: u64 = 59144; +const SIGNED_TX_RLP = [_]u8{ + 0xf8, 0x68, 0x05, 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, 0x34, 0xa0, 0xe6, 0x6d, 0x9f, 0xde, 0x9f, 0x41, 0xf9, + 0xf6, 0xf7, 0xa8, 0xf7, 0x3a, 0x31, 0x25, 0x85, 0x6c, 0x5a, 0xac, 0x6d, 0x04, 0x1b, 0x1d, 0x11, + 0xc3, 0x87, 0x98, 0x89, 0x4b, 0xe7, 0x1d, 0xac, 0x36, 0xa0, 0x11, 0x29, 0x3b, 0xe7, 0x12, 0x46, + 0x01, 0x7e, 0x64, 0x6b, 0x7e, 0x98, 0x3d, 0x8d, 0x4a, 0xf2, 0xb3, 0x25, 0x92, 0x79, 0xdf, 0xee, + 0x3d, 0xd1, 0x67, 0x97, 0x8b, 0xee, 0x8e, 0x7c, 0xe2, 0x43, +}; +const EXPECTED_SENDER = [_]u8{ 0x87, 0xf6, 0x43, 0x3e, 0xae, 0x75, 0x7d, 0xf1, 0xf4, 0x71, 0xbf, 0x9c, 0xe0, 0x3f, 0xe3, 0x2d, 0x75, 0x1f, 0xf9, 0xa0 }; +const TX_TO = [_]u8{ 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb }; +// gas(21000) * gasPrice(1e9) + value(1000) = 21_000_000_001_000. +const TX_MAX_GAS_FEE_PLUS_VALUE: u256 = 21_000_000_001_000; + +fn decodeFixtureTx(alloc: std.mem.Allocator) !types.TxInput { + const decoded = try executor.executor_tx_decode.decodeTxs(alloc, &.{&SIGNED_TX_RLP}); + return decoded[0]; +} + +/// Recovers the sender of an arbitrary raw signed tx, for freshly-built typed-tx fixtures whose +/// sender must be derived from the signed bytes themselves (`decodeFixtureTx`/`EXPECTED_SENDER` +/// serve the one frozen legacy fixture, whose sender is a fixed, pre-computed constant instead). +fn recoverTxSender(alloc: std.mem.Allocator, signed_tx_rlp: []const u8) ![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)).?; +} + +test "recoverSender recovers the known signer of the signed-tx fixture" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tx = try decodeFixtureTx(alloc); + const sender = (try api.recoverSenderFn(alloc, &tx, CHAIN_ID)).?; + try testing.expectEqualSlices(u8, &EXPECTED_SENDER, &sender); +} + +fn oneAccountNodeIndex(alloc: std.mem.Allocator, address: [20]u8, nonce: u64, balance: u256) !struct { root: [32]u8, index: mpt.NodeIndex } { + const account_value = try accountLeafValue(alloc, nonce, balance, EMPTY_TRIE_HASH, KECCAK_EMPTY); + const leaf = try buildSingleLeafTrie(alloc, &address, account_value); + const index = try mpt.buildNodeIndex(alloc, &.{leaf.node_rlp}); + return .{ .root = leaf.root, .index = index }; +} + +const zinput = @import("zesu_input"); + +/// A minimal payload carrying only what `validateForcedTransactions` reads: `block_number` (for the +/// deadline check) and `raw_transactions` (for the INCLUDED/tx-in-block membership check). +fn dummyPayload(block_number: u64, raw_transactions: []const []const u8) zinput.ExecutionPayload { + return .{ + .parent_hash = repeat(32, 0), + .fee_recipient = repeat(20, 0), + .state_root = repeat(32, 0), + .receipts_root = repeat(32, 0), + .logs_bloom = @splat(0), + .prev_randao = repeat(32, 0), + .block_number = block_number, + .gas_limit = 30_000_000, + .gas_used = 0, + .timestamp = 0, + .extra_data = &.{}, + .base_fee_per_gas = 0, + .block_hash = repeat(32, 0), + .transactions = &.{}, + .raw_transactions = raw_transactions, + .withdrawals = &.{}, + .blob_gas_used = 0, + .excess_blob_gas = 0, + }; +} + +const DUMMY_PAYLOAD = dummyPayload(10, &.{}); + +test "validateForcedTransactions: INCLUDED must appear in the payload's transaction list" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var fixture = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 0, 0); + defer fixture.index.deinit(); + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = &SIGNED_TX_RLP, + .acceptance = api.Acceptance.INCLUDED, + .deadline = 100, + }; + + // Present in the block -> accepted. + const payload_with_tx = dummyPayload(10, &.{&SIGNED_TX_RLP}); + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + const rejected = try api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, payload_with_tx, fixture.root, &fixture.index, &.{ftx}); + try testing.expectEqual(@as(usize, 0), rejected.len); + try testing.expectEqual(@as(u64, 1), last_number); + try testing.expect(!std.mem.eql(u8, &repeat(32, 0), &rolling_hash)); // rolling hash updated regardless + + // Same FTX, but absent from the block -> rejected. + const payload_without_tx = dummyPayload(10, &.{}); + var rolling_hash2: [32]u8 = repeat(32, 0); + var last_number2: u64 = 0; + try testing.expectError( + error.IncludedForcedTxNotInBlock, + api.validateForcedTransactionsFn(alloc, &rolling_hash2, &last_number2, CHAIN_ID, payload_without_tx, fixture.root, &fixture.index, &.{ftx}), + ); +} + +test "validateForcedTransactions: FILTERED_ADDRESS_FROM bubbles up the sender" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var fixture = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 0, 0); + defer fixture.index.deinit(); + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = &SIGNED_TX_RLP, + .acceptance = api.Acceptance.FILTERED_ADDRESS_FROM, + .deadline = 100, + }; + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + const rejected = try api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, fixture.root, &fixture.index, &.{ftx}); + try testing.expectEqual(@as(usize, 1), rejected.len); + try testing.expectEqualSlices(u8, &EXPECTED_SENDER, &rejected[0]); +} + +test "validateForcedTransactions: FILTERED_ADDRESS_TO bubbles up the recipient" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var fixture = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 0, 0); + defer fixture.index.deinit(); + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = &SIGNED_TX_RLP, + .acceptance = api.Acceptance.FILTERED_ADDRESS_TO, + .deadline = 100, + }; + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + const rejected = try api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, fixture.root, &fixture.index, &.{ftx}); + try testing.expectEqual(@as(usize, 1), rejected.len); + try testing.expectEqualSlices(u8, &TX_TO, &rejected[0]); +} + +test "validateForcedTransactions: BAD_NONCE dispatch mirrors the account's nonce at the parent state" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = &SIGNED_TX_RLP, + .acceptance = api.Acceptance.BAD_NONCE, + .deadline = 100, + }; + + // Fixture tx.nonce == 5. Account nonce == 0 (mismatch) -> BAD_NONCE correctly declared. + var mismatch = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 0, 0); + defer mismatch.index.deinit(); + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + _ = try api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, mismatch.root, &mismatch.index, &.{ftx}); + + // Account nonce == 5 (matches tx.nonce) -> BAD_NONCE was declared incorrectly, must error. + var match = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 5, 0); + defer match.index.deinit(); + var rolling_hash2: [32]u8 = repeat(32, 0); + var last_number2: u64 = 0; + try testing.expectError( + error.BadNonceMismatch, + api.validateForcedTransactionsFn(alloc, &rolling_hash2, &last_number2, CHAIN_ID, DUMMY_PAYLOAD, match.root, &match.index, &.{ftx}), + ); +} + +test "validateForcedTransactions: BAD_BALANCE dispatch mirrors the gas+value arithmetic" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = &SIGNED_TX_RLP, + .acceptance = api.Acceptance.BAD_BALANCE, + .deadline = 100, + }; + + // Balance below gas+value -> BAD_BALANCE correctly declared. + var insufficient = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 0, TX_MAX_GAS_FEE_PLUS_VALUE - 1); + defer insufficient.index.deinit(); + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + _ = try api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, insufficient.root, &insufficient.index, &.{ftx}); + + // Balance covers gas+value -> BAD_BALANCE was declared incorrectly, must error. + var sufficient = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 0, TX_MAX_GAS_FEE_PLUS_VALUE); + defer sufficient.index.deinit(); + var rolling_hash2: [32]u8 = repeat(32, 0); + var last_number2: u64 = 0; + try testing.expectError( + error.BadBalanceMismatch, + api.validateForcedTransactionsFn(alloc, &rolling_hash2, &last_number2, CHAIN_ID, DUMMY_PAYLOAD, sufficient.root, &sufficient.index, &.{ftx}), + ); +} + +test "validateForcedTransactions: ascending-number and deadline checks" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var fixture = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 0, 0); + defer fixture.index.deinit(); + + // Out-of-order FTX number (last_processed=0, declared number=2, expected 1). + const out_of_order = l2_execution_ssz.ForcedTransactionWitness{ + .number = 2, + .signed_tx_rlp = &SIGNED_TX_RLP, + .acceptance = api.Acceptance.FILTERED_ADDRESS_FROM, + .deadline = 100, + }; + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + try testing.expectError( + error.ForcedTxOutOfOrder, + api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, fixture.root, &fixture.index, &.{out_of_order}), + ); + + // Deadline already passed (payload.block_number=10, deadline=1). + const expired = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = &SIGNED_TX_RLP, + .acceptance = api.Acceptance.FILTERED_ADDRESS_FROM, + .deadline = 1, + }; + var rolling_hash2: [32]u8 = repeat(32, 0); + var last_number2: u64 = 0; + try testing.expectError( + error.ForcedTxDeadlineExceeded, + api.validateForcedTransactionsFn(alloc, &rolling_hash2, &last_number2, CHAIN_ID, DUMMY_PAYLOAD, fixture.root, &fixture.index, &.{expired}), + ); +} + +test "validateForcedTransactions: an unknown acceptance value is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var fixture = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 0, 0); + defer fixture.index.deinit(); + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = &SIGNED_TX_RLP, + .acceptance = 7, + .deadline = 100, + }; + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + try testing.expectError( + error.UnknownForcedTxAcceptance, + api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, fixture.root, &fixture.index, &.{ftx}), + ); +} + +test "validateForcedTransactions: FILTERED_ADDRESS_TO on a contract-creation tx is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // A real signed legacy tx with to=null: recoverSender runs unconditionally, before acceptance + // dispatch, and succeeds here — isolating the rejection below to genuinely + // FilteredAddressToOnContractCreation. + const creation_tx_rlp = try tx_fixtures.buildSignedLegacyTx(alloc, "FilteredAddressToCreation", .{ + .nonce = 0, + .gas_price = 1_000_000_000, + .gas = 21_000, + .to = null, + .value = 0, + .chain_id = CHAIN_ID, + }); + + var fixture = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 0, 0); + defer fixture.index.deinit(); + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = creation_tx_rlp, + .acceptance = api.Acceptance.FILTERED_ADDRESS_TO, + .deadline = 100, + }; + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + try testing.expectError( + error.FilteredAddressToOnContractCreation, + api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, fixture.root, &fixture.index, &.{ftx}), + ); +} + +test "validateForcedTransactions: BAD_NONCE with a sender proven absent at the parent state is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // An empty node index, checked against the canonical empty-trie root: the fixture tx's sender + // is proven absent from the very first lookup, with no witness nodes needed to prove it. + var empty_index = try mpt.buildNodeIndex(alloc, &.{}); + defer empty_index.deinit(); + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = &SIGNED_TX_RLP, + .acceptance = api.Acceptance.BAD_NONCE, + .deadline = 100, + }; + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + try testing.expectError( + error.ForcedTxSenderAbsent, + api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, EMPTY_TRIE_HASH, &empty_index, &.{ftx}), + ); +} + +test "validateForcedTransactions: BAD_NONCE whose tx is actually in the block is rejected" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var fixture = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 0, 0); + defer fixture.index.deinit(); + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = &SIGNED_TX_RLP, + .acceptance = api.Acceptance.BAD_NONCE, + .deadline = 100, + }; + const payload_with_tx = dummyPayload(10, &.{&SIGNED_TX_RLP}); + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + try testing.expectError( + error.InvalidForcedTxFoundInBlock, + api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, payload_with_tx, fixture.root, &fixture.index, &.{ftx}), + ); +} + +test "validateForcedTransactions: a deadline equal to the block number clears the deadline check" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var fixture = try oneAccountNodeIndex(alloc, EXPECTED_SENDER, 0, 0); + defer fixture.index.deinit(); + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = &SIGNED_TX_RLP, + .acceptance = api.Acceptance.FILTERED_ADDRESS_FROM, + .deadline = DUMMY_PAYLOAD.block_number, + }; + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + const rejected = try api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, fixture.root, &fixture.index, &.{ftx}); + try testing.expectEqual(@as(usize, 1), rejected.len); + try testing.expectEqualSlices(u8, &EXPECTED_SENDER, &rejected[0]); +} + +test "validateForcedTransactions: BAD_BALANCE dispatch mirrors a type-2 (EIP-1559) tx's fee+value" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const tx_rlp = try tx_fixtures.buildSignedEip1559Tx(alloc, "BadBalanceEip1559", .{ + .nonce = 0, + .max_priority_fee = 0, + .max_fee_per_gas = 1_000_000_000, + .gas = 21_000, + .to = repeat(20, 0xcc), + .value = 1000, + .chain_id = CHAIN_ID, + }); + const sender = try recoverTxSender(alloc, tx_rlp); + // gas(21000) * maxFeePerGas(1e9) + value(1000) = 21_000_000_001_000. + const max_fee_plus_value: u256 = 21_000_000_001_000; + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = tx_rlp, + .acceptance = api.Acceptance.BAD_BALANCE, + .deadline = 100, + }; + + // Balance below fee+value -> BAD_BALANCE correctly declared. + var insufficient = try oneAccountNodeIndex(alloc, sender, 0, max_fee_plus_value - 1); + defer insufficient.index.deinit(); + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + _ = try api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, insufficient.root, &insufficient.index, &.{ftx}); + + // Balance covers fee+value -> BAD_BALANCE was declared incorrectly, must error. + var sufficient = try oneAccountNodeIndex(alloc, sender, 0, max_fee_plus_value); + defer sufficient.index.deinit(); + var rolling_hash2: [32]u8 = repeat(32, 0); + var last_number2: u64 = 0; + try testing.expectError( + error.BadBalanceMismatch, + api.validateForcedTransactionsFn(alloc, &rolling_hash2, &last_number2, CHAIN_ID, DUMMY_PAYLOAD, sufficient.root, &sufficient.index, &.{ftx}), + ); +} + +test "validateForcedTransactions: BAD_BALANCE dispatch mirrors a type-3 (blob) tx's fee+blob-surcharge+value" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Two versioned hashes, so the blob surcharge term is visibly nonzero. + const versioned_hashes = [_][32]u8{ repeat(32, 0xd1), repeat(32, 0xd2) }; + const tx_rlp = try tx_fixtures.buildSignedBlobTx(alloc, "BadBalanceBlob", .{ + .nonce = 0, + .max_priority_fee = 0, + .max_fee_per_gas = 1_000_000_000, + .gas = 21_000, + .to = repeat(20, 0xcc), + .value = 1000, + .chain_id = CHAIN_ID, + .max_fee_per_blob_gas = 1_000, + .versioned_hashes = &versioned_hashes, + }); + const sender = try recoverTxSender(alloc, tx_rlp); + + const gas_fee: u256 = @as(u256, 21_000) * @as(u256, 1_000_000_000); + const blob_surcharge: u256 = @as(u256, versioned_hashes.len) * @as(u256, primitives.GAS_PER_BLOB) * @as(u256, 1_000); + const max_fee_plus_value: u256 = gas_fee + blob_surcharge + 1000; + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = tx_rlp, + .acceptance = api.Acceptance.BAD_BALANCE, + .deadline = 100, + }; + + // Balance below fee+surcharge+value -> BAD_BALANCE correctly declared. + var insufficient = try oneAccountNodeIndex(alloc, sender, 0, max_fee_plus_value - 1); + defer insufficient.index.deinit(); + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + _ = try api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, insufficient.root, &insufficient.index, &.{ftx}); + + // Balance covers fee+surcharge+value -> BAD_BALANCE was declared incorrectly, must error. + var sufficient = try oneAccountNodeIndex(alloc, sender, 0, max_fee_plus_value); + defer sufficient.index.deinit(); + var rolling_hash2: [32]u8 = repeat(32, 0); + var last_number2: u64 = 0; + try testing.expectError( + error.BadBalanceMismatch, + api.validateForcedTransactionsFn(alloc, &rolling_hash2, &last_number2, CHAIN_ID, DUMMY_PAYLOAD, sufficient.root, &sufficient.index, &.{ftx}), + ); +} + +test "validateForcedTransactions: BAD_BALANCE dispatch mirrors a type-4 (EIP-7702) tx's fee+value" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const tx_rlp = try tx_fixtures.buildSignedEip7702Tx(alloc, "BadBalanceEip7702", .{ + .nonce = 0, + .max_priority_fee = 0, + .max_fee_per_gas = 1_000_000_000, + .gas = 21_000, + .to = repeat(20, 0xcc), + .value = 1000, + .chain_id = CHAIN_ID, + }); + const sender = try recoverTxSender(alloc, tx_rlp); + // gas(21000) * maxFeePerGas(1e9) + value(1000) = 21_000_000_001_000: maxGasFee dispatches type + // 4 through the same gas*maxFeePerGas formula as type 2, adding the blob surcharge only for + // type 3. + const max_fee_plus_value: u256 = 21_000_000_001_000; + + const ftx = l2_execution_ssz.ForcedTransactionWitness{ + .number = 1, + .signed_tx_rlp = tx_rlp, + .acceptance = api.Acceptance.BAD_BALANCE, + .deadline = 100, + }; + + // Balance below fee+value -> BAD_BALANCE correctly declared. + var insufficient = try oneAccountNodeIndex(alloc, sender, 0, max_fee_plus_value - 1); + defer insufficient.index.deinit(); + var rolling_hash: [32]u8 = repeat(32, 0); + var last_number: u64 = 0; + _ = try api.validateForcedTransactionsFn(alloc, &rolling_hash, &last_number, CHAIN_ID, DUMMY_PAYLOAD, insufficient.root, &insufficient.index, &.{ftx}); + + // Balance covers fee+value -> BAD_BALANCE was declared incorrectly, must error. + var sufficient = try oneAccountNodeIndex(alloc, sender, 0, max_fee_plus_value); + defer sufficient.index.deinit(); + var rolling_hash2: [32]u8 = repeat(32, 0); + var last_number2: u64 = 0; + try testing.expectError( + error.BadBalanceMismatch, + api.validateForcedTransactionsFn(alloc, &rolling_hash2, &last_number2, CHAIN_ID, DUMMY_PAYLOAD, sufficient.root, &sufficient.index, &.{ftx}), + ); +} + +// ─── extractL2L1Messages: malformed log ──────────────────────────────────────────────────────────── + +test "extractL2L1Messages rejects a matching log with fewer than 4 topics" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const l2_ms_address = repeat(20, 0x11); + const bridge_topic0 = [_]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 }; + + // Matching address + topic0, but only 3 topics — one short of topics[3], the message hash. + const short_log = try makeLog(alloc, l2_ms_address, &.{ bridge_topic0, repeat(32, 0), repeat(32, 0) }); + const receipts = [_]types.Receipt{try makeReceiptWithLogs(alloc, &.{short_log})}; + + var out = std.ArrayListUnmanaged([32]u8).empty; + try testing.expectError( + error.InvalidBridgeMessageLog, + api.extractL2L1MessagesFn(alloc, &out, &receipts, l2_ms_address), + ); +} + +// ─── readL1L2BridgeState: composed reads over a real two-slot storage trie ───────────────────────── + +test "readL1L2BridgeState reads the number and rolling hash from real L2MessageService storage" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const l2_ms_address = repeat(20, 0x22); + const number: u64 = 7; + const hash_value: [32]u8 = repeat(32, 0x77); + + var index = try mpt.buildNodeIndex(alloc, &.{}); + defer index.deinit(); + + // The message number. + var storage_root = EMPTY_TRIE_HASH; + const number_slot = api.u64ToSlot32Fn(280); + try mpt.updateStorageChainedIndexed(alloc, &storage_root, number_slot, @as(u256, number), &index); + + // The rolling hash keyed by that same number. + const rolling_hash_slot = api.mappingSlotFn(api.u64ToSlot32Fn(281), api.u64ToSlot32Fn(number)); + const hash_as_u256 = std.mem.readInt(u256, &hash_value, .big); + try mpt.updateStorageChainedIndexed(alloc, &storage_root, rolling_hash_slot, hash_as_u256, &index); + + const account_rlp = try accountLeafValue(alloc, 0, 0, storage_root, KECCAK_EMPTY); + var state_root = EMPTY_TRIE_HASH; + try mpt.updateAccountChainedIndexed(alloc, &state_root, mpt.keccak256(&l2_ms_address), account_rlp, &index); + + const bridge_state = try api.readL1L2BridgeStateFn(state_root, l2_ms_address, &index); + try testing.expectEqualSlices(u8, &hash_value, &bridge_state.hash); + try testing.expectEqual(number, bridge_state.number); +} + +test "readL1L2BridgeState rejects a message number that overflows u64" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const l2_ms_address = repeat(20, 0x22); + const overflowing_number: u256 = @as(u256, 1) << 64; + + var index = try mpt.buildNodeIndex(alloc, &.{}); + defer index.deinit(); + + var storage_root = EMPTY_TRIE_HASH; + const number_slot = api.u64ToSlot32Fn(280); + try mpt.updateStorageChainedIndexed(alloc, &storage_root, number_slot, overflowing_number, &index); + + const account_rlp = try accountLeafValue(alloc, 0, 0, storage_root, KECCAK_EMPTY); + var state_root = EMPTY_TRIE_HASH; + try mpt.updateAccountChainedIndexed(alloc, &state_root, mpt.keccak256(&l2_ms_address), account_rlp, &index); + + try testing.expectError( + error.RollingHashNumberOverflow, + api.readL1L2BridgeStateFn(state_root, l2_ms_address, &index), + ); +} diff --git a/riscv-guests/l2-execution/test/legacy_tx_rlp.zig b/riscv-guests/l2-execution/test/legacy_tx_rlp.zig deleted file mode 100644 index a53bc8e2b8..0000000000 --- a/riscv-guests/l2-execution/test/legacy_tx_rlp.zig +++ /dev/null @@ -1,39 +0,0 @@ -//! 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_test.zig b/riscv-guests/l2-execution/test/stateless_input_encode_test.zig index b3d642567d..6ef853df61 100644 --- a/riscv-guests/l2-execution/test/stateless_input_encode_test.zig +++ b/riscv-guests/l2-execution/test/stateless_input_encode_test.zig @@ -11,7 +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"); +const tx_fixtures = @import("tx_fixtures"); fn repeat(comptime n: usize, byte: u8) [n]u8 { var out: [n]u8 = undefined; @@ -140,8 +140,8 @@ test "encode then decode round-trips every field, covering every variable-length defer arena.deinit(); const alloc = arena.allocator(); - const tx_a_rlp = try legacy_tx_rlp.buildLegacyTxRlp(alloc, 7, 1_000_000_000, 21_000, TX_A_TO, 1000, &.{}, TX_A_V_RAW, TX_A_R, TX_A_S); - const tx_b_rlp = try legacy_tx_rlp.buildLegacyTxRlp(alloc, 0, 2_000_000_000, 100_000, null, 0, &TX_B_DATA, TX_B_V_RAW, TX_B_R, TX_B_S); + const tx_a_rlp = try tx_fixtures.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 tx_fixtures.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. diff --git a/riscv-guests/l2-execution/test/tx_fixtures.zig b/riscv-guests/l2-execution/test/tx_fixtures.zig new file mode 100644 index 0000000000..92d2299bd8 --- /dev/null +++ b/riscv-guests/l2-execution/test/tx_fixtures.zig @@ -0,0 +1,261 @@ +//! Shared transaction-fixture builders for tests. +//! +//! `buildLegacyTxRlp` is a pure RLP encoder for a legacy (type-0) transaction: the caller supplies +//! `v`/`r`/`s` directly, so it serves equally as an unsigned EIP-155 preimage builder (`v=chainId, +//! r=s=0`) and as the final signed-tx encoder (`v` the raw wire value). `buildSignedLegacyTx`, +//! `buildSignedEip1559Tx`, `buildSignedBlobTx`, and `buildSignedEip7702Tx` build on top of it (and +//! their own typed-tx payload encoders) to produce a genuinely secp256k1-signed, sender-recoverable +//! transaction from named fields plus a deterministic per-label private key (`fixturePrivateKey`) +//! — the same label always reproduces the same key, and libsecp256k1 signs with RFC-6979 +//! deterministic nonces, so the same (label, fields) pair always reproduces the same signature +//! bytes, run to run. +//! +//! Typed-tx (EIP-1559/EIP-4844/EIP-7702) field order and signature-field handling mirror the +//! vendored decoder's typed-transaction branches exactly: chainId/nonce/fees/gas/to/value/data/ +//! accessList (plus blob-specific maxFeePerBlobGas/blobVersionedHashes for type 3, or an +//! authorizationList for type 4, left empty here since these fixtures only need `tx.type == 4` for +//! dispatch, not real authorization content), then a bare `y_parity` (0 or 1 — the legacy tx +//! instead folds chain id and parity together into its wire `v`) followed by `r`/`s`. Signing +//! preimages drop the signature fields entirely; the legacy tx's EIP-155 preimage takes the other +//! approach, reusing the signed tx's own 9-field shape with `v=chainId, r=s=0`. + +const std = @import("std"); +const executor = @import("zesu_executor"); +const mpt = @import("zesu_mpt"); +const secp256k1 = @import("zesu_secp256k1"); + +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); +} + +/// Deterministic per-label private key: the same label always signs with the same key, and — via +/// libsecp256k1's RFC-6979 deterministic nonces — always produces the same signature bytes for the +/// same message, run to run. +pub fn fixturePrivateKey(comptime label: []const u8) [32]u8 { + return mpt.keccak256("l2exec-range-fixture/" ++ label); +} + +/// A derived ECDSA signature in the shape every builder below needs to re-encode a signed +/// transaction: `y_parity` (0 or 1) for typed txs, folded into the legacy `v` convention by +/// `buildSignedLegacyTx` itself. +const DerivedSignature = struct { y_parity: u64, r: u256, s: u256 }; + +/// Signs `msg_hash` with `private_key` via zesu's real secp256k1 backend, propagating context/sign +/// failures as errors rather than a bare `null`. +fn signHash(msg_hash: [32]u8, private_key: [32]u8) !DerivedSignature { + const ctx = secp256k1.getContext() orelse return error.Secp256k1ContextUnavailable; + const signature = ctx.sign(msg_hash, private_key) orelse return error.FixtureTxSigningFailed; + return .{ + .r = std.mem.readInt(u256, signature.sig[0..32], .big), + .s = std.mem.readInt(u256, signature.sig[32..64], .big), + .y_parity = @as(u64, signature.recid), + }; +} + +pub const LegacyTxArgs = struct { + nonce: u64, + gas_price: u128, + gas: u64, + /// `null` signs a contract-creation transaction. + to: ?[20]u8, + value: u256, + data: []const u8 = &.{}, + chain_id: u64, +}; + +/// Builds and signs a real legacy (type-0) transaction via `buildLegacyTxRlp`'s EIP-155 preimage +/// (`v=chainId, r=s=0`), re-encoding with the derived `v = chainId*2 + 35 + recid`. +pub fn buildSignedLegacyTx(alloc: std.mem.Allocator, comptime label: []const u8, args: LegacyTxArgs) ![]const u8 { + const unsigned_rlp = try buildLegacyTxRlp(alloc, args.nonce, args.gas_price, args.gas, args.to, args.value, args.data, args.chain_id, 0, 0); + const sig = try signHash(mpt.keccak256(unsigned_rlp), fixturePrivateKey(label)); + const v: u256 = @as(u256, args.chain_id) * 2 + 35 + @as(u256, sig.y_parity); + return buildLegacyTxRlp(alloc, args.nonce, args.gas_price, args.gas, args.to, args.value, args.data, v, sig.r, sig.s); +} + +/// RLP-encodes the `to` field the way every typed-tx branch below expects: an address, or an +/// empty RLP string for contract creation. +fn rlpToField(alloc: std.mem.Allocator, to: ?[20]u8) ![]const u8 { + if (to) |addr| return rlp.encodeBytes(alloc, &addr); + return rlp.encodeBytes(alloc, &.{}); +} + +fn emptyAccessListRlp(alloc: std.mem.Allocator) ![]const u8 { + return rlp.encodeList(alloc, &.{}); +} + +pub const Eip1559TxArgs = struct { + nonce: u64, + max_priority_fee: u128, + max_fee_per_gas: u128, + gas: u64, + to: ?[20]u8, + value: u256, + data: []const u8 = &.{}, + chain_id: u64, +}; + +/// RLP-encodes a type-0x02 (EIP-1559) payload: the type byte followed by +/// `rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, +/// accessList])`, plus `y_parity, r, s` appended when `signature` is given. Mirrors the decoder's +/// type-2 branch field-for-field; `signature = null` yields the unsigned signing preimage (9 +/// fields, no signature slots at all). +fn buildEip1559Payload(alloc: std.mem.Allocator, args: Eip1559TxArgs, signature: ?DerivedSignature) ![]const u8 { + var items: [12][]const u8 = undefined; + items[0] = try rlp.encodeU64(alloc, args.chain_id); + items[1] = try rlp.encodeU64(alloc, args.nonce); + items[2] = try rlp.encodeU128(alloc, args.max_priority_fee); + items[3] = try rlp.encodeU128(alloc, args.max_fee_per_gas); + items[4] = try rlp.encodeU64(alloc, args.gas); + items[5] = try rlpToField(alloc, args.to); + items[6] = try rlp.encodeU256(alloc, args.value); + items[7] = try rlp.encodeBytes(alloc, args.data); + items[8] = try emptyAccessListRlp(alloc); + var n: usize = 9; + if (signature) |sig| { + items[9] = try rlp.encodeU64(alloc, sig.y_parity); + items[10] = try rlp.encodeU256(alloc, sig.r); + items[11] = try rlp.encodeU256(alloc, sig.s); + n = 12; + } + return rlp.concat(alloc, &.{ &.{0x02}, try rlp.encodeList(alloc, items[0..n]) }); +} + +/// Builds and signs a real EIP-1559 transaction via `buildEip1559Payload`. +pub fn buildSignedEip1559Tx(alloc: std.mem.Allocator, comptime label: []const u8, args: Eip1559TxArgs) ![]const u8 { + const preimage = try buildEip1559Payload(alloc, args, null); + const sig = try signHash(mpt.keccak256(preimage), fixturePrivateKey(label)); + return buildEip1559Payload(alloc, args, sig); +} + +pub const BlobTxArgs = struct { + nonce: u64, + max_priority_fee: u128, + max_fee_per_gas: u128, + gas: u64, + /// Blob transactions require a real recipient (EIP-4844); legacy/EIP-1559 fixtures instead + /// take an optional `to` for contract creation. + to: [20]u8, + value: u256, + data: []const u8 = &.{}, + chain_id: u64, + max_fee_per_blob_gas: u128, + versioned_hashes: []const [32]u8, +}; + +fn versionedHashesRlp(alloc: std.mem.Allocator, hashes: []const [32]u8) ![]const u8 { + const items = try alloc.alloc([]const u8, hashes.len); + for (hashes, 0..) |h, i| items[i] = try rlp.encodeBytes(alloc, &h); + return rlp.encodeList(alloc, items); +} + +/// RLP-encodes a type-0x03 (EIP-4844 blob) payload: the type byte followed by +/// `rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, +/// maxFeePerBlobGas, blobVersionedHashes])`, plus `y_parity, r, s` appended when `signature` is +/// given. Mirrors the decoder's type-3 branch field-for-field. +fn buildBlobTxPayload(alloc: std.mem.Allocator, args: BlobTxArgs, signature: ?DerivedSignature) ![]const u8 { + var items: [14][]const u8 = undefined; + items[0] = try rlp.encodeU64(alloc, args.chain_id); + items[1] = try rlp.encodeU64(alloc, args.nonce); + items[2] = try rlp.encodeU128(alloc, args.max_priority_fee); + items[3] = try rlp.encodeU128(alloc, args.max_fee_per_gas); + items[4] = try rlp.encodeU64(alloc, args.gas); + items[5] = try rlp.encodeBytes(alloc, &args.to); + items[6] = try rlp.encodeU256(alloc, args.value); + items[7] = try rlp.encodeBytes(alloc, args.data); + items[8] = try emptyAccessListRlp(alloc); + items[9] = try rlp.encodeU128(alloc, args.max_fee_per_blob_gas); + items[10] = try versionedHashesRlp(alloc, args.versioned_hashes); + var n: usize = 11; + if (signature) |sig| { + items[11] = try rlp.encodeU64(alloc, sig.y_parity); + items[12] = try rlp.encodeU256(alloc, sig.r); + items[13] = try rlp.encodeU256(alloc, sig.s); + n = 14; + } + return rlp.concat(alloc, &.{ &.{0x03}, try rlp.encodeList(alloc, items[0..n]) }); +} + +/// Builds and signs a real EIP-4844 blob transaction via `buildBlobTxPayload`. +pub fn buildSignedBlobTx(alloc: std.mem.Allocator, comptime label: []const u8, args: BlobTxArgs) ![]const u8 { + const preimage = try buildBlobTxPayload(alloc, args, null); + const sig = try signHash(mpt.keccak256(preimage), fixturePrivateKey(label)); + return buildBlobTxPayload(alloc, args, sig); +} + +pub const Eip7702TxArgs = struct { + nonce: u64, + max_priority_fee: u128, + max_fee_per_gas: u128, + gas: u64, + /// EIP-7702 requires a real recipient (no contract-creation form), like blob transactions. + to: [20]u8, + value: u256, + data: []const u8 = &.{}, + chain_id: u64, +}; + +/// RLP-encodes a type-0x04 (EIP-7702) payload: the type byte followed by +/// `rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, +/// authorizationList])`, plus `y_parity, r, s` appended when `signature` is given. Mirrors the +/// decoder's type-4 branch field-for-field; the authorization list is always empty here (RLP-encoded +/// the same way `emptyAccessListRlp` encodes its own empty list), since these fixtures only need +/// `tx.type == 4` to reach the guest's type-4 dispatch, not real authorization content. +fn buildEip7702Payload(alloc: std.mem.Allocator, args: Eip7702TxArgs, signature: ?DerivedSignature) ![]const u8 { + var items: [13][]const u8 = undefined; + items[0] = try rlp.encodeU64(alloc, args.chain_id); + items[1] = try rlp.encodeU64(alloc, args.nonce); + items[2] = try rlp.encodeU128(alloc, args.max_priority_fee); + items[3] = try rlp.encodeU128(alloc, args.max_fee_per_gas); + items[4] = try rlp.encodeU64(alloc, args.gas); + items[5] = try rlp.encodeBytes(alloc, &args.to); + items[6] = try rlp.encodeU256(alloc, args.value); + items[7] = try rlp.encodeBytes(alloc, args.data); + items[8] = try emptyAccessListRlp(alloc); + items[9] = try emptyAccessListRlp(alloc); + var n: usize = 10; + if (signature) |sig| { + items[10] = try rlp.encodeU64(alloc, sig.y_parity); + items[11] = try rlp.encodeU256(alloc, sig.r); + items[12] = try rlp.encodeU256(alloc, sig.s); + n = 13; + } + return rlp.concat(alloc, &.{ &.{0x04}, try rlp.encodeList(alloc, items[0..n]) }); +} + +/// Builds and signs a real EIP-7702 transaction via `buildEip7702Payload`. +pub fn buildSignedEip7702Tx(alloc: std.mem.Allocator, comptime label: []const u8, args: Eip7702TxArgs) ![]const u8 { + const preimage = try buildEip7702Payload(alloc, args, null); + const sig = try signHash(mpt.keccak256(preimage), fixturePrivateKey(label)); + return buildEip7702Payload(alloc, args, sig); +} From b4d47c89d5e4c07e61776e7e904467d7489209a5 Mon Sep 17 00:00:00 2001 From: Roman <4833306+Filter94@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:24:15 +0200 Subject: [PATCH 2/2] fix(riscv-guest): restore adversarial-SSZ decodeInput hardening tests (pr2-v2) Missed when reconstructing this branch: PR2's adversarial-input tests for decodeInput's offset/bounds/ordering checks, located dynamically off the same offset-table fields the codec itself reads. --- .../test/l2_execution_ssz_test.zig | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/riscv-guests/l2-execution/test/l2_execution_ssz_test.zig b/riscv-guests/l2-execution/test/l2_execution_ssz_test.zig index 067a4ff0f0..46b12ee5c3 100644 --- a/riscv-guests/l2-execution/test/l2_execution_ssz_test.zig +++ b/riscv-guests/l2-execution/test/l2_execution_ssz_test.zig @@ -122,3 +122,189 @@ test "input: rejects the wrong schema id" { corrupted[1] = 0x03; // the output schema id, on input bytes try std.testing.expectError(error.InvalidSsz, l2_execution_ssz.decodeInput(alloc, corrupted)); } + +// ── Adversarial-input hardening ────────────────────────────────────────────── +// +// `decodeInput` parses prover-controlled bytes inside the guest, so its bounds/ordering checks are +// this codec's actual security boundary. Each test below starts from `encodeInput(sampleInput())` +// and corrupts specific, dynamically-located byte positions — located by reading the same +// offset-table fields `decodeVariableList`/`decodeInput` themselves read, rather than hand-computed +// magic numbers, so a test keeps targeting the right byte even if `sampleInput()`'s field lengths +// change. + +fn readU32LE(buf: []const u8, off: usize) u32 { + return std.mem.readInt(u32, buf[off..][0..4], .little); +} + +fn writeU32LE(buf: []u8, off: usize, value: u32) void { + std.mem.writeInt(u32, buf[off..][0..4], value, .little); +} + +fn nextMultipleOf4(n: usize) usize { + return ((n + 3) / 4) * 4; +} + +// Offsets that are protocol-fixed (documented in this module's own header comment) rather than +// attacker-influenced: a 2-byte schema id, then the input's 92-byte fixed head +// (hash(32)+u64(8)+chain_config(20+20+8)+payloads-offset(4)) — so the payloads-offset field sits at +// body offset 88, and the payloads variable region always starts right after it. +const SCHEMA_SIZE: usize = 2; +const INPUT_FIXED_SIZE: usize = 92; +const PAYLOADS_REGION_START: usize = SCHEMA_SIZE + INPUT_FIXED_SIZE; + +/// Locate payload0's and payload1's absolute start offsets in an `encodeInput` buffer, by reading +/// the payloads list's own offset table instead of recomputing payload0's encoded length by hand. +fn locatePayloads(encoded: []const u8) struct { payload0_start: usize, payload1_start: usize } { + const payload0_rel = readU32LE(encoded, PAYLOADS_REGION_START + 0); + const payload1_rel = readU32LE(encoded, PAYLOADS_REGION_START + 4); + std.debug.assert(payload0_rel == 8); // sanity: sampleInput() has exactly 2 payloads (table = 2*4 bytes) + return .{ + .payload0_start = PAYLOADS_REGION_START + payload0_rel, + .payload1_start = PAYLOADS_REGION_START + payload1_rel, + }; +} + +// Class 1: a variable-section offset pointing past the end of the buffer. +test "input: rejects a payload-list offset that points past the end of the buffer" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const encoded = try l2_execution_ssz.encodeInput(alloc, sampleInput()); + const corrupted = try alloc.dupe(u8, encoded); + + // payload1's offset (table[1]) doubles as payload0's END when decodeVariableList checks item + // 0 (end_i = the next item's start) — pushing it past the payloads region's own length trips + // the `end_i > data.len` bound check. + const payloads_region_len = corrupted.len - PAYLOADS_REGION_START; + const past_end: u32 = @intCast(payloads_region_len + 1000); + writeU32LE(corrupted, PAYLOADS_REGION_START + 4, past_end); + + try std.testing.expectError(error.InvalidSsz, l2_execution_ssz.decodeInput(alloc, corrupted)); +} + +// Class 2: non-monotonic/overlapping offsets — a later element's offset smaller than an earlier one's. +test "input: rejects a later payload offset smaller than an earlier one (non-monotonic)" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const encoded = try l2_execution_ssz.encodeInput(alloc, sampleInput()); + const corrupted = try alloc.dupe(u8, encoded); + + // Leave payload0's offset (table[0]) canonical; force payload1's offset (table[1]) below it. + // decodeVariableList computes item 0's end as table[1], so this makes item 0's start sit AFTER + // its own end — `off_i > end_i` must reject it. + const payload0_off = readU32LE(corrupted, PAYLOADS_REGION_START + 0); + try std.testing.expect(payload0_off >= 4); // sanity: table[0] is at least one 4-byte entry + writeU32LE(corrupted, PAYLOADS_REGION_START + 4, payload0_off - 4); + + try std.testing.expectError(error.InvalidSsz, l2_execution_ssz.decodeInput(alloc, corrupted)); +} + +// Class 3: a truncated variable region, cut AFTER the fixed head (distinct from the existing +// shorter-than-fixed-head test above, which never reaches the payloads region at all). +test "input: rejects a variable region truncated mid-payload" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const encoded = try l2_execution_ssz.encodeInput(alloc, sampleInput()); + const payloads = locatePayloads(encoded); + + // Cut the buffer 4 bytes into payload1 (the LAST payload) — short of LineaPayloadInput's own + // 8-byte fixed head. The outer payloads list accepts this trivially (the last item's end is + // always "whatever's left"), so this exercises decodeLineaPayloadInput's OWN fixed-head length + // guard, a different check than the outer list's bounds check covered by class 1 above. + const cut = payloads.payload1_start + 4; + try std.testing.expect(cut > PAYLOADS_REGION_START and cut < encoded.len); // sanity: past the fixed head, short of the real end + const truncated = encoded[0..cut]; + + try std.testing.expectError(error.InvalidSsz, l2_execution_ssz.decodeInput(alloc, truncated)); +} + +// Class 4: an inner (nested) structure lying about its layout — a forced-transaction list whose +// own offset is inconsistent with its ENCLOSING payload's size, not the whole buffer's. +test "input: rejects a nested forced-tx list offset that overruns its enclosing payload" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const encoded = try l2_execution_ssz.encodeInput(alloc, sampleInput()); + const corrupted = try alloc.dupe(u8, encoded); + const payloads = locatePayloads(corrupted); + + // payload0 is NOT the last payload, so its forced_transactions region is bounded strictly + // BELOW the rest of the buffer (it ends exactly where payload1 begins) — the right place to + // prove an inner offset is validated against ITS OWN enclosing region, not just the overall + // buffer (a value could look "in range" against the latter while still overrunning the former). + const off_ftx = readU32LE(corrupted, payloads.payload0_start + 4); + const ftx_region_start = payloads.payload0_start + off_ftx; + const ftx_region_len = payloads.payload1_start - ftx_region_start; + const original_ftx_offset = readU32LE(corrupted, ftx_region_start); + try std.testing.expectEqual(@as(u32, 4), original_ftx_offset); // sanity: payload0 has exactly 1 forced tx + + // Claim the list needs more room than `ftx_region_len` actually provides — still a small, + // plausible-looking offset well within the OVERALL buffer, so this only fails if the check is + // properly scoped to payload0's own region rather than the whole remaining buffer. + const lying_offset: u32 = @intCast(nextMultipleOf4(ftx_region_len + 1)); + writeU32LE(corrupted, ftx_region_start, lying_offset); + + try std.testing.expectError(error.InvalidSsz, l2_execution_ssz.decodeInput(alloc, corrupted)); +} + +// Class 5 (investigation finding, not a rejection): trailing garbage appended after the last +// variable field. This codec's variable-size fields ALWAYS extend "to the end of the enclosing +// region" for the last element at every nesting level (the same convention used throughout, +// matching the Python reference codec byte-for-byte) — there is no independent total-length field +// anywhere to validate against. Appended bytes are therefore indistinguishable from "the last field +// is simply longer" and are silently absorbed into the deepest-nested last variable field, not +// rejected. Documented here as current, deliberate-tradeoff behavior rather than asserted as a bug: +// every byte in this envelope is already prover-authored, so this affords no capability beyond +// directly encoding a longer field in the first place. Rejecting it would require every decoder in +// this file to report how many bytes it consumed so callers could check for a leftover remainder — +// a return-type change to the whole decode path, not a bounds/ordering guard. +test "input: currently tolerates trailing garbage, absorbed into the last variable-size field" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const value = sampleInput(); + const encoded = try l2_execution_ssz.encodeInput(alloc, value); + + const garbage = [_]u8{ 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE }; + const extended = try alloc.alloc(u8, encoded.len + garbage.len); + @memcpy(extended[0..encoded.len], encoded); + @memcpy(extended[encoded.len..], &garbage); + + const decoded = try l2_execution_ssz.decodeInput(alloc, extended); + + const want_last_payload = value.payloads[value.payloads.len - 1]; + const want_last_ftx = want_last_payload.forced_transactions[want_last_payload.forced_transactions.len - 1]; + const got_last_payload = decoded.payloads[decoded.payloads.len - 1]; + const got_last_ftx = got_last_payload.forced_transactions[got_last_payload.forced_transactions.len - 1]; + + try std.testing.expectEqual(want_last_ftx.signed_tx_rlp.len + garbage.len, got_last_ftx.signed_tx_rlp.len); + try std.testing.expectEqualSlices(u8, want_last_ftx.signed_tx_rlp, got_last_ftx.signed_tx_rlp[0..want_last_ftx.signed_tx_rlp.len]); + try std.testing.expectEqualSlices(u8, &garbage, got_last_ftx.signed_tx_rlp[want_last_ftx.signed_tx_rlp.len..]); +} + +// Class 6: a huge offset/length value (0xFFFFFFFF-class) must fail cleanly, never attempt a giant +// allocation, panic, or crash. +test "input: rejects a huge (~0xFFFFFFFF) offset without attempting a giant allocation" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const encoded = try l2_execution_ssz.encodeInput(alloc, sampleInput()); + const corrupted = try alloc.dupe(u8, encoded); + + // The payloads list's FIRST offset-table entry doubles as decodeVariableList's item-count + // driver (n = first_off / 4): a hostile 0xFFFFFFFC would otherwise demand allocating room for + // ~2^30 element slices. It must be rejected by the `first_off > data.len` bound check BEFORE + // `alloc.alloc` is ever reached — under the test allocator, a real regression here would + // surface as error.OutOfMemory (or a multi-GB attempt), not error.InvalidSsz. + writeU32LE(corrupted, PAYLOADS_REGION_START + 0, 0xFFFFFFFC); + + try std.testing.expectError(error.InvalidSsz, l2_execution_ssz.decodeInput(alloc, corrupted)); +}