Hierarchical multitarget error diagnostic system for zkOS

I view my projects through the system engineering lens, employing system and ontological thinking, stakeholder and requirement analysis, complexity management techniques, and so on. When I was working in Matter Labs, I have touched all sorts of things spanning from compiler frontend and optimizations and formal verification to DevEx and system programming. One of my last gigs was designing diagnostics for ZKsync OS – a large scale intervention at the early developmental stages of the system, which allowed me to go full system engineer. What a treat!

The result was a custom Rust DSL. Here is a sample – this description:

  1. Lists all possible ways of misusing the interface for a subsystem called EVM;
  2. Enumerates all errors from lower level subsystems (EVM calls them internally) that can propagate (cascade) out of EVM.
define_subsystem!(
    Evm,
    interface EvmInterfaceError {
        NoDeploymentScheme,
        UnknownDeploymentData,
        BytecodeNoPadding,
        UnexpectedModifier { modifier: CallModifier },
        InvalidReenterAfterPreemption,
    },
    cascade EvmCascadedError {
        Nonce(NonceSubsystemError),
        Balance(BalanceSubsystemError),
        Deconstruction(DeconstructionSubsystemError),
    }
);

Some errors belong to specific subsystems, but there are also generic error types – defects (internal errors), and resource exhaustion errors.

We throw errors like that:

// Interface (contract) error
return Err(interface_error!(EvmInterfaceError::UnexpectedModifier {
    modifier: call_modifier
}));

// Internal (defect) error
result.ok_or(internal_error!("Expected value to be present"))?;

// Runtime (resource) error
return Err(out_of_ergs_error!());

We can cascade error further so someone else handles them:

// Explicitly
Err(wrap_error!(child_error))
// Wrapping a child error at the call boundary
child_subsystem_call()
    .map_err(wrap_error!())?;

Deeply nested errors can be unpacked, getting to the root cause:

match error.root_cause() {
    RootCause::Runtime(re) => { /* resource exhaustion handling */ }
    RootCause::Internal(ie) => { /* programmer error, log and abort */ }
    RootCause::Usage(info) => {
        println!("{}::{}::{}", info.location, info.subsystem, info.error);
    }
}

Finally, by default, errors only hold the file and line information, and perhaps a minimal set of parameters. We may attach a rich context to errors, but it will only be available when we compile for development targets:

// Attach context lazily.
// Available for native forward run builds.
// If we compile for proving, context won't be included.
result.with_context(|| error_ctx! {
    "caller" => debug_format(call_request.caller),
    "target" => debug_format(target),

// The detailed part is only included in builds for development
    #[detailed] "extra_state" => expensive_computation(),
})

This DSL does more than one job: it enforces boundaries between subsystems, imposes explicit and clean interface descriptions, automates error propagation along explicitly defined paths, and more. As a cherry on top, even though the redesign added complexity inside the DSL itself, it made running ZKSync OS about 2–4% cheaper in RISC-V cycles because of reduced enum nesting on critical paths.

So this is how the story ends; but the moral of it is in the system engineer's path that the characters traveled. If you are curious about that, read on.

What is ZKsync OS

First, what are we working with, what is that ZKsync OS really? Here is a quick overview, assuming little exposure to the blockchain world.

A typical smart contract for Ethereum is written in Solidity, compiled into the bytecode for Ethereum Virtual Machine (EVM), then executed. EVM runs on servers which back the Ethereum network. Once in every few seconds some nodes in this network are randomly selected and they have to agree on the execution results through a consensus mechanism. The blockchain state is eventually updated to reflect these results.

The extensions to Ethereum, the rollups, are offloading execution of smart contracts to separate networks, then publishing the results in bulk on the Ethereum blockchain. Some of them, the ZK-rollups, can publish a small difference between states along with a proof, that the execution producing this state was correct and fair. The proof is backed by cryptography guaranteeing that no one could forge it and publish false execution results.

In case of ZKSync, the underlying execution platform is not strictly EVM, but a virtual machine conforming to the RISC-V architecture. EVM interpreter sits on top of RISC-V virtual machine, compiled into native code for RISC-V. We can add other execution environments, running smart contracts compiled into WASM, native RISC-V instructions and so on. These contracts could, in theory, also interact with each other.

