From 708e08c79b5a454c30ef6e4e12747b340a954036 Mon Sep 17 00:00:00 2001 From: Roman <4833306+Filter94@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:03:00 +0200 Subject: [PATCH 1/2] chore(riscv-guest): guest error codes and coverage (pr3-v2) Deterministic exit-code taxonomy for the l2-execution guest: guest_errors.zig defines 7 stable category codes; the guest entry logs the error name via zkvm_log then exits with the mapped category code instead of a blanket exit(1). --- .../l2-execution/src/evm_execution_guest.zig | 28 +++-- .../l2-execution/src/guest_errors.zig | 109 ++++++++++++++++++ .../test/evm_execution_guest_test.zig | 26 +++++ 3 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 riscv-guests/l2-execution/src/guest_errors.zig diff --git a/riscv-guests/l2-execution/src/evm_execution_guest.zig b/riscv-guests/l2-execution/src/evm_execution_guest.zig index 879a3ee7bde..3a625dccc29 100644 --- a/riscv-guests/l2-execution/src/evm_execution_guest.zig +++ b/riscv-guests/l2-execution/src/evm_execution_guest.zig @@ -9,6 +9,10 @@ pub const execution = @import("execution.zig"); const l2_execution = @import("l2_execution.zig"); const l2_execution_ssz = @import("l2_execution_ssz"); +/// Exit-code taxonomy for guest failures (Readme.md §2.5); `pub` for the guest's test suite, like +/// `execution` above. +pub const guest_errors = @import("guest_errors.zig"); + // Heap starts at the address defined by the linker script (canonical Lineth layout: `_heap_start` = 0x48800000, grows up). extern var _heap_start: u8; // Linker script does not actually constraint the heap to 256 MiB, but this is a reasonable upper bound @@ -25,9 +29,11 @@ const GUEST_HEAP_SIZE: usize = 256 * 1024 * 1024; // C-backed crypto instead. /// zkVM guest entry. Reads the extended `L2ExecutionProofPrivateInput` via `read_input`, runs -/// `l2_execution.runL2Execution`, and emits the SSZ output via `write_output`. Exits 0 on success, -/// 1 on any error. `read_input`/`write_output` are satisfied by zesu-zkvm's `linea_zkvm_io` — where -/// the input lives and how the output surfaces is the proving system's concern, not the guest's. +/// `l2_execution.runL2Execution`, and emits the SSZ output via `write_output`. Exits 0 on success; +/// on failure, exits with `guest_errors.exitCode(err)` — a deterministic, category-stable nonzero +/// code per Readme.md §2.5 — after logging the failing error's name via `zkvm_log`. +/// `read_input`/`write_output` are satisfied by zesu-zkvm's `linea_zkvm_io` — where the input lives +/// and how the output surfaces is the proving system's concern, not the guest's. /// /// This frozen riscv64 binary has no argv, so output format is fixed at build time (always SSZ); /// the `--json`/`--ssz` toggle lives on the native `l2-execution-runner` tool instead. @@ -43,15 +49,21 @@ fn guestMain() callconv(.c) noreturn { zkvm_io.read_input(&buf_ptr, &buf_size); const raw_input = buf_ptr[0..buf_size]; - const out = runL2ExecutionGuest(allocator, raw_input) catch exit(1); + const out = runL2ExecutionGuest(allocator, raw_input) catch |err| { + // `zkvm_log` is the guest's one diagnostic sink (a documented no-op until the prover + // exposes logging that doesn't alias the output commitment). + const name = @errorName(err); + zkvm_log(0, name.ptr, name.len); + exit(guest_errors.exitCode(err)); + }; zkvm_io.write_output(&out); exit(0); } -/// Decode -> execute -> encode, factored out of `guestMain` so the whole pipeline is one -/// `catch exit(1)` away from a clean guest rejection. Returns the output BY VALUE (a small, -/// fixed-size array — see `l2_execution_ssz.encodeOutput`'s doc comment) rather than an -/// allocator-backed slice: there's nothing for an allocator to do here. +/// Decode -> execute -> encode, factored out of `guestMain` so the whole pipeline is one `catch` +/// away from a clean, categorized guest rejection (`guest_errors.exitCode`, Readme.md §2.5). +/// Returns the output BY VALUE (a small, fixed-size array — see `l2_execution_ssz.encodeOutput`'s +/// doc comment) rather than an allocator-backed slice: there's nothing for an allocator to do here. fn runL2ExecutionGuest(allocator: std.mem.Allocator, raw_input: []const u8) ![l2_execution_ssz.OUTPUT_SIZE]u8 { const decoded = try l2_execution_ssz.decodeInput(allocator, raw_input); const result = try l2_execution.runL2Execution(allocator, decoded); diff --git a/riscv-guests/l2-execution/src/guest_errors.zig b/riscv-guests/l2-execution/src/guest_errors.zig new file mode 100644 index 00000000000..a8b1b0bdba8 --- /dev/null +++ b/riscv-guests/l2-execution/src/guest_errors.zig @@ -0,0 +1,109 @@ +//! Deterministic guest exit-code taxonomy (Readme.md §2.5 "Guest Termination Semantics"). +//! +//! Failures map to coarse, category-stable exit codes: adding or renaming an individual error never +//! renumbers a category, so codes stay meaningful to operators across guest versions. Success exits +//! 0; every category here is nonzero, per the standard's failed-termination requirement. +//! +//! Standalone by design: Zig error literals are global (matched by name program-wide), so this file +//! needs no imports. +//! +//! Adding a new Linea-layer error means adding it to BOTH `linea_errors` and `exitCode`'s switch in +//! the same change — the comptime guard in the guest's test suite fails on any `linea_errors` +//! member that `exitCode` leaves at CODE_UNKNOWN. + +/// Matches the width of the guest's exit primitive. +pub const ExitCode = u64; + +/// Fallback for engine/EVM/zesu-internal errors and anything not yet triaged — an unmapped error +/// still fails the guest with a nonzero exit. +pub const CODE_UNKNOWN: ExitCode = 1; +pub const CODE_INVALID_SSZ_ENVELOPE: ExitCode = 2; +pub const CODE_INVALID_STATELESS_INPUT: ExitCode = 3; +pub const CODE_CONFLATION_INVARIANT: ExitCode = 4; +pub const CODE_POLICY_REJECT: ExitCode = 5; +pub const CODE_FORCED_TX_VIOLATION: ExitCode = 6; +/// A node needed to resolve an MPT proof path was missing from the witness pool (a proof of ABSENCE +/// resolves to `null`/`0` instead, never here), at whichever layer surfaces the read: a direct +/// Linea-layer MPT read, the witness header chain, or the EVM's witness-backed database during +/// delegated per-block execution. +pub const CODE_WITNESS_RESOLUTION: ExitCode = 7; + +/// Every error a Linea-layer function deliberately returns, in `exitCode`'s arm order. +pub const linea_errors = error{ + InvalidSsz, + + InvalidStatelessInput, + + EmptyPayloads, + ChainIdMismatch, + ParentHashChainMismatch, + BaseFeeNotConstant, + FeeRecipientMismatch, + MissingParentHeaderWitness, + InvalidGenesisParentHash, + + ExecutionRequestsNotSupported, + WithdrawalsNotSupported, + UnsupportedFork, + + ForcedTxOutOfOrder, + ForcedTxDeadlineExceeded, + ForcedTxSenderRecoveryFailed, + UnknownForcedTxAcceptance, + IncludedForcedTxNotInBlock, + InvalidForcedTxFoundInBlock, + BadNonceMismatch, + BadBalanceMismatch, + FilteredAddressToOnContractCreation, + ForcedTxSenderAbsent, + + RollingHashNumberOverflow, + RollingHashNumberDecreased, + InvalidBridgeMessageLog, + InvalidProof, + InvalidWitness, +}; + +/// Maps a guest failure to its deterministic, category-stable exit code (Readme.md §2.5). +pub fn exitCode(err: anyerror) ExitCode { + return switch (err) { + error.InvalidSsz => CODE_INVALID_SSZ_ENVELOPE, + + error.InvalidStatelessInput => CODE_INVALID_STATELESS_INPUT, + + error.EmptyPayloads, + error.ChainIdMismatch, + error.ParentHashChainMismatch, + error.BaseFeeNotConstant, + error.FeeRecipientMismatch, + error.MissingParentHeaderWitness, + error.InvalidGenesisParentHash, + => CODE_CONFLATION_INVARIANT, + + error.ExecutionRequestsNotSupported, + error.WithdrawalsNotSupported, + error.UnsupportedFork, + => CODE_POLICY_REJECT, + + error.ForcedTxOutOfOrder, + error.ForcedTxDeadlineExceeded, + error.ForcedTxSenderRecoveryFailed, + error.UnknownForcedTxAcceptance, + error.IncludedForcedTxNotInBlock, + error.InvalidForcedTxFoundInBlock, + error.BadNonceMismatch, + error.BadBalanceMismatch, + error.FilteredAddressToOnContractCreation, + error.ForcedTxSenderAbsent, + => CODE_FORCED_TX_VIOLATION, + + error.RollingHashNumberOverflow, + error.RollingHashNumberDecreased, + error.InvalidBridgeMessageLog, + error.InvalidProof, + error.InvalidWitness, + => CODE_WITNESS_RESOLUTION, + + else => CODE_UNKNOWN, + }; +} diff --git a/riscv-guests/l2-execution/test/evm_execution_guest_test.zig b/riscv-guests/l2-execution/test/evm_execution_guest_test.zig index dc94a8a6ad0..0881e94f4e7 100644 --- a/riscv-guests/l2-execution/test/evm_execution_guest_test.zig +++ b/riscv-guests/l2-execution/test/evm_execution_guest_test.zig @@ -37,3 +37,29 @@ test "executeStatelessInputWithLogs matches vanilla executeStatelessInput's root try std.testing.expectEqual(@as(usize, 0), receipt.logs.len); } } + +test "guest_errors.exitCode pins one representative code per category" { + const guest_errors = guest.guest_errors; + + // The raw numbers are the point: these codes are load-bearing for operators, so renumbering + // must break this test. `error.OutOfMemory` stands in for any error outside `linea_errors`. + try std.testing.expectEqual(@as(guest_errors.ExitCode, 1), guest_errors.exitCode(error.OutOfMemory)); + try std.testing.expectEqual(@as(guest_errors.ExitCode, 2), guest_errors.exitCode(error.InvalidSsz)); + try std.testing.expectEqual(@as(guest_errors.ExitCode, 3), guest_errors.exitCode(error.InvalidStatelessInput)); + try std.testing.expectEqual(@as(guest_errors.ExitCode, 4), guest_errors.exitCode(error.EmptyPayloads)); + try std.testing.expectEqual(@as(guest_errors.ExitCode, 5), guest_errors.exitCode(error.UnsupportedFork)); + try std.testing.expectEqual(@as(guest_errors.ExitCode, 6), guest_errors.exitCode(error.BadNonceMismatch)); + try std.testing.expectEqual(@as(guest_errors.ExitCode, 7), guest_errors.exitCode(error.InvalidProof)); +} + +test "guest_errors.linea_errors: every listed error maps to a non-unknown category" { + const guest_errors = guest.guest_errors; + comptime { + for (@typeInfo(guest_errors.linea_errors).error_set.?) |e| { + const err: anyerror = @field(guest_errors.linea_errors, e.name); + if (guest_errors.exitCode(err) == guest_errors.CODE_UNKNOWN) { + @compileError("guest_errors.linea_errors: '" ++ e.name ++ "' maps to CODE_UNKNOWN — add an explicit exitCode() category arm"); + } + } + } +} From 667ace36134267e367689c3921ce5e4c0e12d889 Mon Sep 17 00:00:00 2001 From: Roman <4833306+Filter94@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:25:28 +0200 Subject: [PATCH 2/2] fix(riscv-guest): correct FTX deadline wording to per-block handlingBlockNumber (pr3-v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed when reconstructing this branch: PR3's §6.5 deadline constraint wording fix. Both implementations already enforce ftx.deadlineBlockNumber >= handlingBlockNumber; the spec text previously described a weaker range-start check. --- rollup_spec/Readme.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rollup_spec/Readme.md b/rollup_spec/Readme.md index d0d5b476846..0ab757b3ad7 100644 --- a/rollup_spec/Readme.md +++ b/rollup_spec/Readme.md @@ -754,11 +754,11 @@ These propagate through the proof tree symmetrically to the L1→L2 bridge field The guest processes FTXs in ascending `ftxNumber` order after completing normal block execution. For each FTX in the range: -**Deadline constraint.** Assert: +**Deadline constraint.** For a FTX handled in the block with number `handlingBlockNumber`, assert: ``` -ftx.deadlineBlockNumber >= prevLastBlockNumber +ftx.deadlineBlockNumber >= handlingBlockNumber ``` -A FTX whose deadline falls before the start of this range was already expired; it must have been handled in a prior range. If it wasn't, finalization of the prior range would have been blocked. +Handling a FTX after its deadline would break the forced-inclusion promise, so the declared outcome must land in a block at or before the deadline. This also subsumes range-level expiry: a FTX whose deadline falls before the start of this range was already expired and must have been handled in a prior range — otherwise finalization of the prior range would have been blocked. **Authenticity.** Re-derive the rolling hash step and assert it matches the L1-stored value: ```