Skip to main content

EVM at Risc0

· 4 min read

Let's run Solidity code inside SputnikVM inside Risc0.

First make sure you know how Risc0 works. My previous post explains it.

If you want to jump directly to the full code example, it's in the repo.

Solidity

As an example, I have this simple Solidity code. It is a calculator with two functions. One for addition and one for the nth Fibonacci number.

bytecode/Calculator.sol
contract Calculator {
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}

function fibonacci(uint256 n) public returns (uint256) {
if (n <= 1) {
return n;
} else {
return fibonacci(n - 1) + fibonacci(n - 2);
}
}
}

It needs to be compiled into the byte code. solc can do this.

$ solc \
--bin-runtime \
--optimize \
--overwrite \
--evm-version istanbul \
--output-dir bytecode \
bytecode/Calculator.sol

It produces an EVM bytecode in the bytecode directory.

$ ls bytecode/
Calculator.bin-runtime Calculator.sol

EVM

The EVM I used is SputnikVM. Most important it is written in pure Rust and even with no_std mode. This way I can start an in-memory instance of EVM. Then take the bytecode of a contract and install it. Finally, call the contract with arguments and obtain the result value. For now, it's just a Rust code. Risc0 comes later.

The code is based on Sputnik's benchmark test. Huge thanks to Michael Birch for helping with Sputnik. Also make sure how EVM's function selectors work.

evm-runner/src/lib.rs

// Load previously compiled Calculator contract.
pub const CALCULATOR_EVM_PROGRAM: &str = include_str!(
"../../bytecode/Calculator.bin-runtime"
);

// Run Calculator for a given input.
pub fn run_calc_contract(input: &str) -> String {
run_evm(CALCULATOR_EVM_PROGRAM, input)
}

// Run a program (contract) for a given input.
fn run_evm(program: &str, input: &str) -> String {

// Define EVM configuration.
let config = Config::istanbul();
let vicinity = MemoryVicinity {
gas_price: U256::zero(),
origin: H160::default(),
block_hashes: Vec::new(),
block_number: Default::default(),
block_coinbase: Default::default(),
block_timestamp: Default::default(),
block_difficulty: Default::default(),
block_gas_limit: Default::default(),
chain_id: U256::one(),
block_base_fee_per_gas: U256::zero(),
};

// Initialized the state of EVM's memory.
let mut state = BTreeMap::new();

// Add our contract under the 0x10 address.
state.insert(
H160::from_str("0x1000000000000000000000000000000000000000")
.unwrap(),
MemoryAccount {
nonce: U256::one(),
balance: U256::from(10000000),
storage: BTreeMap::new(),
code: hex::decode(program).unwrap(),
}
);

// Add new user 0xf0 that will be used as the contract caller.
state.insert(
H160::from_str("0xf000000000000000000000000000000000000000")
.unwrap(),
MemoryAccount {
nonce: U256::one(),
balance: U256::from(10000000),
storage: BTreeMap::new(),
code: Vec::new(),
},
);

// Prepare the executor.
let backend = MemoryBackend::new(&vicinity, state);
let metadata = StackSubstateMetadata::new(u64::MAX, &config);
let state = MemoryStackState::new(metadata, &backend);
let precompiles = BTreeMap::new();
let mut executor
= StackExecutor::new_with_precompiles(state, &config, &precompiles);

// Call the 0x10 contract using the 0xf0 user.
// Use the input variable.
let (exit_reason, result) = executor.transact_call(
H160::from_str("0xf000000000000000000000000000000000000000")
.unwrap(),
H160::from_str("0x1000000000000000000000000000000000000000")
.unwrap(),
U256::zero(),
hex::decode(input).unwrap(),
u64::MAX,
Vec::new(),
);

// Make sure the execution succeeded.
assert!(exit_reason == ExitReason::Succeed(ExitSucceed::Returned));

// Return hex encoded string.
hex::encode(result)
}

Let's execute it. In below tests the data variable hold two things: function selector and arguments.

For example 61047ff4000000000000000000000000000000000000000000000000000000000000000a is concatination of the function selector (first 8 chars) and 256-bit long argument. It is just fibonacci(10). a is hex of 10 and 37 is hex of 52.

evm-runner/src/lib.rs
#[test]
fn fibonacci_works() {
let data = "61047ff4000000000000000000000000000000000000000000000000000000000000000a";
let result = run_calc_contract(data);
assert_eq!(result, "0000000000000000000000000000000000000000000000000000000000000037");
}

#[test]
fn addition_works() {
let data = "771602f700000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000002";
let result = run_calc_contract(data);
assert_eq!(result, "0000000000000000000000000000000000000000000000000000000000000009");
}

Risc0

It's time for risc0.

First the guest program. It is super simple. It takes a string as an argument, passes it to the run_calc_contract and returns the result.

methods/guest/src/bin/evm_calc.rs
#![no_main]
#![no_std]

extern crate alloc;

use alloc::{string::String};
use risc0_zkvm::guest::{env};
use evm_runner::run_calc_contract;

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

pub fn main() {
let input: String = env::read();
let result = run_calc_contract(&input);
env::commit(&result);
}

The final step is calling it under ZK.

host/src/main.rs
fn main() {
println!("Proving Calculator.add(7, 2)");
let input = "771602f700000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000002";
let result = run_prover(input);
println!("Proof generated. 7 + 2 = {result}");

println!("Proving Calculator.fibonacci(4)");
let input = "61047ff40000000000000000000000000000000000000000000000000000000000000004";
let result = run_prover(input);
println!("Proof generated. fibonacci(4) = {result}");
}

fn run_prover(input: &str) -> u32 {
// Make the prover.
let method_code = std::fs::read(EVM_CALC_PATH).unwrap();
let mut prover = Prover::new(&method_code, EVM_CALC_ID).unwrap();

// Push the input as an argument.
prover.add_input_u32_slice(to_vec(input).unwrap().as_slice());

// Execute the prover.
let receipt = prover.run().unwrap();

// Verify the proof.
assert!(receipt.verify(EVM_CALC_ID).is_ok());

// Return result as an u32 value.
let result: String = from_slice(receipt.journal.as_slice()).unwrap();
u32::from_str_radix(&result, 16).unwrap()
}
$ cargo run --release -p host
Proving Calculator.add(7, 2)
Proof generated. 7 + 2 = 9
Proving Calculator.fibonacci(4)
Proof generated. fibonacci(4) = 3

Conclusion

How amazing and mindblowing it is! Of course, it's just a proof of concept. Yet with further development of Risc0 improving its proving time and with more flexible SputnikVM this approach is more than promising.

Join us

Interested?

Join our Discord, our Twitter or write us at contact@odra.dev.