diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7a924f5d..5aa0b2e2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -154,12 +154,19 @@ jobs: - name: build erc20-Counter run: cargo build working-directory: examples/erc20-counter + - name: forge test erc20-Counter + run: forge test + working-directory: examples/erc20-counter + env: + ETH_RPC_URL: https://ethereum-sepolia-rpc.publicnode.com - name: build token-stats run: cargo build working-directory: examples/token-stats - name: test erc20-Counter run: ./test-local-deployment.sh working-directory: examples/erc20-counter + env: + RISC0_DEV_MODE: true - run: sccache --show-stats doc: diff --git a/Cargo.toml b/Cargo.toml index d80aa0de..c3e05ff8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,10 @@ alloy-sol-types = { version = "0.7" } alloy = { version = "0.2.1", features = ["full"] } alloy-trie = { version = "0.4.0" } +# Beacon chain support +beacon-api-client = { git = "https://github.com/ralexstokes/ethereum-consensus.git", rev = "cf3c404043230559660810bc0c9d6d5a8498d819" } +ethereum-consensus = { git = "https://github.com/ralexstokes/ethereum-consensus.git", rev = "cf3c404043230559660810bc0c9d6d5a8498d819" } + anyhow = { version = "1.0" } bincode = { version = "1.3" } clap = { version = "4.5", features = ["derive", "env"] } @@ -41,6 +45,7 @@ once_cell = "1.19" revm = { version = "13.0", default-features = false, features = ["std"] } serde = "1.0" serde_json = "1.0" +sha2 = { version = "0.10" } test-log = "0.2.15" tokio = { version = "1.35" } tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/contracts/src/steel/Steel.sol b/contracts/src/steel/Steel.sol index 7fd91b6c..0cd6ef0a 100644 --- a/contracts/src/steel/Steel.sol +++ b/contracts/src/steel/Steel.sol @@ -19,16 +19,107 @@ pragma solidity ^0.8.9; /// @title Steel Library /// @notice This library provides a collection of utilities to work with Steel commitments in Solidity. library Steel { - /// @notice A Commitment struct representing a block number and its block hash. + /// @notice Represents a commitment to a specific block in the blockchain. + /// @dev The `blockID` encodes both the block identifier (block number or timestamp) and the version. + /// @dev The `blockDigest` is the block hash or beacon block root, used for validation. struct Commitment { - uint256 blockNumber; // Block number at which the commitment was made. - bytes32 blockHash; // Hash of the block at the specified block number. + uint256 blockID; + bytes32 blockDigest; } + /// @notice The version of the Commitment is incorrect. + error InvalidCommitmentVersion(); + + /// @notice The Commitment is too old and can no longer be validated. + error CommitmentTooOld(); + /// @notice Validates if the provided Commitment matches the block hash of the given block number. /// @param commitment The Commitment struct to validate. - /// @return isValid True if the commitment's block hash matches the block hash of the block number, false otherwise. - function validateCommitment(Commitment memory commitment) internal view returns (bool isValid) { - return commitment.blockHash == blockhash(commitment.blockNumber); + /// @return True if the commitment's block hash matches the block hash of the block number, false otherwise. + function validateCommitment(Commitment memory commitment) internal view returns (bool) { + (uint240 blockID, uint16 version) = Encoding.decodeVersionedID(commitment.blockID); + if (version == 0) { + return validateBlockCommitment(blockID, commitment.blockDigest); + } else if (version == 1) { + return validateBeaconCommitment(blockID, commitment.blockDigest); + } else { + revert InvalidCommitmentVersion(); + } + } + + /// @notice Validates if the provided block commitment matches the block hash of the given block number. + /// @param blockNumber The block number to compare against. + /// @param blockHash The block hash to validate. + /// @return True if the block's block hash matches the block hash, false otherwise. + function validateBlockCommitment(uint256 blockNumber, bytes32 blockHash) internal view returns (bool) { + if (block.number - blockNumber > 256) { + revert CommitmentTooOld(); + } + return blockHash == blockhash(blockNumber); + } + + /// @notice Validates if the provided beacon commitment matches the block root of the given timestamp. + /// @param blockTimestamp The timestamp to compare against. + /// @param blockRoot The block root to validate. + /// @return True if the block's block root matches the block root, false otherwise. + function validateBeaconCommitment(uint256 blockTimestamp, bytes32 blockRoot) internal view returns (bool) { + if (block.timestamp - blockTimestamp > 12 * 8191) { + revert CommitmentTooOld(); + } + return blockRoot == Beacon.blockRoot(blockTimestamp); + } +} + +/// @title Beacon Library +library Beacon { + /// @notice The address of the Beacon roots contract. + /// @dev https://eips.ethereum.org/EIPS/eip-4788 + address internal constant BEACON_ROOTS_ADDRESS = 0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02; + + /// @notice The Beacon block root could not be found as the next block has not been issued yet. + error NoParentBeaconBlock(); + + /// @notice Attempts to find the root of the Beacon block with the given timestamp. + /// @dev Since the Beacon roots contract only returns the parent Beacon block’s root, we need to find the next + /// Beacon block instead. This is done by adding the block time of 12s until a value is returned. + function blockRoot(uint256 timestamp) internal view returns (bytes32 root) { + uint256 blockTimestamp = block.timestamp; + while (true) { + timestamp += 12; // Beacon block time is 12 seconds + if (timestamp > blockTimestamp) revert NoParentBeaconBlock(); + + (bool success, bytes memory result) = BEACON_ROOTS_ADDRESS.staticcall(abi.encode(timestamp)); + if (success) { + return abi.decode(result, (bytes32)); + } + } + } +} + +/// @title Encoding Library +library Encoding { + /// @notice Encodes a version and ID into a single uint256 value. + /// @param id The base ID to be encoded, limited by 240 bits (or the maximum value of a uint240). + /// @param version The version number to be encoded, limited by 16 bits (or the maximum value of a uint16). + /// @return Returns a single uint256 value that contains both the `id` and the `version` encoded into it. + function encodeVersionedID(uint240 id, uint16 version) internal pure returns (uint256) { + uint256 encoded; + assembly { + encoded := or(shl(240, version), id) + } + return encoded; + } + + /// @notice Decodes a version and ID from a single uint256 value. + /// @param id The single uint256 value to be decoded. + /// @return Returns two values: a uint240 for the original base ID and a uint16 for the version number encoded into it. + function decodeVersionedID(uint256 id) internal pure returns (uint240, uint16) { + uint240 decoded; + uint16 version; + assembly { + decoded := and(id, 0x0000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff) + version := shr(240, id) + } + return (decoded, version); } } diff --git a/examples/README.md b/examples/README.md index ce79b14a..18c0e779 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,7 +16,6 @@ Explore a more advanced interaction between [Steel] and a custom Ethereum smart This example shows how the [Steel] library can be used to call multiple view functions of a contract. This example generates a proof of a [Compound] cToken's APR (Annual Percentage Rate), showcasing the potential for on-chain verification of complex financial metrics. -[Counter]: ./erc20-counter/contracts/Counter.sol [coprocessor]: https://www.risczero.com/news/a-guide-to-zk-coprocessors-for-scalability [Steel]: ../steel [Compound]: https://compound.finance/ diff --git a/examples/erc20-counter/.env b/examples/erc20-counter/.env new file mode 100644 index 00000000..caa2304c --- /dev/null +++ b/examples/erc20-counter/.env @@ -0,0 +1 @@ +ETH_RPC_URL="https://ethereum-rpc.publicnode.com" diff --git a/examples/erc20-counter/.gitignore b/examples/erc20-counter/.gitignore index 1292191a..f547a208 100644 --- a/examples/erc20-counter/.gitignore +++ b/examples/erc20-counter/.gitignore @@ -12,11 +12,8 @@ out/ anvil_logs.txt # Autogenerated contracts -contracts/ImageID.sol -contracts/Elf.sol - -# Dotenv file -.env +contracts/src/ImageID.sol +contracts/src/Elf.sol # Cargo target/ diff --git a/examples/erc20-counter/Cargo.toml b/examples/erc20-counter/Cargo.toml index b148b208..e1e37659 100644 --- a/examples/erc20-counter/Cargo.toml +++ b/examples/erc20-counter/Cargo.toml @@ -27,6 +27,7 @@ bytemuck = { version = "1.14" } clap = { version = "4.5" } hex = { version = "0.4" } erc20-counter-methods = { path = "./methods" } +log = { version = "0.4" } serde = { version = "1.0", features = ["derive", "std"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio = { version = "1.39", features = ["full"] } diff --git a/examples/erc20-counter/README.md b/examples/erc20-counter/README.md index 4a853afb..8df768ae 100644 --- a/examples/erc20-counter/README.md +++ b/examples/erc20-counter/README.md @@ -1,4 +1,4 @@ -# RISC Zero View Call Proofs ERC20-Counter Example +# ERC20-Counter Example This example implements a counter that increments based on off-chain RISC Zero [Steel] proofs submitted to the [Counter] contract. The contract interacts with ERC-20 tokens, using [Steel] proofs to verify that an account holds at least 1 token before incrementing the counter. @@ -35,6 +35,7 @@ The contract includes functionality to query the current value of the counter at ## Dependencies To get started, you need to have the following installed: + - [Rust] - [Foundry] - [RISC Zero] @@ -59,7 +60,6 @@ When you're ready, follow the [deployment guide] to get your application running [Groth16 SNARK proof]: https://www.risczero.com/news/on-chain-verification [RISC Zero]: https://dev.risczero.com/api/zkvm/install [Sepolia]: https://www.alchemy.com/overviews/sepolia-testnet -[cargo-binstall]: https://github.com/cargo-bins/cargo-binstall#cargo-binaryinstall [deployment guide]: ./deployment-guide.md [Rust]: https://doc.rust-lang.org/cargo/getting-started/installation.html [Counter]: ./contracts/Counter.sol diff --git a/examples/erc20-counter/apps/Cargo.toml b/examples/erc20-counter/apps/Cargo.toml index 747f179b..bb185958 100644 --- a/examples/erc20-counter/apps/Cargo.toml +++ b/examples/erc20-counter/apps/Cargo.toml @@ -9,6 +9,7 @@ alloy-primitives = { workspace = true } anyhow = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } erc20-counter-methods = { workspace = true } +log = { workspace = true } risc0-ethereum-contracts = { workspace = true } risc0-steel = { workspace = true, features = ["host"] } risc0-zkvm = { workspace = true, features = ["client"] } diff --git a/examples/erc20-counter/apps/README.md b/examples/erc20-counter/apps/README.md index 08f8c22c..9f568427 100644 --- a/examples/erc20-counter/apps/README.md +++ b/examples/erc20-counter/apps/README.md @@ -15,24 +15,23 @@ cargo run --bin publisher ```text $ cargo run --bin publisher -- --help -Usage: publisher --eth-wallet-private-key --rpc-url --contract --token --account +Usage: publisher [OPTIONS] --eth-wallet-private-key --eth-rpc-url --counter --token-contract --account Options: --eth-wallet-private-key - Ethereum Node endpoint [env: ETH_WALLET_PRIVATE_KEY=] - --rpc-url - Ethereum Node endpoint [env: RPC_URL=] - --contract - Counter's contract address on Ethereum - --token - ERC20 contract address on Ethereum + Private key [env: ETH_WALLET_PRIVATE_KEY=] + --eth-rpc-url + Ethereum RPC endpoint URL [env: ETH_RPC_URL=] + --beacon-api-url + Beacon API endpoint URL [env: BEACON_API_URL=] + --counter + Address of the Counter verifier + --token-contract + Address of the ERC20 token contract [env: TOKEN_CONTRACT=] --account - Account address to read the balance_of on Ethereum + Address to query the token balance of -h, --help - Print help - -V, --version - Print version -``` + Print help``` [publisher]: ./src/bin/publisher.rs [Counter]: ../contracts/Counter.sol diff --git a/examples/erc20-counter/apps/src/bin/publisher.rs b/examples/erc20-counter/apps/src/bin/publisher.rs index 47bcb6ab..07eb7b54 100644 --- a/examples/erc20-counter/apps/src/bin/publisher.rs +++ b/examples/erc20-counter/apps/src/bin/publisher.rs @@ -16,61 +16,72 @@ // to the Bonsai proving service and publish the received proofs directly // to your deployed app contract. -use std::time::Duration; - use alloy::{ - network::EthereumWallet, providers::ProviderBuilder, signers::local::PrivateKeySigner, - sol_types::SolCall, + network::EthereumWallet, + providers::ProviderBuilder, + signers::local::PrivateKeySigner, + sol_types::{SolCall, SolValue}, }; use alloy_primitives::Address; use anyhow::{ensure, Context, Result}; use clap::Parser; -use erc20_counter_methods::BALANCE_OF_ELF; +use erc20_counter_methods::{BALANCE_OF_ELF, BALANCE_OF_ID}; use risc0_ethereum_contracts::encode_seal; use risc0_steel::{ ethereum::{EthEvmEnv, ETH_SEPOLIA_CHAIN_SPEC}, host::BlockNumberOrTag, - Contract, + Commitment, Contract, }; -use risc0_zkvm::{default_prover, ExecutorEnv, ProverOpts, VerifierContext}; +use risc0_zkvm::{default_prover, sha::Digest, ExecutorEnv, ProverOpts, VerifierContext}; use tokio::task; use tracing_subscriber::EnvFilter; use url::Url; alloy::sol! { - /// ERC-20 balance function signature. - /// This must match the signature in the guest. + /// Interface to be called by the guest. interface IERC20 { function balanceOf(address account) external view returns (uint); } + + /// Data committed to by the guest. + struct Journal { + Commitment commitment; + address tokenContract; + } } alloy::sol!( - #[sol(rpc)] - "../contracts/ICounter.sol" + #[sol(rpc, all_derives)] + "../contracts/src/ICounter.sol" ); -/// Arguments of the publisher CLI. +/// Simple program to create a proof to increment the Counter contract. #[derive(Parser, Debug)] -#[clap(author, version, about, long_about = None)] struct Args { - /// Ethereum Node endpoint. + /// Private key #[clap(long, env)] eth_wallet_private_key: PrivateKeySigner, - /// Ethereum Node endpoint. + /// Ethereum RPC endpoint URL #[clap(long, env)] - rpc_url: Url, + eth_rpc_url: Url, - /// Counter's contract address on Ethereum - #[clap(long)] - contract: Address, + /// Optional Beacon API endpoint URL + /// + /// When provided, Steel uses a beacon block commitment instead of the execution block. This + /// allows proofs to be validated using the EIP-4788 beacon roots contract. + #[clap(long, env)] + beacon_api_url: Option, - /// ERC20 contract address on Ethereum + /// Address of the Counter verifier #[clap(long)] - token: Address, + counter: Address, + + /// Address of the ERC20 token contract + #[clap(long, env)] + token_contract: Address, - /// Account address to read the balance_of on Ethereum + /// Address to query the token balance of #[clap(long)] account: Address, } @@ -82,14 +93,14 @@ async fn main() -> Result<()> { .with_env_filter(EnvFilter::from_default_env()) .init(); // Parse the command line arguments. - let args = Args::parse(); + let args = Args::try_parse()?; // Create an alloy provider for that private key and URL. let wallet = EthereumWallet::from(args.eth_wallet_private_key); let provider = ProviderBuilder::new() .with_recommended_fillers() .wallet(wallet) - .on_http(args.rpc_url); + .on_http(args.eth_rpc_url); // Create an EVM environment from that provider and a block number. let mut env = EthEvmEnv::from_provider(provider.clone(), BlockNumberOrTag::Latest).await?; @@ -103,23 +114,29 @@ async fn main() -> Result<()> { // Preflight the call 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(args.token, &mut env); + let mut contract = Contract::preflight(args.token_contract, &mut env); let returns = contract.call_builder(&call).call().await?; println!( "Call {} Function on {:#} returns: {}", IERC20::balanceOfCall::SIGNATURE, - args.token, + args.token_contract, returns._0 ); // Finally, construct the input from the environment. - let view_call_input = env.into_input().await?; + // There are two options: Use EIP-4788 for verification by providing a Beacon API endpoint, + // or use the regular `blockhash' opcode. + let evm_input = if let Some(beacon_api_url) = args.beacon_api_url { + env.into_beacon_input(beacon_api_url).await? + } else { + env.into_input().await? + }; println!("Creating proof for the constructed input..."); let prove_info = task::spawn_blocking(move || { let env = ExecutorEnv::builder() - .write(&view_call_input)? - .write(&args.token)? + .write(&evm_input)? + .write(&args.token_contract)? .write(&args.account)? .build() .unwrap(); @@ -134,10 +151,24 @@ async fn main() -> Result<()> { .await? .context("failed to create proof")?; let receipt = prove_info.receipt; + let journal = &receipt.journal.bytes; + + // Decode and log the commitment + let journal = Journal::abi_decode(journal, true).context("invalid journal")?; + println!( + "The guest committed to block {}", + journal.commitment.blockDigest + ); + + // ABI encode the seal. let seal = encode_seal(&receipt)?; // Create an alloy instance of the Counter contract. - let contract = ICounter::new(args.contract, provider); + let contract = ICounter::new(args.counter, provider); + + // Call ICounter::imageID() to check that the contract has been deployed correctly. + let contract_image_id = Digest::from(contract.imageID().call().await?._0.0); + ensure!(contract_image_id == Digest::from(BALANCE_OF_ID)); // Call the increment function of the contract and wait for confirmation. println!( @@ -146,11 +177,9 @@ async fn main() -> Result<()> { contract.address() ); let call_builder = contract.increment(receipt.journal.bytes.into(), seal.into()); + log::debug!("Send {} {}", contract.address(), call_builder.calldata()); let pending_tx = call_builder.send().await?; - let receipt = pending_tx - .with_timeout(Some(Duration::from_secs(60))) - .get_receipt() - .await?; + let receipt = pending_tx.get_receipt().await?; ensure!(receipt.status(), "transaction failed"); let value = contract.get().call().await?._0; diff --git a/examples/erc20-counter/contracts/ERC20.sol b/examples/erc20-counter/contracts/ERC20.sol deleted file mode 100644 index 8e03603f..00000000 --- a/examples/erc20-counter/contracts/ERC20.sol +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IERC20} from "openzeppelin-contracts/token/ERC20/IERC20.sol"; - -contract ERC20 is IERC20 { - uint256 public totalSupply; - mapping(address => uint256) public balanceOf; - mapping(address => mapping(address => uint256)) public allowance; - string public name; - string public symbol; - uint8 public decimals; - - constructor(string memory _name, string memory _symbol, uint8 _decimals) { - name = _name; - symbol = _symbol; - decimals = _decimals; - } - - function transfer(address recipient, uint256 amount) external returns (bool) { - balanceOf[msg.sender] -= amount; - balanceOf[recipient] += amount; - emit Transfer(msg.sender, recipient, amount); - return true; - } - - function approve(address spender, uint256 amount) external returns (bool) { - allowance[msg.sender][spender] = amount; - emit Approval(msg.sender, spender, amount); - return true; - } - - function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) { - allowance[sender][msg.sender] -= amount; - balanceOf[sender] -= amount; - balanceOf[recipient] += amount; - emit Transfer(sender, recipient, amount); - return true; - } - - function _mint(address to, uint256 amount) internal { - balanceOf[to] += amount; - totalSupply += amount; - emit Transfer(address(0), to, amount); - } - - function _burn(address from, uint256 amount) internal { - balanceOf[from] -= amount; - totalSupply -= amount; - emit Transfer(from, address(0), amount); - } - - function mint(address to, uint256 amount) external { - _mint(to, amount); - } - - function burn(address from, uint256 amount) external { - _burn(from, amount); - } -} diff --git a/examples/erc20-counter/script/DeployCounter.s.sol b/examples/erc20-counter/contracts/script/DeployCounter.s.sol similarity index 85% rename from examples/erc20-counter/script/DeployCounter.s.sol rename to examples/erc20-counter/contracts/script/DeployCounter.s.sol index b4159688..1cced212 100644 --- a/examples/erc20-counter/script/DeployCounter.s.sol +++ b/examples/erc20-counter/contracts/script/DeployCounter.s.sol @@ -20,9 +20,8 @@ import {Script} from "forge-std/Script.sol"; import {console2} from "forge-std/console2.sol"; import {IRiscZeroVerifier} from "risc0/IRiscZeroVerifier.sol"; import {RiscZeroCheats} from "risc0/test/RiscZeroCheats.sol"; - -import {Counter} from "../contracts/Counter.sol"; -import {ERC20} from "../contracts/ERC20.sol"; +import {Counter} from "../src/Counter.sol"; +import {ERC20FixedSupply} from "../test/Counter.t.sol"; /// @notice Deployment script for the Counter contract. /// @dev Use the following environment variable to control the deployment: @@ -30,13 +29,14 @@ import {ERC20} from "../contracts/ERC20.sol"; /// /// See the Foundry documentation for more information about Solidity scripts. /// https://book.getfoundry.sh/tutorials/solidity-scripting -contract CounterDeploy is Script, RiscZeroCheats { +contract DeployCounter is Script, RiscZeroCheats { function run() external { uint256 deployerKey = uint256(vm.envBytes32("ETH_WALLET_PRIVATE_KEY")); + address tokenOwner = vm.envAddress("TOKEN_OWNER"); vm.startBroadcast(deployerKey); - ERC20 toyken = new ERC20("TOYKEN", "TOY", 0); + ERC20FixedSupply toyken = new ERC20FixedSupply("TOYKEN", "TOY", tokenOwner); console2.log("Deployed ERC20 TOYKEN to", address(toyken)); IRiscZeroVerifier verifier = deployRiscZeroVerifier(); diff --git a/examples/erc20-counter/contracts/Counter.sol b/examples/erc20-counter/contracts/src/Counter.sol similarity index 89% rename from examples/erc20-counter/contracts/Counter.sol rename to examples/erc20-counter/contracts/src/Counter.sol index 808f6d16..34ed51b9 100644 --- a/examples/erc20-counter/contracts/Counter.sol +++ b/examples/erc20-counter/contracts/src/Counter.sol @@ -27,13 +27,13 @@ import {ImageID} from "./ImageID.sol"; // auto-generated contract after running /// before incrementing the counter. This contract leverages RISC0-zkVM for generating and verifying these proofs. contract Counter is ICounter { /// @notice Image ID of the only zkVM binary to accept verification from. - bytes32 public constant imageId = ImageID.BALANCE_OF_ID; + bytes32 public constant imageID = ImageID.BALANCE_OF_ID; /// @notice RISC Zero verifier contract address. IRiscZeroVerifier public immutable verifier; /// @notice Address of the ERC-20 token contract. - address public immutable tokenAddress; + address public immutable tokenContract; /// @notice Counter to track the number of successful verifications. uint256 public counter; @@ -41,13 +41,13 @@ contract Counter is ICounter { /// @notice Journal that is committed to by the guest. struct Journal { Steel.Commitment commitment; - address tokenAddress; + address tokenContract; } /// @notice Initialize the contract, binding it to a specified RISC Zero verifier and ERC-20 token address. constructor(IRiscZeroVerifier _verifier, address _tokenAddress) { verifier = _verifier; - tokenAddress = _tokenAddress; + tokenContract = _tokenAddress; counter = 0; } @@ -55,12 +55,12 @@ contract Counter is ICounter { function increment(bytes calldata journalData, bytes calldata seal) external { // Decode and validate the journal data Journal memory journal = abi.decode(journalData, (Journal)); - require(journal.tokenAddress == tokenAddress, "Invalid token address"); + require(journal.tokenContract == tokenContract, "Invalid token address"); require(Steel.validateCommitment(journal.commitment), "Invalid commitment"); // Verify the proof bytes32 journalHash = sha256(journalData); - verifier.verify(seal, imageId, journalHash); + verifier.verify(seal, imageID, journalHash); counter += 1; } diff --git a/examples/erc20-counter/contracts/ICounter.sol b/examples/erc20-counter/contracts/src/ICounter.sol similarity index 90% rename from examples/erc20-counter/contracts/ICounter.sol rename to examples/erc20-counter/contracts/src/ICounter.sol index 3e01396e..73b09385 100644 --- a/examples/erc20-counter/contracts/ICounter.sol +++ b/examples/erc20-counter/contracts/src/ICounter.sol @@ -23,4 +23,7 @@ interface ICounter { /// @notice Returns the value of the counter. function get() external view returns (uint256); + + /// @notice Returns the image ID used for verification. + function imageID() external view returns (bytes32); } diff --git a/examples/erc20-counter/contracts/test/Counter.t.sol b/examples/erc20-counter/contracts/test/Counter.t.sol new file mode 100644 index 00000000..51ab4f32 --- /dev/null +++ b/examples/erc20-counter/contracts/test/Counter.t.sol @@ -0,0 +1,93 @@ +// Copyright 2024 RISC Zero, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import {Receipt as RiscZeroReceipt} from "risc0/IRiscZeroVerifier.sol"; +import {RiscZeroMockVerifier} from "risc0/test/RiscZeroMockVerifier.sol"; +import {Counter} from "../src/Counter.sol"; +import {Steel, Beacon, Encoding} from "risc0/steel/Steel.sol"; +import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol"; + +contract ERC20FixedSupply is ERC20 { + constructor(string memory name, string memory symbol, address owner) ERC20(name, symbol) { + _mint(owner, 1000); + } +} + +contract CounterTest is Test { + bytes4 constant MOCK_SELECTOR = bytes4(0); + + RiscZeroMockVerifier private verifier; + ERC20 private token; + Counter private counter; + bytes32 private imageId; + + function setUp() public { + // fork from the actual chain to get realistic Beacon block roots + string memory RPC_URL = vm.envString("ETH_RPC_URL"); + vm.createSelectFork(RPC_URL); + + verifier = new RiscZeroMockVerifier(MOCK_SELECTOR); + token = new ERC20FixedSupply("TOYKEN", "TOY", address(0x01)); + counter = new Counter(verifier, address(token)); + imageId = counter.imageID(); + } + + function testCommitment() public { + // get the hash of the previous block + uint240 blockNumber = uint240(block.number - 1); + bytes32 blockHash = blockhash(blockNumber); + + // mock the Journal + Counter.Journal memory journal = Counter.Journal({ + commitment: Steel.Commitment(Encoding.encodeVersionedID(blockNumber, 0), blockHash), + tokenContract: address(token) + }); + // create a mock proof + RiscZeroReceipt memory receipt = verifier.mockProve(imageId, sha256(abi.encode(journal))); + + uint256 previous_count = counter.get(); + + counter.increment(abi.encode(journal), receipt.seal); + + // check that the counter was incremented + assert(counter.get() == previous_count + 1); + } + + function testEIP4788Commitment() public { + // get the root of a previous Beacon block + uint240 beaconTimestamp = uint240(block.timestamp - 60); + bytes32 beaconRoot = Beacon.blockRoot(beaconTimestamp); + + // mock the Journal + Counter.Journal memory journal = Counter.Journal({ + commitment: Steel.Commitment(Encoding.encodeVersionedID(beaconTimestamp, 1), beaconRoot), + tokenContract: address(token) + }); + // create a mock proof + RiscZeroReceipt memory receipt = verifier.mockProve(imageId, sha256(abi.encode(journal))); + + uint256 previous_count = counter.get(); + + counter.increment(abi.encode(journal), receipt.seal); + + // check that the counter was incremented + assert(counter.get() == previous_count + 1); + } +} diff --git a/examples/erc20-counter/foundry.toml b/examples/erc20-counter/foundry.toml index 0d3ed72f..0cf40fb2 100644 --- a/examples/erc20-counter/foundry.toml +++ b/examples/erc20-counter/foundry.toml @@ -1,8 +1,7 @@ [profile.default] -src = "contracts" +src = "contracts/src" out = "out" libs = ["../../lib", "../../contracts/src"] -test = "tests" -ffi = true - -# See more config options https://github.com/foundry-rs/foundry/tree/master/config +test = "contracts/test" +script = "contracts/script" +evm_version = 'cancun' diff --git a/examples/erc20-counter/methods/build.rs b/examples/erc20-counter/methods/build.rs index 5ec9fee9..99e81a93 100644 --- a/examples/erc20-counter/methods/build.rs +++ b/examples/erc20-counter/methods/build.rs @@ -18,8 +18,8 @@ use risc0_build::{embed_methods_with_options, DockerOptions, GuestOptions}; use risc0_build_ethereum::generate_solidity_files; // Paths where the generated Solidity files will be written. -const SOLIDITY_IMAGE_ID_PATH: &str = "../contracts/ImageID.sol"; -const SOLIDITY_ELF_PATH: &str = "../contracts/Elf.sol"; +const SOLIDITY_IMAGE_ID_PATH: &str = "../contracts/src/ImageID.sol"; +const SOLIDITY_ELF_PATH: &str = "../contracts/src/Elf.sol"; fn main() { // Builds can be made deterministic, and thereby reproducible, by using Docker to build the diff --git a/examples/erc20-counter/methods/guest/src/bin/balance_of.rs b/examples/erc20-counter/methods/guest/src/bin/balance_of.rs index a052b03d..6f1ce2c7 100644 --- a/examples/erc20-counter/methods/guest/src/bin/balance_of.rs +++ b/examples/erc20-counter/methods/guest/src/bin/balance_of.rs @@ -19,7 +19,7 @@ use alloy_primitives::{Address, U256}; use alloy_sol_types::{sol, SolValue}; use risc0_steel::{ ethereum::{EthEvmInput, ETH_SEPOLIA_CHAIN_SPEC}, - Contract, SolCommitment, + Commitment, Contract, }; use risc0_zkvm::guest::env; @@ -37,7 +37,7 @@ sol! { /// ABI encodable journal data. sol! { struct Journal { - SolCommitment commitment; + Commitment commitment; address tokenAddress; } } diff --git a/examples/erc20-counter/rust-toolchain.toml b/examples/erc20-counter/rust-toolchain.toml index 6c18dfcd..cb78e29e 100644 --- a/examples/erc20-counter/rust-toolchain.toml +++ b/examples/erc20-counter/rust-toolchain.toml @@ -1,4 +1,5 @@ [toolchain] -channel = "stable" -components = ["rustfmt", "rust-src"] +channel = "1.79" +components = ["clippy", "rustfmt", "rust-src"] +targets = [] profile = "minimal" \ No newline at end of file diff --git a/examples/erc20-counter/test-local-deployment.sh b/examples/erc20-counter/test-local-deployment.sh index 9caba335..cd78a315 100755 --- a/examples/erc20-counter/test-local-deployment.sh +++ b/examples/erc20-counter/test-local-deployment.sh @@ -24,6 +24,7 @@ echo "Anvil started with PID $ANVIL_PID" sleep 5 export ETH_WALLET_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +export TOKEN_OWNER=0x9737100D2F42a196DE56ED0d1f6fF598a250E7E4 # Build the project echo "Building the project..." @@ -31,16 +32,12 @@ cargo build # Deploy the Counter contract echo "Deploying the Counter contract..." -forge script --rpc-url http://localhost:8545 --broadcast script/DeployCounter.s.sol +forge script --rpc-url http://localhost:8545 --broadcast DeployCounter # Extract the Toyken address -export TOYKEN_ADDRESS=$(jq -re '.transactions[] | select(.contractName == "ERC20") | .contractAddress' ./broadcast/DeployCounter.s.sol/31337/run-latest.json) +export TOYKEN_ADDRESS=$(jq -re '.transactions[] | select(.contractName == "ERC20FixedSupply") | .contractAddress' ./broadcast/DeployCounter.s.sol/31337/run-latest.json) echo "ERC20 Toyken Address: $TOYKEN_ADDRESS" -# Mint Toyken to a specific address -echo "Minting Toyken to 0x9737100D2F42a196DE56ED0d1f6fF598a250E7E4..." -cast send --private-key $ETH_WALLET_PRIVATE_KEY --rpc-url http://localhost:8545 $TOYKEN_ADDRESS 'mint(address, uint256)' 0x9737100D2F42a196DE56ED0d1f6fF598a250E7E4 100 - # Extract the Counter contract address export COUNTER_ADDRESS=$(jq -re '.transactions[] | select(.contractName == "Counter") | .contractAddress' ./broadcast/DeployCounter.s.sol/31337/run-latest.json) echo "Counter Address: $COUNTER_ADDRESS" @@ -48,10 +45,10 @@ echo "Counter Address: $COUNTER_ADDRESS" # Publish a new state echo "Publishing a new state..." cargo run --bin publisher -- \ - --rpc-url=http://localhost:8545 \ - --contract=${COUNTER_ADDRESS:?} \ - --token=${TOYKEN_ADDRESS:?} \ - --account=0x9737100D2F42a196DE56ED0d1f6fF598a250E7E4 + --eth-rpc-url=http://localhost:8545 \ + --counter=${COUNTER_ADDRESS:?} \ + --token-contract=${TOYKEN_ADDRESS:?} \ + --account=${TOKEN_OWNER:?} # Attempt to verify counter value as part of the script logic echo "Verifying state..." diff --git a/examples/token-stats/core/src/lib.rs b/examples/token-stats/core/src/lib.rs index b82cc32f..244384e0 100644 --- a/examples/token-stats/core/src/lib.rs +++ b/examples/token-stats/core/src/lib.rs @@ -14,7 +14,7 @@ use alloy_primitives::{address, Address}; use alloy_sol_types::sol; -use risc0_steel::SolCommitment; +use risc0_steel::Commitment; /// Address of Compound USDC (cUSDCv3) token. pub const CONTRACT: Address = address!("c3d688B66703497DAA19211EEdff47f25384cdc3"); @@ -29,7 +29,7 @@ sol! { sol! { struct APRCommitment { - SolCommitment commitment; + Commitment commitment; uint64 annualSupplyRate; } } diff --git a/steel/CHANGELOG.md b/steel/CHANGELOG.md index 427f3132..e3453419 100644 --- a/steel/CHANGELOG.md +++ b/steel/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file. ### ⚡️ Features +- Add support for creating a commitment to a beacon block root using `EvmEnv::into_beacon_input`, which can be verified using the [EIP-4788](https://eips.ethereum.org/EIPS/eip-4788) beacon roots contract. + +### 🚨 Breaking Changes + +- `EvmInput` has been changed to an `enum` to support different input types for the guest, such as the new `BeaconInput`. This changes the binary input data, but does not require any code changes. +- `SolCommitment` has been renamed to `Commitment`. + +## [0.12.0](https://github.com/risc0/risc0-ethereum/releases/tag/steel-v0.12.0) - 2024-08-09 + +### ⚡️ Features + - Replace `ethers` dependency completely with `alloy`. - Make `host` functions `async`. - Add support to build `EvmEnv` from any `alloy` provider. @@ -42,4 +53,4 @@ let returns = contract.call_builder(&CALL).from(CALLER).call().await?; let input = env.into_input().await?; ``` -## [0.11.1](https://github.com/risc0/risc0-ethereum/releases/tag/steel-v0.11.1) - 2024-06-25 \ No newline at end of file +## [0.11.1](https://github.com/risc0/risc0-ethereum/releases/tag/steel-v0.11.1) - 2024-06-25 diff --git a/steel/Cargo.toml b/steel/Cargo.toml index d963c438..e9ebc0d7 100644 --- a/steel/Cargo.toml +++ b/steel/Cargo.toml @@ -19,12 +19,14 @@ alloy-rlp = { workspace = true } alloy-rlp-derive = { workspace = true } alloy-sol-types = { workspace = true } anyhow = { workspace = true } +beacon-api-client = { workspace = true, optional = true } +ethereum-consensus = { workspace = true, optional = true } log = { workspace = true, optional = true } nybbles = { workspace = true } once_cell = { workspace = true } revm = { workspace = true, features = ["serde"] } serde = { workspace = true } -serde_json = { workspace = true, optional = true } +sha2 = { workspace = true } tokio = { workspace = true, optional = true } url = { workspace = true, optional = true } @@ -33,8 +35,16 @@ alloy = { workspace = true, features = ["node-bindings"] } alloy-trie = { workspace = true } bincode = { workspace = true } risc0-steel = { path = ".", features = ["host"] } +serde_json = { workspace = true } test-log = { workspace = true } [features] default = [] -host = ["dep:alloy", "dep:log", "dep:serde_json", "dep:tokio", "dep:url"] +host = [ + "dep:alloy", + "dep:beacon-api-client", + "dep:ethereum-consensus", + "dep:log", + "dep:tokio", + "dep:url", +] diff --git a/steel/README.md b/steel/README.md index 72d39501..fcd5bfea 100644 --- a/steel/README.md +++ b/steel/README.md @@ -96,11 +96,11 @@ function validate(bytes calldata journalData, bytes calldata seal) external { The guest code to create the journal would look like the following: ```rust -use risc0_steel::SolCommitment; +use risc0_steel::Commitment; sol! { struct Journal { - SolCommitment commitment; + Commitment commitment; address tokenAddress; } } diff --git a/steel/src/beacon.rs b/steel/src/beacon.rs new file mode 100644 index 00000000..7f86b35f --- /dev/null +++ b/steel/src/beacon.rs @@ -0,0 +1,247 @@ +// Copyright 2024 RISC Zero, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::{block::BlockInput, Commitment, CommitmentVersion, EvmBlockHeader, GuestEvmEnv}; +use alloy_primitives::B256; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Input committing to the corresponding Beacon Chain block root. +#[derive(Clone, Serialize, Deserialize)] +pub struct BeaconInput { + /// Input committing to an execution block hash. + input: BlockInput, + /// Merkle proof linking the execution block hash to the Beacon block root. + proof: MerkleProof, +} + +impl BeaconInput { + /// Converts the input into a [EvmEnv] for a verifiable state access in the guest. + /// + /// [EvmEnv]: crate::EvmEnv + pub fn into_env(self) -> GuestEvmEnv { + let mut env = self.input.into_env(); + + let beacon_root = self.proof.process(env.header.seal()); + env.commitment = Commitment { + blockID: Commitment::encode_id( + env.header().timestamp(), + CommitmentVersion::Beacon as u16, + ), + blockDigest: beacon_root, + }; + + env + } +} + +/// Merkle proof-of-inclusion. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MerkleProof { + /// Path of Merkle nodes to compute the root. + pub path: Vec, + /// Index of the Merkle leaf to prove. + /// The left-most leaf has index 0 and the right-most leaf 2^depth - 1. + pub index: u32, +} + +impl MerkleProof { + /// Returns the rebuilt hash obtained by traversing the Merkle tree up from `leaf`. + #[inline] + pub fn process(&self, leaf: B256) -> B256 { + let mut index = self.index; + let mut computed_hash = leaf; + let mut hasher = Sha256::new(); + for node in &self.path { + if index % 2 != 0 { + hasher.update(node); + hasher.update(computed_hash); + } else { + hasher.update(computed_hash); + hasher.update(node); + } + computed_hash.copy_from_slice(&hasher.finalize_reset()); + index /= 2; + } + + computed_hash + } +} + +#[cfg(feature = "host")] +pub mod host { + use super::{BeaconInput, MerkleProof}; + use crate::{ + block::BlockInput, + ethereum::EthBlockHeader, + host::{db::AlloyDb, HostEvmEnv}, + }; + + use alloy::{network::Ethereum, providers::Provider, transports::Transport}; + use alloy_primitives::Sealable; + use anyhow::{bail, ensure, Context}; + use beacon_api_client::{mainnet::Client as BeaconClient, BeaconHeaderSummary, BlockId}; + use ethereum_consensus::{ssz::prelude::*, types::SignedBeaconBlock, Fork}; + use proofs::{Proof, ProofAndWitness}; + use url::Url; + + impl BeaconInput { + /// Derives the verifiable input from a [HostEvmEnv] and a Beacon API endpoint. + pub(crate) async fn from_env_and_endpoint( + env: HostEvmEnv, EthBlockHeader>, + url: Url, + ) -> anyhow::Result + where + T: Transport + Clone, + P: Provider, + { + let block_hash = env.header().hash_slow(); + let parent_beacon_block_root = env + .header() + .inner() + .parent_beacon_block_root + .context("parent_beacon_block_root missing in execution header")?; + + let input = BlockInput::from_env(env) + .await + .context("failed to derive block input")?; + let client = BeaconClient::new(url); + + // first get the header of the parent and then the actual block header + let parent_beacon_header = client + .get_beacon_header(BlockId::Root(parent_beacon_block_root)) + .await + .with_context(|| { + format!("failed to get block header {}", parent_beacon_block_root) + })?; + let beacon_header = get_child_beacon_header(&client, parent_beacon_header) + .await + .with_context(|| { + format!("failed to get child of block {}", parent_beacon_block_root) + })?; + + // get the entire block + let signed_beacon_block = client + .get_beacon_block(BlockId::Root(beacon_header.root)) + .await + .with_context(|| format!("failed to get block {}", beacon_header.root))?; + // create the inclusion proof of the execution block hash depending on the fork version + let (proof, beacon_root) = match signed_beacon_block { + SignedBeaconBlock::Deneb(signed_block) => { + prove_block_hash_inclusion(signed_block.message)? + } + _ => { + bail!( + "invalid version of block {}: expected {}; got {}", + beacon_header.root, + Fork::Deneb, + signed_beacon_block.version() + ); + } + }; + + // convert and verify the proof + let proof: MerkleProof = proof + .try_into() + .context("proof derived from API is invalid")?; + ensure!( + proof.process(block_hash) == beacon_root, + "proof derived from API does not verify", + ); + + Ok(BeaconInput { input, proof }) + } + } + + /// Returns the inclusion proof of `block_hash` in the given `BeaconBlock`. + fn prove_block_hash_inclusion( + beacon_block: T, + ) -> Result { + // the `block_hash` is in the ExecutionPayload in the BeaconBlockBody in the BeaconBlock + beacon_block.prove(&[ + "body".into(), + "execution_payload".into(), + "block_hash".into(), + ]) + } + + /// Returns the header, with `parent_root` equal to `parent.root`. + /// + /// It iteratively tries to fetch headers of successive slots until success. + /// TODO: use [BeaconClient::get_beacon_header_for_parent_root], once the nodes add support. + async fn get_child_beacon_header( + client: &BeaconClient, + parent: BeaconHeaderSummary, + ) -> anyhow::Result { + let parent_slot = parent.header.message.slot; + let mut request_error = None; + for slot in (parent_slot + 1)..=(parent_slot + 32) { + match client.get_beacon_header(BlockId::Slot(slot)).await { + Err(err) => request_error = Some(err), + Ok(resp) => { + let header = &resp.header.message; + ensure!( + header.parent_root == parent.root, + "block {} has wrong parent_root: expected {}; got {}", + resp.root, + parent.root, + header.parent_root + ); + return Ok(resp); + } + } + } + // return the last error, if all calls failed + let err = anyhow::Error::from(request_error.unwrap()); + Err(err.context("no valid response received for the 32 consecutive slots")) + } + + impl TryFrom for MerkleProof { + type Error = anyhow::Error; + + fn try_from(proof: Proof) -> Result { + let depth = proof.index.checked_ilog2().context("index is zero")?; + let index = proof.index - (1 << depth); + ensure!(proof.branch.len() == depth as usize, "index is invalid"); + + Ok(MerkleProof { + path: proof.branch, + index: index.try_into().context("index too large")?, + }) + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use alloy_primitives::b256; + + #[test] + fn process_simple_proof() { + let leaf = b256!("94159da973dfa9e40ed02535ee57023ba2d06bad1017e451055470967eb71cd5"); + let proof = MerkleProof { + path: vec![ + b256!("8f594dbb4f4219ad4967f86b9cccdb26e37e44995a291582a431eef36ecba45c"), + b256!("f8c2ed25e9c31399d4149dcaa48c51f394043a6a1297e65780a5979e3d7bb77c"), + b256!("382ba9638ce263e802593b387538faefbaed106e9f51ce793d405f161b105ee6"), + ], + index: 2, + }; + assert_eq!( + proof.process(leaf), + b256!("27097c728aade54ff1376d5954681f6d45c282a81596ef19183148441b754abb") + ); + } +} diff --git a/steel/src/block.rs b/steel/src/block.rs new file mode 100644 index 00000000..3447ab6c --- /dev/null +++ b/steel/src/block.rs @@ -0,0 +1,201 @@ +// Copyright 2024 RISC Zero, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::{state::StateDb, EvmBlockHeader, EvmEnv, GuestEvmEnv, MerkleTrie}; +use ::serde::{Deserialize, Serialize}; +use alloy_primitives::Bytes; +use revm::primitives::HashMap; + +/// Input committing to the corresponding execution block hash. +#[derive(Clone, Serialize, Deserialize)] +pub struct BlockInput { + header: H, + state_trie: MerkleTrie, + storage_tries: Vec, + contracts: Vec, + ancestors: Vec, +} + +impl BlockInput { + /// Converts the input into a [EvmEnv] for a verifiable state access in the guest. + pub fn into_env(self) -> GuestEvmEnv { + // verify that the state root matches the state trie + let state_root = self.state_trie.hash_slow(); + assert_eq!(self.header.state_root(), &state_root, "State root mismatch"); + + // seal the header to compute its block hash + let header = self.header.seal_slow(); + + // validate that ancestor headers form a valid chain + let mut block_hashes = HashMap::with_capacity(self.ancestors.len() + 1); + block_hashes.insert(header.number(), header.seal()); + + let mut previous_header = header.inner(); + for ancestor in &self.ancestors { + let ancestor_hash = ancestor.hash_slow(); + assert_eq!( + previous_header.parent_hash(), + &ancestor_hash, + "Invalid ancestor chain: block {} is not the parent of block {}", + ancestor.number(), + previous_header.number() + ); + block_hashes.insert(ancestor.number(), ancestor_hash); + previous_header = ancestor; + } + + // TODO(victor): When do we check that the storage tries are ok? + let db = StateDb::new( + self.state_trie, + self.storage_tries, + self.contracts, + block_hashes, + ); + + EvmEnv::new(db, header) + } +} + +#[cfg(feature = "host")] +pub mod host { + use std::fmt::Display; + + use super::BlockInput; + use crate::{ + host::{db::AlloyDb, HostEvmEnv}, + state::StateAccount, + EvmBlockHeader, MerkleTrie, + }; + use alloy::{ + network::Network, providers::Provider, rpc::types::Header as RpcHeader, + transports::Transport, + }; + use alloy_primitives::{keccak256, StorageKey}; + use anyhow::{anyhow, ensure, Context}; + use log::debug; + use revm::primitives::HashMap; + + impl BlockInput { + /// Derives the verifiable input from a [HostEvmEnv]. + pub(crate) async fn from_env( + env: HostEvmEnv, H>, + ) -> anyhow::Result + where + T: Transport + Clone, + N: Network, + P: Provider, + H: EvmBlockHeader + TryFrom, + >::Error: Display, + { + let db = &env.db.unwrap(); + + // use the same provider as the database + let provider = db.inner().provider(); + let block_number = db.inner().block_number(); + + // retrieve EIP-1186 proofs for all accounts + let mut proofs = Vec::new(); + for (address, storage_keys) in db.accounts() { + let proof = provider + .get_proof( + *address, + storage_keys.iter().map(|v| StorageKey::from(*v)).collect(), + ) + .number(block_number) + .await + .context("eth_getProof failed")?; + proofs.push(proof); + } + + // build the sparse MPT for the state and verify it against the header + let state_nodes = proofs.iter().flat_map(|p| p.account_proof.iter()); + let state_trie = + MerkleTrie::from_rlp_nodes(state_nodes).context("accountProof invalid")?; + ensure!( + env.header.state_root() == &state_trie.hash_slow(), + "accountProof root does not match header's stateRoot" + ); + + // build the sparse MPT for account storages and filter duplicates + let mut storage_tries = HashMap::new(); + for proof in proofs { + // skip non-existing accounts or accounts where no storage slots were requested + if proof.storage_proof.is_empty() || proof.storage_hash.is_zero() { + continue; + } + + // build the sparse MPT for that account's storage by iterating over all storage + // proofs + let storage_nodes = proof.storage_proof.iter().flat_map(|p| p.proof.iter()); + let storage_trie = + MerkleTrie::from_rlp_nodes(storage_nodes).context("storageProof invalid")?; + let storage_root_hash = storage_trie.hash_slow(); + // verify it against the state trie + let account: StateAccount = state_trie + .get_rlp(keccak256(proof.address)) + .with_context(|| { + format!("invalid RLP value in state trie for {}", proof.address) + })? + .unwrap_or_default(); + ensure!( + account.storage_root == storage_root_hash, + "storageProof of {} does not match storageRoot in the state", + proof.address + ); + + storage_tries.insert(storage_root_hash, storage_trie); + } + let storage_tries: Vec<_> = storage_tries.into_values().collect(); + + // collect the bytecode of all referenced contracts + let contracts: Vec<_> = db.contracts().values().cloned().collect(); + + // retrieve ancestor block headers + let mut ancestors = Vec::new(); + if let Some(&block_hash_min_number) = db.block_hash_numbers().iter().min() { + for number in (block_hash_min_number..block_number).rev() { + let rpc_block = provider + .get_block_by_number(number.into(), false) + .await + .context("eth_getBlockByNumber failed")? + .with_context(|| format!("block {} not found", number))?; + let header: H = rpc_block + .header + .try_into() + .map_err(|err| anyhow!("header invalid: {}", err))?; + ancestors.push(header); + } + } + + debug!("state size: {}", state_trie.size()); + debug!("storage tries: {}", storage_tries.len()); + debug!( + "total storage size: {}", + storage_tries.iter().map(|t| t.size()).sum::() + ); + debug!("contracts: {}", contracts.len()); + debug!("ancestor blocks: {}", ancestors.len()); + + let input = BlockInput { + header: env.header.into_inner(), + state_trie, + storage_tries, + contracts, + ancestors, + }; + + Ok(input) + } + } +} diff --git a/steel/src/host/mod.rs b/steel/src/host/mod.rs index cb133523..e761de32 100644 --- a/steel/src/host/mod.rs +++ b/steel/src/host/mod.rs @@ -16,7 +16,10 @@ use std::fmt::Display; use crate::{ - ethereum::EthEvmEnv, state::StateAccount, EvmBlockHeader, EvmEnv, EvmInput, MerkleTrie, + beacon::BeaconInput, + block::BlockInput, + ethereum::{EthBlockHeader, EthEvmEnv}, + EvmBlockHeader, EvmEnv, EvmInput, }; use alloy::{ network::{Ethereum, Network}, @@ -27,11 +30,8 @@ use alloy::{ Transport, }, }; -use alloy_primitives::{keccak256, StorageKey}; -use anyhow::{anyhow, ensure, Context}; +use anyhow::{anyhow, Context, Result}; use db::{AlloyDb, TraceDb}; -use log::debug; -use revm::primitives::HashMap; use url::Url; pub mod db; @@ -44,7 +44,7 @@ pub(crate) type HostEvmEnv = EvmEnv, H>; impl EthEvmEnv, Ethereum, RootProvider>>>> { /// Creates a new provable [EvmEnv] for Ethereum from an HTTP RPC endpoint. - pub async fn from_rpc(url: Url, number: BlockNumberOrTag) -> anyhow::Result { + pub async fn from_rpc(url: Url, number: BlockNumberOrTag) -> Result { let provider = ProviderBuilder::new().on_http(url); EvmEnv::from_provider(provider, number).await } @@ -59,13 +59,16 @@ where >::Error: Display, { /// Creates a new provable [EvmEnv] from an alloy [Provider]. - pub async fn from_provider(provider: P, number: BlockNumberOrTag) -> anyhow::Result { + pub async fn from_provider(provider: P, number: BlockNumberOrTag) -> Result { let rpc_block = provider .get_block_by_number(number, false) .await .context("eth_getBlockByNumber failed")? .with_context(|| format!("block {} not found", number))?; - let header: H = try_into_header(rpc_block.header)?; + let header: H = rpc_block + .header + .try_into() + .map_err(|err| anyhow!("header invalid: {}", err))?; log::info!("Environment initialized for block {}", header.number()); let db = TraceDb::new(AlloyDb::new(provider, header.number())); @@ -74,7 +77,7 @@ where } } -impl EvmEnv>, H> +impl HostEvmEnv, H> where T: Transport + Clone, N: Network, @@ -82,112 +85,21 @@ where H: EvmBlockHeader + TryFrom, >::Error: Display, { - /// Converts the environment into a [EvmInput]. - /// - /// The resulting input contains inclusion proofs for all the required chain state data. It can - /// therefore be used to execute the same calls in a verifiable way in the zkVM. - pub async fn into_input(self) -> anyhow::Result> { - let db = &self.db.unwrap(); - - // use the same provider as the database - let provider = db.inner().provider(); - let block_number = db.inner().block_number(); - - // retrieve EIP-1186 proofs for all accounts - let mut proofs = Vec::new(); - for (address, storage_keys) in db.accounts() { - let proof = provider - .get_proof( - *address, - storage_keys.iter().map(|v| StorageKey::from(*v)).collect(), - ) - .number(block_number) - .await - .context("eth_getProof failed")?; - proofs.push(proof); - } - - // build the sparse MPT for the state and verify it against the header - let state_nodes = proofs.iter().flat_map(|p| p.account_proof.iter()); - let state_trie = MerkleTrie::from_rlp_nodes(state_nodes).context("accountProof invalid")?; - ensure!( - self.header.state_root() == &state_trie.hash_slow(), - "accountProof root does not match header's stateRoot" - ); - - // build the sparse MPT for account storages and filter duplicates - let mut storage_tries = HashMap::new(); - for proof in proofs { - // skip non-existing accounts or accounts where no storage slots were requested - if proof.storage_proof.is_empty() || proof.storage_hash.is_zero() { - continue; - } - - // build the sparse MPT for that account's storage by iterating over all storage proofs - let storage_nodes = proof.storage_proof.iter().flat_map(|p| p.proof.iter()); - let storage_trie = - MerkleTrie::from_rlp_nodes(storage_nodes).context("storageProof invalid")?; - let storage_root_hash = storage_trie.hash_slow(); - // verify it against the state trie - let account: StateAccount = state_trie - .get_rlp(keccak256(proof.address)) - .with_context(|| format!("invalid RLP value in state trie for {}", proof.address))? - .unwrap_or_default(); - ensure!( - account.storage_root == storage_root_hash, - "storageProof of {} does not match storageRoot in the state", - proof.address - ); - - storage_tries.insert(storage_root_hash, storage_trie); - } - let storage_tries: Vec<_> = storage_tries.into_values().collect(); - - // collect the bytecode of all referenced contracts - let contracts: Vec<_> = db.contracts().values().cloned().collect(); - - // retrieve ancestor block headers - let mut ancestors = Vec::new(); - if let Some(&block_hash_min_number) = db.block_hash_numbers().iter().min() { - for number in (block_hash_min_number..block_number).rev() { - let rpc_block = provider - .get_block_by_number(number.into(), false) - .await - .context("eth_getBlockByNumber failed")? - .with_context(|| format!("block {} not found", number))?; - let header: H = try_into_header(rpc_block.header)?; - ancestors.push(header); - } - } - - debug!("state size: {}", state_trie.size()); - debug!("storage tries: {}", storage_tries.len()); - debug!( - "total storage size: {}", - storage_tries.iter().map(|t| t.size()).sum::() - ); - debug!("contracts: {}", contracts.len()); - debug!("ancestor blocks: {}", ancestors.len()); - - let input = EvmInput { - header: self.header.into_inner(), - state_trie, - storage_tries, - contracts, - ancestors, - }; - - Ok(input) + /// Converts the environment into a [EvmInput] committing to a block hash. + pub async fn into_input(self) -> Result> { + Ok(EvmInput::Block(BlockInput::from_env(self).await?)) } } -fn try_into_header>( - rpc_header: RpcHeader, -) -> anyhow::Result +impl HostEvmEnv, EthBlockHeader> where - >::Error: Display, + T: Transport + Clone, + P: Provider, { - rpc_header - .try_into() - .map_err(|err| anyhow!("header invalid: {}", err)) + /// Converts the environment into a [EvmInput] committing to a Beacon block root. + pub async fn into_beacon_input(self, url: Url) -> Result> { + Ok(EvmInput::Beacon( + BeaconInput::from_env_and_endpoint(self, url).await?, + )) + } } diff --git a/steel/src/lib.rs b/steel/src/lib.rs index be262384..7afb8d57 100644 --- a/steel/src/lib.rs +++ b/steel/src/lib.rs @@ -16,10 +16,14 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] use ::serde::{Deserialize, Serialize}; -use alloy_primitives::{BlockNumber, Bytes, Sealable, Sealed, B256, U256}; -use revm::primitives::{BlockEnv, CfgEnvWithHandlerCfg, HashMap, SpecId}; +use alloy_primitives::{ruint::FromUintError, uint, BlockNumber, Sealable, Sealed, B256, U256}; +use beacon::BeaconInput; +use block::BlockInput; +use revm::primitives::{BlockEnv, CfgEnvWithHandlerCfg, SpecId}; use state::StateDb; +pub mod beacon; +pub mod block; pub mod config; mod contract; pub mod ethereum; @@ -32,79 +36,25 @@ mod state; pub use contract::{CallBuilder, Contract}; pub use mpt::MerkleTrie; -/// The serializable input to derive and validate a [EvmEnv]. +/// The serializable input to derive and validate an [EvmEnv] from. +#[non_exhaustive] #[derive(Clone, Serialize, Deserialize)] -pub struct EvmInput { - header: H, - state_trie: MerkleTrie, - storage_tries: Vec, - contracts: Vec, - ancestors: Vec, +pub enum EvmInput { + /// Input committing to the corresponding execution block hash. + Block(BlockInput), + /// Input committing to the corresponding Beacon Chain block root. + Beacon(BeaconInput), } impl EvmInput { /// Converts the input into a [EvmEnv] for execution. /// /// This method verifies that the state matches the state root in the header and panics if not. - pub fn into_env(self) -> GuestEvmEnv { - // verify that the state root matches the state trie - let state_root = self.state_trie.hash_slow(); - assert_eq!(self.header.state_root(), &state_root, "State root mismatch"); - - // seal the header to compute its block hash - let header = self.header.seal_slow(); - - // validate that ancestor headers form a valid chain - let mut block_hashes = HashMap::with_capacity(self.ancestors.len() + 1); - block_hashes.insert(header.number(), header.seal()); - - let mut previous_header = header.inner(); - for ancestor in &self.ancestors { - let ancestor_hash = ancestor.hash_slow(); - assert_eq!( - previous_header.parent_hash(), - &ancestor_hash, - "Invalid ancestor chain: block {} is not the parent of block {}", - ancestor.number(), - previous_header.number() - ); - block_hashes.insert(ancestor.number(), ancestor_hash); - previous_header = ancestor; - } - - let db = StateDb::new( - self.state_trie, - self.storage_tries, - self.contracts, - block_hashes, - ); - - EvmEnv::new(db, header) - } -} - -// Keep everything in the Steel library private except the commitment. -mod private { - alloy_sol_types::sol! { - #![sol(all_derives)] - /// A Commitment struct representing a block number and its block hash. - struct Commitment { - uint256 blockNumber; // Block number at which the commitment was made. - bytes32 blockHash; // Hash of the block at the specified block number. - } - } -} - -/// Solidity struct representing the committed block used for validation. -pub use private::Commitment as SolCommitment; - -impl SolCommitment { - /// Constructs a commitment from a sealed [EvmBlockHeader]. #[inline] - fn from_header(header: &Sealed) -> Self { - SolCommitment { - blockNumber: U256::from(header.number()), - blockHash: header.seal(), + pub fn into_env(self) -> GuestEvmEnv { + match self { + EvmInput::Block(input) => input.into_env(), + EvmInput::Beacon(input) => input.into_env(), } } } @@ -117,7 +67,7 @@ pub struct EvmEnv { db: Option, cfg_env: CfgEnvWithHandlerCfg, header: Sealed, - commitment: SolCommitment, + commitment: Commitment, } impl EvmEnv { @@ -125,9 +75,9 @@ impl EvmEnv { /// It uses the default configuration for the latest specification. pub fn new(db: D, header: Sealed) -> Self { let cfg_env = CfgEnvWithHandlerCfg::new_with_spec_id(Default::default(), SpecId::LATEST); - let commitment = SolCommitment::from_header(&header); + let commitment = Commitment::from_header(&header); #[cfg(feature = "host")] - log::info!("Commitment to block {}", commitment.blockHash); + log::info!("Commitment to block {}", commitment.blockDigest); Self { db: Some(db), @@ -152,15 +102,15 @@ impl EvmEnv { self.header.inner() } - /// Returns the [SolCommitment] used to validate the environment. + /// Returns the [Commitment] used to validate the environment. #[inline] - pub fn commitment(&self) -> &SolCommitment { + pub fn commitment(&self) -> &Commitment { &self.commitment } - /// Consumes and returns the [SolCommitment] used to validate the environment. + /// Consumes and returns the [Commitment] used to validate the environment. #[inline] - pub fn into_commitment(self) -> SolCommitment { + pub fn into_commitment(self) -> Commitment { self.commitment } } @@ -179,3 +129,72 @@ pub trait EvmBlockHeader: Sealable { /// Fills the EVM block environment with the header's data. fn fill_block_env(&self, blk_env: &mut BlockEnv); } + +// Keep everything in the Steel library private except the commitment. +mod private { + alloy_sol_types::sol! { + #![sol(all_derives)] + /// A commitment to a specific block in the blockchain. + struct Commitment { + /// Encodes both the block identifier (block number or timestamp) and the version. + uint256 blockID; + /// The block hash or beacon block root, used for validation. + bytes32 blockDigest; + } + } +} + +/// Solidity struct representing the committed block used for validation. +pub use private::Commitment; + +/// The different versions of a [Commitment]. +#[repr(u16)] +enum CommitmentVersion { + Block, + Beacon, +} + +impl Commitment { + /// Constructs a commitment from a sealed [EvmBlockHeader]. + #[inline] + fn from_header(header: &Sealed) -> Self { + Commitment { + blockID: Self::encode_id(header.number(), CommitmentVersion::Block as u16), + blockDigest: header.seal(), + } + } + + /// Returns the block identifier without the commitment version. + #[inline] + pub fn block_id(&self) -> u64 { + Self::decode_id(self.blockID).unwrap().0 + } + + /// Encodes an ID and version into a single [U256] value. + #[inline] + pub(crate) fn encode_id(id: u64, version: u16) -> U256 { + U256::from_limbs([id, 0, 0, (version as u64) << 48]) + } + + /// Decodes an ID and version from a single [U256] value. + #[inline] + pub(crate) fn decode_id(mut id: U256) -> Result<(u64, u16), FromUintError> { + let version = (id.as_limbs()[3] >> 48) as u16; + id &= uint!(0x0000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff_U256); + Ok((id.try_into()?, version)) + } +} + +#[cfg(test)] +mod tests { + use super::Commitment; + + #[test] + fn versioned_id() { + let tests = vec![(u64::MAX, u16::MAX), (u64::MAX, 0), (0, u16::MAX), (0, 0)]; + for test in tests { + let id = Commitment::encode_id(test.0, test.1); + assert_eq!(Commitment::decode_id(id).unwrap(), test); + } + } +} diff --git a/steel/tests/common/mod.rs b/steel/tests/common/mod.rs index 93d04f3d..69d6181c 100644 --- a/steel/tests/common/mod.rs +++ b/steel/tests/common/mod.rs @@ -21,7 +21,6 @@ use once_cell::sync::Lazy; use revm::primitives::SpecId; use risc0_steel::{ config::ChainSpec, ethereum::EthEvmEnv, host::BlockNumberOrTag, CallBuilder, Contract, - EvmBlockHeader, }; pub static ANVIL_CHAIN_SPEC: Lazy = @@ -45,7 +44,7 @@ where .unwrap() .with_chain_spec(&ANVIL_CHAIN_SPEC); let block_hash = env.header().hash_slow(); - let block_number = U256::from(env.header().number()); + let block_number = env.header().inner().number; let preflight_result = { let mut preflight = Contract::preflight(address, &mut env); @@ -57,8 +56,12 @@ where let env = input.into_env().with_chain_spec(&ANVIL_CHAIN_SPEC); let commitment = env.commitment(); - assert_eq!(commitment.blockHash, block_hash, "invalid commitment"); - assert_eq!(commitment.blockNumber, block_number, "invalid commitment"); + assert_eq!(commitment.blockDigest, block_hash, "invalid commitment"); + assert_eq!( + commitment.blockID, + U256::from(block_number), + "invalid commitment" + ); let result = { let contract = Contract::new(address, &env);