Then ZKsync Airbender generates proofs of correctness for RISC-V execution, not for EVM itself. It is a stronger proof so it also guarantees that everything executed on top of RISC-V was proper.

ZKsync OS is an implementation for a state transition function that enables multiple execution environments (EVM, EraVM, Wasm, etc.) to operate within a unified ecosystem. Think of it as an operating system, a resource manager, controlling execution environments, feeding them transactions, collecting results, managing interop, and so on. To execute a smart contract, we launch the virtual RISC-V machine with an EVM interpreter on top of it; there is some windup when the interpreter is initialized and accepts inputs. All of it becomes a part of execution trace for which Airbender generates a proof.

Compilation targets

ZKsync OS compiles to three distinct target profiles, each with different requirements:

Profile Architecture Purpose
Proving / ZK riscv32 ZKsync Airbender generates a proof for the execution of ZKSync OS.
Forward run / Production native Off-circuit execution, production
Debug native Development of ZKsync OS itself and of smart contracts, using anvil-zksync, deep tracing

For proving, ZKsync OS compiles to a minimal RISC-V binary for zero-knowledge proof circuits. There, you pay "gas" for every instruction. Yet the same code must provide rich, actionable diagnostics when run natively for development and debugging. The forward run profile sits in the middle: we are fine with a slight overhead and we can optimize for an average case, where performance will not be substantially affected.

General requirements

I started working on ZKSync OS when it has already been several months in development. The system was growing organically, there were some leftovers from earlier iterations, and the codebase was moving fast. It was also a critical piece of software – a dangerous combination, promoting brittleness and ad-hoc solutions. We have already started to suffer from inconsistencies between how errors were handled in different parts of the system: there were multiple error hierarchies here and there, some of them redundant, some of them incompatible.

It was the right moment to intervene, clean out the debris, refine the emerging architecture, and imbue it with error diagnostics. My intention was to:

  • Structure and enforce subsystem boundaries.
  • Explicitly describe possible interface violations.
  • Adapt to tradeoffs between runtime costs of diagnostics and its completeness.
  • For debugging, provide rich context with ZKSync OS state, precise error location, and the propagation path.
  • Force good practices on developers. New errors should not be defined spontaneously, they should be properly described and categorized. Subsystem interface violation, a bug in a subsystem, resource exhaustion correspond to different responsible parties and recovery strategies.
  • Automate error propagation.
  • Maximize static guarantees from Rust. At the same time the added infrastructure should not become too complex (and hard to maintain), and making use of unstable features of type system could break the codebase in the future.
  • compatible with nostd and noalloc in RISC-V builds, but may use them in forward run builds.

With this in mind we also have to target three platforms with different sets of constraints and stakeholders:

Profile Architecture Constraints Stakeholders
Proving / ZK riscv32 Zero overhead, no heap, no std Prover, its developers and operators, developers of sequencer and interacting subsystems
Forward run / Prod native Basic context, file+line are OK Participants of the rollup network, smart contract users
Debug native Full context, expensive ops are OK Developers of ZKSync OS, tooling, smart contract developers…

The verbosity level should be a compile-time decision – in the proving binary, we need to eliminate all debugging overhead.

In addition to that, there was already an ad hoc solution – a bunch of enums scattered across the codebase, not forming a single hierarchy, without defined propagation paths. But we were used to them, and forcing our colleagues into a new way of doing things adds friction, so we needed a clear, gentle migration path. We could not switch all subsystems to the new error hierarchy at once because it would put too much strain on the codebase, on reviewers and on other developers. Still, it had to be done fast, because the development moves rapidly.

Lastly, such error system would introduce additional complexity in order to reduce it on use site. I had to make sure there are some diagnostic tools for the error system itself so that developers could pick it up quickly and be guided in case they misuse it.

Systems and errors

I want to approach the design problem as a system engineer, so in case you are not familiar with how system engineering views systems, errors, stakeholders, here is a refresher.

Systems

Anything composed of interacting parts is a system. Thanks to these interactions the system gains emergent properties, which are absent in its components.

Engineers believe that we can model a slice of reality as a system composed of smaller subsystems. These systems are open – they accept inputs from other systems.

