How Sway's IR Verification Pass Validates Compiled Code: A Deep Dive into the Module Verifier

Sway's IR verification pass validates compiled code by hierarchically checking modules, functions, blocks, and instructions for structural integrity and type safety, returning detailed IrError diagnostics before code generation.

Sway's intermediate representation (IR) serves as the bridge between high-level Sway code and FuelVM bytecode. Before handing IR to any backend, Sway's IR verification pass ensures structural and type-correctness across the entire Context. This verification system, implemented in the FuelLabs/sway repository, acts as a gatekeeper that prevents malformed IR from reaching code generation.

Module-Level Verification in Sway's IR

The entry point for Sway's IR verification pass is the module_verifier function in sway-ir/src/verify.rs. This function acts as a standard pass that the pass manager can invoke:

pub fn module_verifier(
    context: &Context,
    _analyses: &AnalysisResults,
    module: Module,
) -> Result<AnalysisResult, IrError> {
    context.verify_module(module)?;
    Ok(Box::new(ModuleVerifierResult))
}

The real work happens in Context::verify_module, which performs two major checks:

  1. Function verification: Iterates over every function in the module and calls verify_function to validate function-level invariants.
  2. Global variable validation: Ensures non-mutable global variables have initializers, preventing undefined references.
fn verify_module(&self, module: Module) -> Result<(), IrError> {
    for function in module.function_iter(self) {
        self.verify_function(module, function)?;
    }
    // Globals must have initializers unless mutable
    for global in &self.modules[module.0].global_variables {
        if !global.1.is_mutable(self) && global.1.get_initializer(self).is_none() {
            let global_name = module.lookup_global_variable_name(self, global.1);
            return Err(IrError::VerifyGlobalMissingInitializer(
                global_name.unwrap_or_else(|| "<unknown>".to_owned()),
            ));
        }
    }
    Ok(())
}

If any check fails, the verifier returns an IrError variant such as VerifyGlobalMissingInitializer, pinpointing the offending global variable.

Function-Level Verification

Once module-level checks pass, Sway's IR verification pass examines each function via verify_function in sway-ir/src/verify.rs. This method validates structural integrity before descending into individual blocks:

fn verify_function(&self, cur_module: Module, function: Function) -> Result<(), IrError> {
    // Parent module consistency
    if function.get_module(self) != cur_module { … }

    // Entry block must have no predecessors
    let entry_block = function.get_entry_block(self);
    if entry_block.num_predecessors(self) != 0 { … }

    // Function arguments must match entry‑block arguments
    if function.num_args(self) != entry_block.num_args(self) { … }
    for ((_, func_arg), block_arg) in function.args_iter(self).zip(entry_block.arg_iter(self)) {
        if func_arg != block_arg { … }
    }

    // Verify every block in the function
    for block in function.block_iter(self) {
        self.verify_block(cur_module, function, block)?;
    }

    // Verify function‑level metadata
    self.verify_metadata(function.get_metadata(self))?;
    Ok(())
}

Key validations at this stage include:

  • Module ownership: Functions must belong to the module being verified (InconsistentParent error if not).
  • Entry block integrity: The entry block must have no predecessors (it must be the root of the control flow graph).
  • Argument consistency: Function arguments must exactly match the entry block's arguments in both count and type.
  • Metadata validation: Function-level metadata undergoes verify_metadata checks.

Block-Level Verification

After function validation, Sway's IR verification pass examines each basic block via verify_block. This method ensures that blocks are well-formed before inspecting individual instructions:

