# A Brief Introduction to RISC Zero & Steel

2025 is the year of privacy-preserving tech. Here’s a succinct overview of RISC Zero.

## RISC Zero

The **RISC Zero zkVM** is an implementation of the **RISC-V architecture** as an **arithmetic circuit**. Specifically, the zkVM implements `RV32IM` which is the **32-bit base instruction set** along with the **multiplication feature**, extended using the `ECALL` instruction to add **optimized cryptographic instructions**. Additional technical details can be found [here](https://dev.risczero.com/api/zkvm/zkvm-specification#the-zkvm-execution-model).

Mathematically, the `RV32IM` circuit encodes RISC-V as **polynomial constraints** within a **STARK-based proving system**. In short, the **memory and register transitions** of the emulated processor at each **clock cycle** are captured within the **execution trace**, providing a complete snapshot of the guest program being executed.

[![zkVM Overview | RISC Zero Developer Docs](https://dev.risczero.com/assets/images/from-rust-to-receipt-23117368c4f46d78c8cac3b753245a5a.png align="left")](https://github.com/risc0/risc0/blob/main/website/static/diagrams/from-rust-to-receipt.png)

1. The **guest program** is compiled to an executable format known as the **ELF binary**.
    
2. The untrusted **host program** orchestrates verifiable computation, handling two-way communication with the zkVM environment and transmitting **private inputs** to the guest program.
    
    * [`ExecutorEnvBuilder::write`](https://docs.rs/risc0-zkvm/1.2.1/risc0_zkvm/struct.ExecutorEnvBuilder.html#method.write) is used to pass *private* data from the *host* to the *guest*.
        
    * [`env::write`](https://docs.rs/risc0-zkvm/1.2.1/risc0_zkvm/guest/env/fn.write.html) is used to pass *private* data from the *guest* to the *host*.
        
    * [`env::commit`](https://docs.rs/risc0-zkvm/1.2.1/risc0_zkvm/guest/env/fn.commit.html) is used to pass *public* data from the *guest* to the *host*.
        
3. The **executor** runs the ELF binary and records the execution trace (aka the **session**).
    
4. The **prover** checks and proves the validity of the session, producing a cryptographic **receipt** that attests to the execution of the guest program.
    
    <div data-node-type="callout">
    <div data-node-type="callout-emoji">⚠️</div>
    <div data-node-type="callout-text"><strong>Proving with private data should be done with local proof generation</strong> to ensure it never leaves the machine, as the prover can see all private information involved.</div>
    </div>
    
    1. The **receipt claim** portion of the receipt contains the journal and other important details.
        
        * The **journal** portion of the receipt claim contains the **public output** committed by the guest.
            
        * The cryptographic **image ID** indicates the method or boot image for zkVM execution.
            
        * The guest program **exit status** and **memory state** are also included in the claim.
            
    2. The **seal** portion of the receipt is a **zk-STARK** that attests to the receipt claim.
        
        * The **control ID** is the first entry in the seal. It is the **Merkle hash** of the contents of the **control columns**, assumed to be known to the verifier as part of the circuit definition.
            
5. Verification of the receipt provides a cryptographic assurance of honest journal creation.
    
    [![](https://miro.medium.com/v2/resize:fit:1400/1*pxRtmI1Jf_Ldy-MLDsWVGQ.png align="left")](https://github.com/risc0/risc0/blob/main/website/docs/proof-system/assets/proof-system-layers.png)
    
    1. The **RISC-V Circuit** proves zkVM execution by dividing the session into multiple segments and generating a proof for each segment, forming a **composite receipt** of multiple STARKs.
        
    2. The **Recursion Circuit** uses incrementally verifiable computation to combine the proofs of a composite receipt into a single STARK, generating a **succinct receipt**. It is also used to aggregate and efficiently verify multiple succinct receipts in a similar manner. More details can be found [here](https://youtu.be/x0-7Y46bQO0?feature=shared&t=1310) and a full example analogous to verifiable encryption with RSA can be found [here](https://github.com/risc0/risc0/tree/release-1.2/examples/composition).
        
    3. The **Groth16 Circuit** converts a succinct receipt into a single **Groth16 receipt**, acting as a compact SNARK wrapper around the larger STARK proof that can be verified on-chain by calling `IRiscZeroVerifier::verify`.
        

<div data-node-type="callout">
<div data-node-type="callout-emoji">⚠️</div>
<div data-node-type="callout-text"><strong>To keep fake receipts and verification bypass of </strong><code>RISC0_DEV_MODE</code><strong> out of production environments, it is recommended to build with the&nbsp;</strong><code>disable-dev-mode</code><strong>&nbsp;feature flag:</strong></div>
</div>

|  | `disable-dev-mode` off (default) | `disable-dev-mode` on |
| --- | --- | --- |
| `RISC0_DEV_MODE=true` | dev-mode activated | prover panic |
| `RISC0_DEV_MODE=false` or unset | default project behaviour | default project behaviour |

### Parity Example

Suppose we want to prove the parity of a integer value without revealing the value itself. The trivial guest program could look something like this:

```rust
#![no_main]
#![no_std]

use risc0_zkvm::guest::env;

risc0_zkvm::guest::entry!(main);

fn main() {
    // Load the number from the host
    let x: u32 = env::read();

    // Compute the product while being careful with integer overflow
    let is_even: bool = x % 2 == 0;

    // Commit the result to the journal without exposing the value
    env::commit(&is_even);
}
```

Note that it is perfectly fine to panic within the guest, for example with the following snippet if we wanted to consider non-zero integers only:

```rust
// Verify the value is non-zero
if x == 0 {
   panic!("Number is zero")
}
```

We would just need to make sure to handle it appropriately within host, but will omit this for simplicity. The host program can be split into multiple files for some logical separation. Here is an idea of how the proving could look in `lib.rs`:

```rust
use is_even_methods::IS_EVEN_ELF;
use risc0_zkvm::{default_prover, ExecutorEnv, Receipt};

// Compute whether the value is even inside the zkVM
pub fn is_even(x: u32) -> (Receipt, bool) {
    let env = ExecutorEnv::builder()
        // Send x to the guest
        .write(&x)
        .unwrap()
        .build()
        .unwrap();

    // Obtain the default prover.
    let prover = default_prover();

    // Produce a receipt by proving the specified ELF binary.
    let receipt = prover.prove(env, IS_EVEN_ELF).unwrap().receipt;

    // Extract journal of receipt (i.e. output b, where b = x % 2 == 0)
    let b: bool = receipt.journal.decode().expect(
        "Journal output should deserialize into the same types (& order) that it was written",
    );

    // Report the result
    println!("I know the value is {}, and I can prove it!", b);

    (receipt, b)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_even() {
        const TEST_VALUE: u32 = 5;
        let (_, result) = is_even(5);
        let actual: bool = TEST_VALUE % 2 == 0;
        assert_eq!(
            result,
            actual,
            "We expect the zkVM output to be {}", actual
        );
    }
}
```

And the rest of the host program in `main.rs`:

```rust
use rand::Rng;
use is_even_methods::EVEN_ID;

fn main() {
    // Initialize tracing. To view logs, run `RUST_LOG=info cargo run`
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();

    // Generate a random number
    let mut rng = rand::thread_rng();
    let x: u32 = rng.gen::<u32>();

    let (receipt, _) = is_even(5);

    // Here is where one would send 'receipt' over the network...

    // Verify receipt, panic if it's wrong
    receipt.verify(IS_EVEN_ID).expect(
        "Code you have proven should successfully verify; did you specify the correct image ID?",
    );
}
```

## Steel

**Steel** is a production-ready **smart contract execution prover** that enables unbounded runtime for EVM apps by proving correctness of off-chain execution without the need for writing ZK circuits.

[![Security Model Diagram](https://dev.risczero.com/assets/images/security-model-diagram-70478725ab3b760ff9bf29eee53a4f74.svg align="left")](https://github.com/risc0/risc0/blob/main/website/static/diagrams/security-model-diagram.svg)

* Steel uses [revm](https://docs.rs/revm/latest/revm/) for simulation of an **EVM environment** which is constructed and pre-populated in the host program before being **passed as an input** to the guest program.
    
    * **EVM state** can be queried within the RISC Zero zkVM using alloy’s [sol! macro](https://alloy.rs/examples/sol-macro/index.html).
        
    * **Merkle storage proofs** are verified by the guest to validate the **integrity of RPC data**.
        
* The **STARK proofs** produced by the zkVM are **wrapped in circom Groth16 SNARKs**, which are significantly smaller and faster (read: cheaper) to verify on-chain.
    
    * The STARK proof **control root** is passed as a public input to the SNARK, allowing for **updates** to the RISC-V prover **without requiring a new trusted setup ceremony**.
        
    * It is recommended to use the [router](https://github.com/risc0/risc0-ethereum/blob/main/contracts/version-management-design.md#router) to **forward verification** to the appropriate contract. Additional details and considerations can be found in the on-chain verifier and version management design document [here](https://github.com/risc0/risc0-ethereum/blob/main/contracts/version-management-design.md).
        
* **Steel commitments**, comprising a **block identifier** and **block digest**, are validated on-chain to guarantee the correctness of blockchain state associated with the Steel proof.
    
    * **Block hash commitments** are verified with the `blockhash` **opcode**, up to 256 blocks.
        
    * **Beacon block commitments** are verified with the [EIP-4788](https://eips.ethereum.org/EIPS/eip-4788) **beacon root contract**, extending L1 Ethereum validation time to just over 24 hours.
        
    * **Steel history** separates the **execution and commitment blocks** to enable state older than 24 hours to be queried, up to the Cancun upgrade date of March 13 2024.
        

### Counter Example

Consider the trivial smart contract that allows `counter` to be incremented if, and only if, the ERC20 balance of the sender is at least one token:

```solidity
pragma solidity ^0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Counter {
    IERC20 public immutable token;
    
    uint256 public counter;
    
    constructor(address _token) {
		    token = IERC20(_token);
		}

    function increment(address account) public {
        require(account == msg.sender, "Invalid sender");
        require(IERC20(token).balanceOf(account) >= token.decimals(), "Insufficient balance");
        
        ++counter;
    }
}
```

With Steel, leveraging RISC Zero as a coprocessor for efficient proof generation and verification, this becomes:

```solidity
pragma solidity ^0.8.20;

import {IRiscZeroVerifier} from "risc0-ethereum/IRiscZeroVerifier.sol";
import {Steel} from "risc0-ethereum/contracts/src/steel/Steel.sol";
import {ImageID} from "./ImageID.sol"; // auto-generated contract after running `cargo build`.

contract SteelCounter {
    address public immutable token;
    IRiscZeroVerifier public immutable verifier;
    
    uint256 public counter;
    mapping(bytes32 => bool) public processedJournals;
    
    constructor(address _token, address _verifier) {
		    token = _token;
		    verifier = IRiscZeroVerifier(_verifier);
		}

    function increment(bytes calldata journalData, bytes calldata seal) public {
        // Decode the public outputs of the zkVM guest program
        Journal memory journal = abi.decode(journalData, (Journal));
        
        // Validate journal data & Steel commitment to ensure the proof can be trusted
        require(journal.token == token, "Invalid token address");
        require(journal.account == msg.sender, "Invalid sender address");
        require(Steel.validateCommitment(journal.commitment), "Invalid Steel Commitment");

        // Compute the journal hash
        bytes32 journalHash = sha256(journalData);
        
        // Mark the journal as processed to prevent replay
        require(!processedJournals[journalHash], "Journal already processed");
		    processedJournals[journalHash] = true;
		    
        // Verify the execution proof & increment the counter if successful
        verifier.verify(seal, imageID, journalHash);
        ++counter;
    }
}
```

Note that the proof is verified if, and only if, the token address, sender address, and the Steel commitment are valid. Successful verification of the RISC Zero Groth16 proof thus ensures that the account balance is at least one token, so `counter` is incremented without repeating EVM execution.

With on-chain verification in place, the corresponding guest program could look something like this:

```rust
#![no_main]

use alloy_primitives::{Address, U256};
use alloy_sol_types::sol;
use risc0_steel::{
    ethereum::{EthEvmInput, ETH_SEPOLIA_CHAIN_SPEC},
    Commitment, Contract,
};
use risc0_zkvm::guest::env;

risc0_zkvm::guest::entry!(main);

// Specify the function to call using the [`sol!`] macro.
// This parses the Solidity syntax to generate a struct that implements the `SolCall` trait.
sol! {
    interface IERC20 {
        function balanceOf(address account) public view returns (uint);
        function decimals() public view returns (uint);
    }
}

// ABI encodable journal data.
sol! {
    struct Journal {
        Commitment commitment;
        address token;
        address account;
    }
}

fn main() {
    // Load the EVM input, token address, and sender address from the host
    let input: EthEvmInput = env::read();
    let contract: Address = env::read();
    let sender: Address = env::read();

    // Convert the input into an `EvmEnv` for execution, specifying the chain configuration.
    // This checks that the state matches the state root in the header provided in the input.
    let env = input.into_env().with_chain_spec(&ETH_SEPOLIA_CHAIN_SPEC);

    // Execute the view call(s), returning the result in the type(s) generated by the `sol!` macro.
    let balance_call = IERC20::balanceOfCall { sender };
    let decimals_call = IERC20::decimalsCall {};
    let contract = Contract::::new(contract, &env);
    let balance = contract.call_builder(&balance_call).call();
    let decimals = contract.call_builder(&decimals_call).call();

    // Check that the given account holds at least 1 token.
    assert!(balance._0 >= U256::from(decimals));

    // Commit the block hash and number used when deriving `view_call_env` to the journal.
    let journal = Journal {
        commitment: env.into_commitment(),
        token: contract,
        account: sender
    };
    env::commit_slice(&journal.abi_encode());
}
```

While the host program, including the preflight calls to prepare the inputs that are required to execute the functions in the guest without RPC access, could look something like this:

```rust
use alloy_primitives::{address, Address};
use alloy_sol_types::{sol, SolCall, SolType};
use anyhow::{Context, Result};
use clap::Parser;
use erc20_methods::ERC20_GUEST_ELF;
use risc0_steel::{
    ethereum::{EthEvmEnv, ETH_SEPOLIA_CHAIN_SPEC},
    Commitment, Contract,
};
use risc0_zkvm::{default_executor, ExecutorEnv};
use tracing_subscriber::EnvFilter;
use url::Url;

sol! {
    // ERC-20 interface – must match that in the guest.
    interface IERC20 {
        function balanceOf(address account) public view returns (uint);
        function decimals() public view returns (uint);
    }
}

/// Function(s) to call, implements the [SolCall] trait.
const BALANCE_CALL: IERC20::balanceOfCall = IERC20::balanceOfCall {
    account: address!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"), // vitalik.eth
};
const DECIMALS_CALL: IERC20::decimalsCall = IERC20::decimalsCall {};

/// Address of the deployed contract to call the function(s) on (USDT contract on Sepolia).
const CONTRACT: Address = address!("aA8E23Fb1079EA71e0a56F48a2aA51851D8433D0");
/// Address of the caller.
const CALLER: Address = address!("f08A50178dfcDe18524640EA6618a1f965821715");

#[derive(Parser, Debug)]
#[command(about, long_about = None)]
struct Args {
    /// URL of the RPC endpoint
    #[arg(short, long, env = "RPC_URL")]
    rpc_url: Url,
}

#[tokio::main]
async fn main() -> Result<()> {
    // Initialize tracing. To view logs, run `RUST_LOG=info cargo run`
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::from_default_env())
        .init();

    // Parse the command line arguments.
    let args = Args::parse();

    // Create an EVM environment from an RPC endpoint, defaulting to the latest block.
    let mut env = EthEvmEnv::builder().rpc(args.rpc_url).build().await?;
    // The `with_chain_spec` method is used to specify the chain configuration.
    env = env.with_chain_spec(&ETH_SEPOLIA_CHAIN_SPEC);

    // Preflight the call(s) to prepare the input that is required to execute the function in
    // the guest without RPC access. It also returns the result of the call.
    let mut contract = Contract::preflight(CONTRACT, &mut env);
    let balance = contract.call_builder(&BALANCE_CALL).from(CALLER).call().await?;
    println!(
        "Call {} Function by {:#} on {:#} returns: {}",
        IERC20::balanceOfCall::SIGNATURE,
        CALLER,
        CONTRACT,
        returns._0
    );
    let decimals = contract.call_builder(&DECIMALS_CALL).from(CALLER).call().await?;
    println!(
        "Call {} Function by {:#} on {:#} returns: {}",
        IERC20::decimalsCall::SIGNATURE,
        CALLER,
        CONTRACT,
        returns._0
    );

    // Finally, construct the input from the environment.
    let input = env.into_input().await?;

    println!("Running the guest with the constructed input...");
    let session_info = {
        let env = ExecutorEnv::builder()
            .write(&input)
            .unwrap()
            .build()
            .context("failed to build executor env")?;
        let exec = default_executor();
        exec.execute(env, COUNTER_GUEST_ELF)
            .context("failed to run executor")?
    };

    // The journal should be the ABI encoded commitment.
    let commitment = Commitment::abi_decode(session_info.journal.as_ref(), true)
        .context("failed to decode journal")?;
    println!("{:?}", commitment);

    Ok(())
}
```

## Resources

* A glossary of key terminology is available [here](https://dev.risczero.com/terminology).
    
* The `risc0-zkvm` crate can be found [here](https://docs.rs/risc0-zkvm). A full list of Rust crates can be found [here](https://github.com/risc0/risc0#rust-libraries).
    
* Compatibility of various crates with zkVM can be found in the nightly [Crate Validation Report](https://reports.risczero.com/crates-validation).
    
* Several example zkVM applications can be found [here](https://github.com/risc0/risc0/tree/release-1.2/examples), with some leveraging Steel found [here](https://github.com/risc0/risc0-ethereum/tree/main/examples).
    

## Security

* Various audits can be found in the [rz-security](https://github.com/risc0/rz-security/tree/main/audits) repository.
    
* Several security advisories can be found in the [risc0](https://github.com/risc0/risc0/security/advisories) repository.
    
* A bug bounty program can be found on [HackenProof](https://hackenproof.com/programs/risc-zero-zkvm).
    
* A cryptographic security model can be found [here](https://dev.risczero.com/api/security-model), with details of the trusted setup [here](https://dev.risczero.com/api/trusted-setup-ceremony).