Some components end up on the boundary of their parent system. They are exposed to the outside world. I like to think that the parts at the boundary are at the same time parts of the system, and the supersystem. The interactions between systems follow protocols and happen at their boundaries.

Faults, failures, errors

Cybernetics frames the operation of systems in terms of goals, feedback, and control. An error is then deviation from a desired state, detected by feedback loops. This means the system should have mechanisms to detect deviations (errors) and correct course (error handling or recovery).

The words faults, failures and errors are often used loosely. In system engineering they have a precise meaning:

  • A fault is the root cause: a bug, a bad input, a violated assumption, a hardware glitch.
  • A fault is dormant until it produces an error: some part of the system's internal state is now wrong, even though nothing outside has noticed yet.
  • An error becomes a failure when it crosses the system's boundary and someone outside notices: wrong output, a crash, a transaction that should have reverted and didn't.

A bug in the code is a fault; contract failing because of insufficient gas is not a fault.

Errors have cause and origin location. For bugs in logic, they are tightly coupled: the execution reached a certain location, some invariant was not held, hence the behavior diverged from the correct one. But for runtime errors, particularly resource ones, the precise location is not as meaningful. Such errors are often not point failures but results of accumulation. The location tells you which subsystem was starved, which narrows the search space even if it doesn't name the culprit. Useful for triage, not root-causing.

Furthermore, for resource errors, it makes little sense to make their reporting location a part of their identity. So they are in a way global, not "belonging" to any particular subsystem.

Handling and propagation

Once something fishy has happened and we caught it, we can choose to:

  • retry;
  • roll back to a known-good state;
  • degrade gracefully;
  • abort the operation;
  • escalate up the system levels.

Which one is right depends on what happened and who is to blame. A caller who passed bad input has a bug on their side: reject and report. A subsystem that violated its own invariant has a bug on their side: this is a programming error, and it should be loud, not quietly swallowed. Caller should not, and can not fix it – the developers of the callee should. Running out of gas or memory is neither – it is an expected condition, and it should be handled, not treated as a surprise.

Propagation crosses subsystem boundaries, and each boundary should say explicitly what is allowed to cross it – the same way a function signature declares its return type. The alternative is what we already had: a panic buried deep in a call stack, or a sentinel value with no agreed meaning, hoping someone further up will notice and do the right thing, or an enum belonging to one subsystem ending up somewhere very far up the call stack.

And that brings in the stakeholders – one of key concepts in systems engineering. They hold a stake in the system, the system is somehow important to them. Everyone who has to deal with the system somehow makes it to the list. If a subsystem A produces an error, there is an audience to it. Will it be contained and never cross the subsystem boundary? Can something be done about the error, and if so, who can do it? To answer such questions we need to identify the stakeholders for different levels and parts of the system.

A lot of system engineering adventures start with stakeholder analysis. Stakeholders are the source of requirements, which in turn help flesh out the functional architecture of the system and then the measurable validation criteria.

Context is everything attached to an error beyond its bare classification: where it happened (file, line, subsystem), how it got here (the propagation path), and what the system looked like at the time. But we usually can't store the entire state and pass it around if an error occurs. What do we include in the context then? Again, depends on the stakeholders.

With this vocabulary in hand – fault, error, failure; stakeholders; recovery; propagation; context – I can now describe how the design actually answers each of these, and how do we adapt to different target profiles.

Stakeholders

Who cares about knowing what error occurred in ZKSync OS? It concerns many parties, some human, some organizations, some code. I separate them because their requirements for diagnostics differ. Humans, for example, want legible and actionable error messages; code cares about errors that are easy to parse/handle. Humans won't be as much puzzled by a change in error message, or error code, but tooling will likely grow dependent on them1.

  • Smart contracts, run on top of EVM controlled by ZKSync OS
    • and their developers,
    • and the development tooling for both native Ethereum and EVM-based rollups
      • which includes environments and nodes like Anvil, Foundry, Hardhat…
      • … and compilers for Solidity – solc or our new LLVM-based Solidity compiler solx
      • … and compilers for other languages like Vyper,
      • … and the IDEs like Visual Studio Code,
      • … and the language plugins for editors like;
      • and the developers of this tooling;
  • Developers of ZKSync OS itself
    • … including developers of individual subsystems,
    • … and developers who own the subsystem differ from developers who just use it through its interface.
  • Proof generator
    • … and its developers;
  • Sequencer (which directly interfaces with ZKSync OS)
    • … and its developers;
  • Security engineers
  • Auditors