fn verify_block(
    &self,
    cur_module: Module,
    cur_function: Function,
    cur_block: Block,
) -> Result<(), IrError> {
    // Block‑function relationship
    if cur_block.get_function(self) != cur_function { … }

    // Empty, unreferenced blocks are ignored
    if cur_block.num_instructions(self) <= 1 && cur_block.num_predecessors(self) == 0 {
        return Ok(());
    }

    // Verify block arguments match their declaration order
    for (arg_idx, arg_val) in cur_block.arg_iter(self).enumerate() {
        match self.values[arg_val.0].value {
            ValueDatum::Argument(BlockArgument { idx, .. }) if idx == arg_idx => (),
            _ => return Err(IrError::VerifyBlockArgMalformed),
        }
    }

    // Verify all instructions inside the block
    let r = InstructionVerifier { … }.verify_instructions();

    // If an error occurs, pretty‑print the offending block/value for debugging
    if let Err(error) = &r { … }

    // Terminator checks – exactly one terminator and it must be the last instruction
    let (last_is_term, num_terms) = cur_block
        .instruction_iter(self)
        .fold((false, 0), |(_, n), ins| {
            if ins.is_terminator(self) { (true, n + 1) } else { (false, n) }
        });
    if !last_is_term {
        Err(IrError::MissingTerminator(cur_block.get_label(self).clone()))
    } else if num_terms != 1 {
        Err(IrError::MisplacedTerminator(cur_block.get_label(self).clone()))
    } else {
        Ok(())
    }
}

Critical block-level checks include:

  • Parent consistency: Blocks must belong to the function under verification.
  • Argument ordering: Block arguments must appear in the order declared in the IR (VerifyBlockArgMalformed if violated).
  • Terminator rules: Each block must contain exactly one terminator instruction, and it must be the final instruction (MissingTerminator or MisplacedTerminator errors if violated).
  • Dead block handling: Empty blocks with no predecessors are silently ignored to avoid noise from unreachable code.

Instruction-Level Verification

The granular validation occurs in InstructionVerifier::verify_instructions, which dispatches to op-specific validators. For each instruction, the verifier checks parent consistency, operand type compatibility, and operation-specific constraints.

Binary Operation Validation

The verify_binary_op function demonstrates type-specific validation for arithmetic and bitwise operations:

fn verify_binary_op(
    &self,
    op: &BinaryOpKind,
    arg1: &Value,
    arg2: &Value,
) -> Result<(), IrError> {
    let arg1_ty = arg1.get_type(self.context).ok_or(IrError::VerifyBinaryOpIncorrectArgType)?;
    let arg2_ty = arg2.get_type(self.context).ok_or(IrError::VerifyBinaryOpIncorrectArgType)?;

    match op {
        BinaryOpKind::Lsh | BinaryOpKind::Rsh => {
            // Shift RHS may be any uint, LHS must be uint or b256
            let is_lhs_ok = arg1_ty.is_uint(self.context) || arg1_ty.is_b256(self.context);
            if !is_lhs_ok || !arg2_ty.is_uint(self.context) {
                return Err(IrError::VerifyBinaryOpIncorrectArgType);
            }
        }
        BinaryOpKind::Add | BinaryOpKind::Sub => {
            // Either (uint, uint) or (ptr, u64)
            if !(arg1_ty.eq(self.context, &arg2_ty) && arg1_ty.is_uint(self.context)
                || arg1_ty.is_ptr(self.context) && arg2_ty.is_uint64(self.context))
            {
                return Err(IrError::VerifyBinaryOpIncorrectArgType);
            }
        }
        // ... other ops omitted for brevity …
    }
    Ok(())
}

Operation-Specific Validators

The verifier includes specialized checks for various instruction types:

Operation Validation Logic
BitCast Source and target types must be ≤ 64 bits (verify_bitcast).
Call Callee must belong to the same module and argument types must match the function signature (verify_call).
Load/Store Operand must be a pointer and stored/loaded types must agree (verify_load, verify_store).
FuelVM-specific verify_gtf, verify_log, and verify_state_* ensure correct operand families and immediate ranges for FuelVM operations.
Metadata Struct tags must be valid identifiers (verify_metadata).

All validators return specific IrError variants that include context such as function names, block labels, and offending values to facilitate debugging.

Integration with the Pass Manager

