A Brief Introduction to RISC Zero & Steel

Table of contents
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.
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.
The guest program is compiled to an executable format known as the ELF binary.
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
is used to pass private data from the host to the guest.env::write
is used to pass private data from the guest to the host.env::commit
is used to pass public data from the guest to the host.
The executor runs the ELF binary and records the execution trace (aka the session).
The prover checks and proves the validity of the session, producing a cryptographic receipt that attests to the execution of the guest program.
⚠️Proving with private data should be done with local proof generation to ensure it never leaves the machine, as the prover can see all private information involved.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.
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.
Verification of the receipt provides a cryptographic assurance of honest journal creation.
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.
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 and a full example analogous to verifiable encryption with RSA can be found here.
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
.
RISC0_DEV_MODE
out of production environments, it is recommended to build with the disable-dev-mode
feature flag: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:
#![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:
// 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
:
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
:
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.
Steel uses 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.
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 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.
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 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:
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:
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:
#![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(Ð_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:
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(Ð_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.
The
risc0-zkvm
crate can be found here. A full list of Rust crates can be found here.Compatibility of various crates with zkVM can be found in the nightly Crate Validation Report.
Several example zkVM applications can be found here, with some leveraging Steel found here.
Security
Various audits can be found in the rz-security repository.
Several security advisories can be found in the risc0 repository.
A bug bounty program can be found on HackenProof.
A cryptographic security model can be found here, with details of the trusted setup here.
Subscribe to my newsletter
Read articles from Giovanni Di Siena directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