From 2025 on, the developers category includes both humans and AI agents. They act on errors differently:

  • a human sees an error, builds a mental model, maybe checks docs, then edits code;
  • an LLM agent gets the error as a text token stream, pattern-matches against training and context, and immediately emits an action.

Humans arguably tolerate message changes better than agents – we can always check the documentation by the error code. Agents may get confused by rewording. Humans like terser, compressed messages that fit our attention span; agents prefer longer messages with richer context.

Even the simplest little systems have surprisingly many direct and indirect stakeholders. Here is a hypothetical: 1) we make a platform for smart contract execution that has unparalleled diagnostics 2) debugging contracts becomes so easy it becomes a competitive advantage. Now other execution platforms (using custom ZK VMs, on top of other blockchains like Solana or Tezos) are concerned. First, they might need to keep up and improve their diagnostics experience. If we gained enough traction, both developers and tooling become dependent on our formats, codes, conventions, usage patterns and so on. This might become a collaboration opportunity – joining forces and working on a unified debugging experience, which would improve life for everyone.

Finally, we are not frozen in time – stakeholders may change, the requirements may evolve.

Classifying errors

Most stakeholders see the system from one of three sides:

  1. Subsystem developers see the internals of ZKSync OS, but they do not possess the whole knowledge of the system. They typically observe the system at subsystems boundaries.
  2. Developers of prover, sequencer and tooling may observe ZKSync OS at its outermost boundary, where it accepts transactions and returns results.
  3. Developers of smart contracts interface with other systems, for example sequencer. Errors from ZKSync OS may be wrapped and cascaded to them.

The origin of error defines how much information does a stakeholder need to make sense of it. So this will be a starting point for our error taxonomy. We can start classifying errors based on their origin:

  • Defects, or internal errors, which should be fixed at origin.
  • Interface misuse, when we violate the protocol of interaction with a subsystem. It can be fixed by those who initiated interaction.
  • Runtime errors, for which, as we said, the origin is more loosely related to the cause.

Defects

A defect signals a violated invariant: something the programmer asserts cannot happen given correct code. The intent is similar to panic!, but it can be propagated through Result, so the ZK circuit can handle it deterministically without unwinding. Additionally, compiling the code with no-panic has benefits for system software.

The defect's identity is an error message and an origin location (file + line) in the codebase.

Interface errors

Interface errors occur at a subsystem boundary, triggered by their users: calling a function with invalid arguments, violating preconditions, etc.

To enforce correctness and improve the sturdiness of the system, we define subsystem-specific error enums for every subsystem.

Runtime errors

Runtime errors represent conditions that are expected and valid at runtime on the deep level – a transaction running out of gas, a call frame exhausting its return memory buffer. These trigger transaction-level reverts, not global ZKSync OS panics.

Enforcing functional architecture

Having three kinds of errors – defects, contract violations, and runtime errors – we start working them into the system.

The first step is to solidify the system structure. It means putting defined boundaries around subsystems, so that for interface errors their subsystem is clear.

ZKSync OS is written in Rust, which provides structure through language level modules. But these modules are structural; they do not precisely map to the functional architecture of the system.

By convention, we will contain every subsystem within a branch of module tree. When a subsystem operates, errors can appear. Every subsystem boundary has a declared error type enumerating the errors permitted to cross that boundary.

Our task is then to explicitly draw boundaries around subsystems and make their interfaces use the corresponding error types. Here is my solution:

// Collects all static information about a subsystem
pub trait Subsystem: core::fmt::Debug {
    const SUBSYSTEM_NAME: &'static str;
    // Enum describing all possible interface violations
    type Interface: InterfaceErrorKind; // implement InterfaceErrorKind for an interface type error of a subsystem
}
pub enum SubsystemError<S: Subsystem> {
    LeafUsage(InterfaceError<S::Interface>),
    LeafDefect(InternalError),
    LeafRuntime(RuntimeError),
}

Only interface misuse is specific to a subsystem. A defect, or a runtime error can be passed around freely, wherever they originated from.

We only missed one option – when one these three kinds of errors was raised in a subsystem deeper, then cascaded to the interface for this subsystem. Then we wrap the error and pass it up the call stack. Here is an upgrade:

pub trait Subsystem: core::fmt::Debug {
    const SUBSYSTEM_NAME: &'static str;
    type Interface: InterfaceErrorKind;
    // Enum describing all possible wrapped errors
    type Cascaded: ICascadedInner;
}
pub enum SubsystemError<S: Subsystem> {
    LeafUsage(InterfaceError<S::Interface>),
    LeafDefect(InternalError),
    LeafRuntime(RuntimeError),
    Cascaded(CascadedError<S::Cascaded>),
}

Now all the error propagation paths are defined statically. Here is a simplified example of such description:

trait BootloaderSubsystem {
    const SUBSYSTEM_NAME: &'static str = "Bootloader";
    type Interface: InterfaceErrorKind = BootloaderInterfaceError;
    // Enum describing all possible wrapped errors
    // Implement ICascadedError for an enum collecting all inner subsystem from
    // which an error can reach the bootloader boundary
    type Cascaded: ICascadedInner = BootloaderWrappedError; 

}
impl InterfaceErrorKind for BootloaderInterfaceError { ... }
enum BootloaderInterfaceError {
       CantPayRefundOverflow,
       TopLevelInsufficientBalance { balance: u256 },
} 
impl ICascadedInner for BootloaderInterfaceError { ... }
enum BootloaderWrappedError {
       Balance(BalanceSubsystemError),
       EEError(EESubsystemError),
       Nonce(NonceSubsystemError),
}

With this code, we are able to use SubsystemError<BootloaderSubsystem> as a return type for the functions that make up an interface for a subsystem, like that

fn some_bootloader_function(
    &mut self,
    from_ee: ExecutionEnvironmentType,
    ...
) -> Result<(), SubsystemError<BootloaderSubsystem>> { // or make an alias like BootloaderError
    ...

What of subsystems that have no interface errors (very simple) or no cascaded errors (leaf subsystems)? We can assign an empty enum to the Interface and Cascaded types of Subsystem trait:

pub enum NoErrors {}
pub trait Subsystem: core::fmt::Debug {
    const SUBSYSTEM_NAME: &'static str;
    type Interface: InterfaceErrorKind = NoErrors;
    type Cascaded: ICascadedInner = NoErrors;
}

NoErrors is an uninhabited type (a Rust empty enum with no variants). It satisfies all the traits required to use it as interface or cascaded type (InterfaceErrorKind, ICascadedInner, GetRootCause, etc.) with bodies that are all unreachable!() — the compiler guarantees they can never execute.

This serves as the neutral element of sorts: subsystems that produce no interface errors or no cascaded errors specify NoErrors as the corresponding associated type. The Subsystem trait provides these as defaults:

pub trait Subsystem: core::fmt::Debug {
    const SUBSYSTEM_NAME: &'static str;
    type Interface: InterfaceErrorKind = NoErrors;
    type Cascaded: ICascadedInner = NoErrors;
}

While the code looks cumbersome, it's not going to be an issue once we add a DSL to generate all the boilerplate for us.

Root cause

If errors can be cascaded from deep inside the subsystem hierarchy, simply pattern matching on the error type might not be sufficient. So we implement a lookup to get to the innermost error.

pub enum RootCause<'a> {
    Runtime(&'a RuntimeError),
    Internal(&'a InternalError),
    Usage(ErrorInfo<'a>),
}

pub struct ErrorInfo<'a> {
    pub subsystem: &'static str,
    pub location: ErrorLocation,
    pub error: &'a dyn core::fmt::Display,
}

pub trait GetRootCause {
    fn root_cause(&self) -> RootCause;
}

The GetRootCause trait recursively traverses a CascadedError chain to find the original leaf error. Here is an example for a deeply nested cascade:

Bootloader::Cascaded(EE::Cascaded(EVM::LeafUsage(InterfaceError)))
     ↓ root_cause()
RootCause::Usage(ErrorInfo {
   subsystem: "Evm",
   location: ...,
   error: &interface_error })

This allows error reporting and logging infrastructure to extract the fundamental cause without needing to exhaustively match every intermediate cascade level.

match error.root_cause() {
    RootCause::Runtime(re) => { /* resource exhaustion handling */ }
    RootCause::Internal(ie) => { /* programmer error, log and abort */ }
    RootCause::Usage(info) => {
        println!("{}::{}::{}",
                 info.location,
                 info.subsystem,
                 info.error);
    }
}

In my opinion, this is the right balance between type safety and usability. If we stick the pattern matching over the full path to error here, we introduce a dependence on the error propagation path, meaning we won't be able to change the subsystem structure behind the boundary as easily.

GetRootCause::root_cause() returns RootCause<'a> with a lifetime tied to the original error. The traversal is a chain of match expressions over borrowed references. No cloning, no boxing, no heap allocation occurs during root-cause extraction – even for deeply nested cascade chains.

This matters in an environment where allocation cost is carefully tracked (as it must be in a proof system context).

Summary of error taxonomy

To sum up, there are exactly three semantic categories of errors across the entire system, and an additional category to encode cascading paths:

Usage errors
Likely, the caller violated a subsystem's interface contract. Probably triggered by bad input or incorrect API use. Recoverable at the right level.
Defect errors
An internal invariant was violated. Ideally, these indicate bugs in the system itself, never in user-supplied data. Even when triggered by external data, it is not an expected validation result.
Runtime errors
Resource limits exceeded: out of ergs (gas), out of return memory, out of native resources. These are expected in normal operation.
Cascaded errors
An error originated in a child subsystem and is being propagated up the subsystems stack. The chain is preserved for root-cause analysis.

This distinction is enforced structurally through SubsystemError<S> type, not merely by convention.

I like this design because it clearly buys some robustness for its complexity:

  1. Exhaustive classification: every error falls into exactly one of three categories (four if we count the cascaded errors). At a call site, a match on a SubsystemError forces the caller to handle each semantically distinct case.
  2. Generic propagation: the three leaf variants (LeafDefect, LeafRuntime, LeafUsage) are universally convertible via From impls. Any InternalError can become a SubsystemError<S> for any S. This enables ergonomic ? usage across subsystem boundaries.
  3. Zero-cost when unused: if S::Interface = NoErrors, the LeafUsage variant is uninhabited at the type level (though not at the enum level); the unreachable!() methods ensure no accidental construction. For our builds, we have observed that the compiler will cut out these enum variants reducing the monomorphised Subsystem enum so it only has two variants: runtime error and defect.

A couple of technical questions:

  1. Why not inner enums? That would force us to create enums for all four combinations of having or not having interface and cascaded errors. Also, we really do not want a deep nested enum hierarchy for errors if we can survive without it. Rust compiler can not flatten nested enums, and this may quickly lead to an overhead for the prover target. Most common instances of errors are resource exhaustion and invariant violations, so these should be the cheapest to carry around.
  2. Why create an empty enum NoErrors instead of using the ! (never) type? I made this project in summer 2025, the never type was not stable (and still is not, as of July 2026).

Migration and SystemError

When I started working on improving the error handling, there were lots of error defining enums scattered across subsystems. In order to gradually migrate every subsystem to the new error types, I first introduced a type SystemError which is a simplified version of SubsystemError. It does not allow distinguishing between defects and interface errors, and it does not allow error wrapping. However, because it is also not subsystem-specific, all such errors are compatible regardless of their origin.

pub enum SystemError {
    LeafDefect(InternalError),
    LeafRuntime(RuntimeError),
}

SystemError is convertible from any SubsystemError<S> where S has Interface = NoErrors and Cascaded = NoErrors.

impl<S> From<SubsystemError<S>> for SystemError
where
    S: Subsystem<Interface = NoErrors, Cascaded = NoErrors>,
{
    fn from(value: SubsystemError<S>) -> Self {
        match value {
            SubsystemError::LeafUsage(_) => unreachable!(),
            SubsystemError::LeafDefect(internal_error) => internal_error.into(),
            SubsystemError::LeafRuntime(runtime_error) => runtime_error.into(),
            SubsystemError::Cascaded(_) => unreachable!(),
        }
    }
}

As I worked through subsystems, I gradually encoded explicit interface errors, until all of them were using SubsystemError.

Context and the target platforms

The last piece from our system engineering overview was the error context. We would like to add arbitrary context to any error so that the human (or AI agent) has better understanding of what went wrong.

A popular Rust library anyhow allows attaching context to errors this way:

let content = fs::read(path)
        .with_context(||
                      format!("Failed to read instrs from {}",
                              path.display()))?;

We are not designing a generic system, so we can use the information that we have three kinds of stakeholders requiring different level of details in the context:

  • For the proving system, we can't afford rich context at all; all diagnostics should be kept to an absolute minimum to save gas on proving. Besides, it does not support neither std nor the standard Rust allocator.
  • For the forward run system, which executes ZKSync OS and the transactions on EVM, we either want:
    • a medium-sized context for a regular operation,

Additionally, we will fix a context format as a mapping from string keys to values.

Users can attach context to errors this way:

// Attach context lazily (noop on RISC-V)
result.with_context(|| error_ctx! {
    "caller" => debug_format(call_request.caller),
    "target" => debug_format(target),

    #[detailed] "extra_state" => expensive_computation(),
})

The error_ctx! macro is the end-user DSL for building contexts. Invalid usages are caught with compile_error! inside the macro arms:

  • Empty string keys ("" => value)
  • Unknown attributes (#[some_random_attribute])
  • Duplicate #[detailed] attributes
  • Identifier followed by => (misuse of shorthand)
  • Missing => in key-value pair

This allowed my fellow ZKSync OS developers to have more guidance in using the newly added error_ctx macro.

I use two compiler time parameters to control the verbosity of the context actually included in the compiled binaries:

  1. The target platform: RISC-V vs native
  2. Cargo feature detailed_errors.

Additionally, we can also turn on and off error origin tracking.

# zk_ee/Cargo.toml
default = ["error_origins", "detailed_errors"]
error_origins = []    # Track file path + line number for errors
detailed_errors = []  # Enables #[detailed] context entries

Let's go quickly through our implementation of context.

The trait IErrorContext is the abstraction layer. On non-RISC-V targets, where we can use dynamically allocated memory, the context is a heap-allocated vector map. On RISC-V targets, it becomes a unit struct.

pub trait IErrorContext {
    fn get(&self, name: &str) -> Option<&String>;
    fn push(self, name: &'static str, value: impl ToString,
            visibility: ValueVisibility) -> Self;
    fn push_lazy<F>(self, name: &'static str, f: F,
                    visibility: ValueVisibility) -> Self
    where F: FnOnce() -> String;

    fn to_vec(&self) -> Option<Vec<NamedContextElement>>;
    fn into_vec(self) -> Option<Vec<NamedContextElement>>;
}

// On non-RISC-V targets
pub struct ErrorContext { values: Vec<(&'static str, String)> }

// On RISC-V targets
pub struct ErrorContext;  // unit struct, zero bytes

The first context method push includes the entry if the ValueVisibility and feature flags allow it:

pub enum ValueVisibility {
    AnyForwardRun,   // Included in all non-proving runs
    DetailedOnly,    // Only with `detailed_errors` feature, never on RISC-V
}

fn push(mut self, name: &'static str, value: impl ToString,
        visibility: ValueVisibility) -> Self {
    match visibility {
        ValueVisibility::AnyForwardRun => perform_push_value(),
        ValueVisibility::DetailedOnly if cfg!(feature = "detailed_errors") =>
            perform_push_value(),
        _ => {}
    }
    self
}

The second method push_lazy conditionally calls the closure only when the entry should actually be stored:

fn push_lazy<F>(mut self, name: &'static str, f: F,
               visibility: ValueVisibility) -> Self
where F: FnOnce() -> String {
    let should_include = match visibility {
        ValueVisibility::AnyForwardRun => true,
        ValueVisibility::DetailedOnly => cfg!(feature = "detailed_errors"),
    };
    if should_include { let value = f(); self.values.push(...) }
    self
}

This ensures expensive computations (e.g., debug_format(complex_object)) are never evaluated unless both the target is native and detailed_errors is enabled. The tests explicitly verify this with atomic counters.

Critically, push_lazy does not call f. The closure is constructed by the caller but never invoked. The Rust compiler, seeing that the result is unused, eliminates the closure and its captured environment entirely via dead-code elimination. This guarantees zero cost even for complex expressions passed to the context.

Summary of diagnostics verbosity

This table summarizes how much diagnostics information is retained for different targets:

Dimension RISC-V (proving) Native, no detailed_errors Native with detailed_errors
ErrorLocation Unit struct (Copy) { file, line } { file, line }
ErrorContext Unit struct (Copy) Vec<(name, String)> Vec<(name, String)>
error_ctx! body Discarded (no-op macro) Evaluated (AnyForwardRun) Evaluated (all entries)
#[detailed] context entries Discarded Discarded (not evaluated) Lazily evaluated and stored
Are error types Copy? Yes No (Vec not Copy) No (Vec not Copy)
Heap allocation None On error path On error path
String formatting None On AnyForwardRun entries On all entries

Conclusion

First, what did we achieve for the price of all this complexity?

  1. Explicit error semantics: The four-way classification (LeafUsage, LeafDefect, LeafRuntime, Cascaded) forces callers to reason about error semantics at match sites. It is impossible to accidentally conflate a contract violation with a programmer defect or resource exhaustion.
  2. Structural cascade tracing: The CascadedError chain provides an implicit stack trace through subsystem boundaries. Combined with GetRootCause, error diagnosis can identify both the origin and the propagation path.
  3. Uniform macro interface: All error creation is macro-driven with automatic location capture, making accidental locationless errors impossible.
  4. Incremental verbosity: The three-tier system (proving → production → debug) provides meaningful information at each level without retroactively changing semantics. Errors are always errors; only metadata richness changes.
  5. Lazy evaluation of expensive context: The push_lazy / #[detailed] mechanism guarantees that expensive operations (e.g., full debug formatting of complex state) are never even evaluated unless the feature is active. No runtime guard is required.
  6. Zero-cost abstraction in proving context: Compiling for RISC-V eliminates all error metadata machinery at compile time, stripping diagnostics to the bare minimum with optional file+line information of origin.
  7. Type-safe subsystem boundaries: SubsystemError<S> is parameterised – the type system enforces that a subsystem can only generate interface errors it declared, and can only forward errors from child subsystems it explicitly listed in its cascade type. Mismatched errors are compiler errors.
  8. DSL ergonomics: The define_subsystem! macro reduces boilerplate dramatically. Declaring a new subsystem with its full error vocabulary is a simple, short invocation. The error_ctx! DSL includes rich compile-time validation with actionable compile_error! messages for misuse, making adoption easier.

In my opinion, we met the core constraints of the main stakeholders: rich, structured, traceable errors during development and testing, with complete overhead elimination in the zero-knowledge proof environment. This is achieved through Rust's type system rather than runtime switches, making it both robust and maintainable.

Where would we go from there? System engineering suggests the following plan for an iteration:

  • identify stakeholders and their requirements;
  • create measurable validation criteria;
  • after implementing, verify and validate your work.

If we take things a bit more seriously and pour more resources into the engineering process, we'd have to build something like this table:

Stakeholder Need Requirement Mechanism Verification
Prover operator Minimal execution cost No context formatting or allocation on proving target Target-specific context representation Cycle count, binary size, allocation audit
Subsystem developer Identify ownership Errors retain origin subsystem and location InterfaceError<S>, ErrorLocation Integration test over nested propagation
Tooling author Stable machine parsing Stable error identifier independent of message text Numeric/string error code Compatibility test across versions
Debugger user Actionable state Detailed context available in debug profile #[detailed] entries User study or diagnosis-time benchmark

Taking this road requires studying the declared errors themselves, not the framework for defining them, so it promptly exits the scope of our little story.

The measurability is complicated for any requirements related to ergonomics, robustness, design constraints. So it seems reasonable to limit with the expert opinion of the stakeholders themselves. In the short time I've spent working on this project before I left Matter Labs, I did receive a positive feedback from my colleagues. If I were to continue, I'd contact more stakeholders, for example, smart contract developers who use an environment with a running instance of ZKSync OS to develop their contracts. If all stakeholders are subjectively satisfied, then the job was done well.

Footnotes:

1

Hyrum's law again.