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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 40 additions & 36 deletions riscv-guests/l2-execution/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,43 @@ 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);

// ── 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
Expand Down Expand Up @@ -230,6 +267,7 @@ pub fn build(b: *std.Build) void {
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);

Expand Down Expand Up @@ -379,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.
Expand All @@ -403,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);

Expand Down Expand Up @@ -434,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"),
Expand All @@ -471,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);

Expand Down
28 changes: 19 additions & 9 deletions riscv-guests/l2-execution/src/l2_execution.zig
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! l2-execution guest logic: the Linea-specific layer on top of per-block stateless execution.
//!
//! Faithful translation of the Python oracle (`rollup_spec.l2_execution.run_l2_execution_guest` and
//! Faithful translation of the Python reference implementation (`rollup_spec.l2_execution.run_l2_execution_guest` and
//! its helpers) to Zig, wired against zesu's exposed modules:
//! - per-block execution + full logs: `execution.executeStatelessInputWithLogs`;
//! - vanilla stateless-input decode: `zesu_ssz_decode.decode`;
Expand Down Expand Up @@ -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 oracle'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;

Expand Down Expand Up @@ -105,7 +113,7 @@ fn addToForcedTxRollingHash(prev: [32]u8, tx_hash: [32]u8, deadline: u64, from_a
}

/// Hash of a list of 32-byte digests (e.g. `l2_l1_messages_hash`'s message-hash preimages). Named
/// `hashDigestList`, matching the Python oracle's `hash_digest_list` (renamed from `hash_hash_list`
/// `hashDigestList`, matching the Python reference implementation's `hash_digest_list` (renamed from `hash_hash_list`
/// for the same reason): "hash a HashList" reads as a typo, not a type name; `Digest` avoids the
/// verb/noun clash.
fn hashDigestList(alloc: std.mem.Allocator, values: []const [32]u8) ![32]u8 {
Expand All @@ -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 oracle 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
Expand All @@ -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 {
Expand Down Expand Up @@ -327,10 +336,11 @@ pub fn runL2ExecutionWithEngine(comptime Engine: type, alloc: std.mem.Allocator,
// "No L2MessageService configured" mode: a zero l2MessageServiceAddress means this range's
// chain has no bridge contract, so there is nothing to read or scan. Both the L1->L2 bridge
// rolling-hash boundary reads and the L2->L1 message-log extraction are suppressed and the four
// bridge PI fields are pinned to zero (mirrors the Python oracle's read_l1l2_bridge_state
// zero-address branch). This is a real semantic, not test scaffolding — but it is also what lets
// a vanilla EF stateless input (which has no L2MessageService account, and whose witness only
// covers nodes execution touched) be dummy-wrapped and run through this guest unchanged.
// bridge PI fields are pinned to zero (mirrors the Python reference implementation's
// read_l1l2_bridge_state zero-address branch). This is a real semantic, not test scaffolding —
// but it is also what lets a vanilla EF stateless input (which has no L2MessageService account,
// and whose witness only covers nodes execution touched) be dummy-wrapped and run through this
// guest unchanged.
const bridge_suppressed = isZeroAddress(l2_ms_address);

var current_parent_hash = parent_block_hash;
Expand Down
83 changes: 57 additions & 26 deletions riscv-guests/l2-execution/test/l2_execution_range_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<n>"), 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 {
Expand Down Expand Up @@ -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);
}
Loading
Loading