The module verifier is registered as a standard analysis pass in sway-ir/src/verify.rs:

pub fn create_module_verifier_pass() -> Pass {
    Pass {
        name: MODULE_VERIFIER_NAME,
        descr: "Verify module",
        deps: vec![],
        runner: ScopedPass::ModulePass(PassMutability::Analysis(module_verifier)),
    }
}

The pass manager loads this pass during pipeline construction (referenced in pass_manager.rs). When the Sway compiler finishes IR generation, the pass manager executes the verifier before any backend passes, guaranteeing that only well-formed IR proceeds to code generation.

Practical Usage Examples

Direct Verification via Context

You can invoke the verifier directly on a Context to validate IR before further processing:

use sway_ir::{Context, Backtrace, ExperimentalFeatures};

let mut ctx = Context::new(&source_engine, ExperimentalFeatures::default(), Backtrace::All);
// ... build or deserialize the IR ...

// Validate the whole program
if let Err(err) = ctx.verify() {
    eprintln!("IR verification failed: {}", err);
}

The call to ctx.verify() triggers the module verifier for every module in the context, returning detailed error information if validation fails.

Using the Pass Manager

As implemented in the Sway compiler, you can register the verifier as part of a transformation pipeline:

use sway_ir::pass_manager::PassManager;

let mut pm = PassManager::new(&mut ctx);
pm.register(sway_ir::verify::create_module_verifier_pass());
// ... register other passes like `create_demotion_pass`, `create_codegen_pass`, etc.

pm.run(); // `module_verifier` runs automatically as part of the pipeline

The verifier functions as a standard pass that can be inserted anywhere in the pipeline, though it typically runs early to catch errors before expensive optimization or code generation passes execute.

Summary

  • Sway's IR verification pass operates hierarchically, validating modules, functions, blocks, and instructions in sequence.
  • Module-level checks ensure functions belong to the correct module and global variables have required initializers.
  • Function-level validation verifies entry block integrity, argument consistency, and metadata correctness.
  • Block-level rules enforce single terminator placement, proper argument ordering, and parent consistency.
  • Instruction-level verification dispatches to op-specific validators that check type compatibility, operand constraints, and FuelVM-specific requirements.
  • Integration occurs through the standard pass manager, with the verifier running before backend code generation to guarantee sound IR.

Frequently Asked Questions

What happens when Sway's IR verification pass finds an error?

When the verifier detects an invariant violation, it immediately returns an IrError variant specific to the failure type, such as VerifyGlobalMissingInitializer, MissingTerminator, or VerifyBinaryOpIncorrectArgType. The error includes context like the function name, block label, and offending value, allowing the compiler to produce precise diagnostic messages that pinpoint exactly where the IR became malformed.

Can I run Sway's IR verification pass independently of the full compiler pipeline?

Yes, the verification pass can be invoked directly through the Context::verify() method, which iterates over all modules and runs the full verification suite. Alternatively, you can instantiate a PassManager, register the verifier using create_module_verifier_pass(), and execute it as part of a custom transformation pipeline. This flexibility allows IR tools and debugging utilities to validate code without running the entire Sway compiler frontend.

How does the verifier handle FuelVM-specific operations?

The instruction-level verification includes specialized validators for FuelVM-specific operations such as verify_gtf, verify_log, and verify_state_*. These functions check that operands belong to the correct value families (e.g., pointers, unsigned integers) and that immediate values fall within valid ranges required by the FuelVM specification. This ensures that the generated IR is not only structurally sound but also compatible with the target virtual machine's constraints.

What is the relationship between block arguments and function arguments in the verifier?

The verifier enforces that function arguments must exactly match the entry block's arguments in both count and type. During function-level verification, the code iterates through function.args_iter() and entry_block.arg_iter() simultaneously, comparing each pair. If any mismatch occurs, the verifier returns an InconsistentParent or type error. This strict coupling ensures that the IR maintains a consistent view of data flow between the function signature and its entry point.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →