risc0_steel/contract.rs
1// Copyright 2025 RISC Zero, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::{state::WrapStateDb, EvmFactory, GuestEvmEnv};
16use alloy_evm::Evm;
17use alloy_primitives::Address;
18use alloy_sol_types::{SolCall, SolType};
19use anyhow::anyhow;
20use revm::context::result::{ExecutionResult, ResultAndState, SuccessReason};
21use std::{fmt::Debug, marker::PhantomData};
22
23/// Represents a contract instance for interacting with EVM environments.
24///
25/// This struct provides a way to interact with a deployed smart contract
26/// at a specific `address` within a given EVM environment `E`.
27///
28/// **Note:** This contract interaction is not type-safe regarding the ABI.
29/// Ensure the deployed contract at `address` matches the ABI used for calls (`S: SolCall`).
30///
31/// ### Usage Scenarios
32///
33/// 1. **Host (Preflight):** Use [Contract::preflight] to set up calls on the host environment. The
34/// environment can be initialized using the [EthEvmEnv::builder] or [EvmEnv::builder]. This
35/// fetches necessary state and prepares proofs for guest execution.
36/// - Consider [CallBuilder::call_with_prefetch] for calls with many storage accesses to
37/// potentially optimize preflight time by reducing RPC calls.
38/// 2. **Guest:** Use [Contract::new] within the guest environment, typically initialized from
39/// [EvmInput::into_env].
40///
41///
42/// ### Making Contract Calls (Host Preflight or Guest Execution)
43///
44/// To interact with the contract's functions, you use the [Contract::call_builder] method to
45/// prepare a call.
46/// This follows a specific workflow:
47///
48/// 1. **Create Builder:** Call [Contract::call_builder] with a specific Solidity function call
49/// object (e.g., `MyCall { arg1: ..., arg2: ... }` derived using `alloy_sol_types::sol!`). This
50/// returns a [CallBuilder] instance, initializing its internal transaction environment (`tx`)
51/// with the contract address and call data.
52///
53/// 2. **Configure Transaction:** Because the underlying transaction type (`EvmFactory::Tx`) is
54/// generic, configuration parameters (like caller address, value, gas limit, nonce)
55/// are set by **directly modifying the public `.tx` field** of the returned [CallBuilder]
56/// instance.
57/// ```rust,no_run
58/// # use risc0_steel::{ethereum::{ETH_MAINNET_CHAIN_SPEC, EthEvmEnv}, Contract};
59/// # use alloy_primitives::{Address, U256};
60/// # use alloy_sol_types::sol;
61/// # sol! { interface Test { function test() external view returns (uint); } }
62/// # #[tokio::main(flavor = "current_thread")]
63/// # async fn main() -> anyhow::Result<()> {
64/// # let rpc_url = "https://ethereum-rpc.publicnode.com".parse()?;
65/// # let mut env = EthEvmEnv::builder().rpc(rpc_url).chain_spec(Ð_MAINNET_CHAIN_SPEC).build().await?;
66/// # let mut contract = Contract::preflight(Address::ZERO, &mut env);
67/// # let my_call = Test::testCall {};
68/// let mut builder = contract.call_builder(&my_call);
69/// builder.tx.caller = Address::ZERO;
70/// builder.tx.value = U256::from(0); // Set value if payable
71/// builder.tx.gas_limit = 100_000;
72/// // ... set other fields like gas_price, nonce as needed
73/// # Ok(())
74/// # }
75/// ```
76/// **Note:** Fluent configuration methods like `.from(address)` or `.value(amount)` are
77/// **not available** directly on the `CallBuilder` due to this generic design. You must
78/// use direct field access on `.tx`. Consult the documentation of the specific `Tx`
79/// type provided by your chosen [`EvmFactory`] for available fields (e.g., `revm::primitives::TxEnv`).
80///
81/// 3. **Execute Call:** Once configured, execute the call using the appropriate method on the
82/// [`CallBuilder`] instance. Common methods include:
83/// - `.call()`: Executes in the guest, panicking on EVM errors.
84/// - `.try_call()`: Executes in the guest, returning a `Result` for error handling.
85/// - `.call().await`: Executes preflight on the host (requires `host` feature).
86/// - `.call_with_prefetch().await`: Executes preflight on the host, potentially optimizing
87/// state loading (requires `host` feature).
88///
89/// See the [`CallBuilder`] documentation for more details on execution methods.
90///
91/// ### Examples
92///
93/// ```rust,no_run
94/// # use risc0_steel::{ethereum::{EthEvmInput, EthEvmEnv, ETH_MAINNET_CHAIN_SPEC}, Contract, host::BlockNumberOrTag};
95/// # use alloy_primitives::{Address, address};
96/// # use alloy_sol_types::sol;
97/// # use url::Url;
98/// # #[tokio::main(flavor = "current_thread")]
99/// # async fn main() -> anyhow::Result<()> {
100/// const CONTRACT_ADDRESS: Address = address!("dAC17F958D2ee523a2206206994597C13D831ec7"); // USDT
101/// const ACCOUNT_TO_QUERY: Address = address!("F977814e90dA44bFA03b6295A0616a897441aceC"); // Binance
102/// sol! {
103/// interface IERC20 {
104/// function balanceOf(address account) external view returns (uint);
105/// }
106/// }
107/// const CALL: IERC20::balanceOfCall = IERC20::balanceOfCall { account: ACCOUNT_TO_QUERY };
108///
109/// // === Host Setup ===
110/// let rpc_url = "https://ethereum-rpc.publicnode.com".parse()?;
111/// let mut host_env = EthEvmEnv::builder().rpc(rpc_url).chain_spec(Ð_MAINNET_CHAIN_SPEC).build().await?;
112///
113/// // Preflight the call on the host
114/// let mut contract_host = Contract::preflight(CONTRACT_ADDRESS, &mut host_env);
115/// let mut builder = contract_host.call_builder(&CALL);
116/// // Configure via builder.tx
117/// builder.tx.caller = Address::default();
118/// builder.tx.gas_limit = 10_000;
119/// // Execute
120/// let balance_result = builder.call().await?;
121/// println!("Host preflight balance: {}", balance_result);
122///
123/// // Generate input for the guest
124/// let evm_input = host_env.into_input().await?;
125///
126/// // === Guest Setup & Execution ===
127/// // (Inside the RISC Zero guest)
128/// # {
129/// let guest_env = evm_input.into_env(Ð_MAINNET_CHAIN_SPEC);
130/// let contract_guest = Contract::new(CONTRACT_ADDRESS, &guest_env);
131///
132/// // Execute the same call in the guest
133/// let mut builder = contract_guest.call_builder(&CALL);
134/// builder.tx.caller = Address::default();
135/// builder.tx.gas_limit = 10_000;
136/// let guest_balance_result = builder.call();
137/// println!("Guest execution balance: {}", guest_balance_result);
138/// # }
139/// # Ok(())
140/// # }
141/// ```
142///
143/// [EthEvmEnv::builder]: crate::ethereum::EthEvmEnv
144/// [EvmEnv::builder]: crate::EvmEnv
145/// [EvmInput::into_env]: crate::EvmInput::into_env
146pub struct Contract<E> {
147 address: Address,
148 env: E,
149}
150
151impl<'a, F: EvmFactory> Contract<&'a GuestEvmEnv<F>> {
152 /// Creates a `Contract` instance for use within the guest environment.
153 ///
154 /// The `env` should typically be obtained via [EvmInput::into_env].
155 ///
156 /// [EvmInput::into_env]: crate::EvmInput::into_env
157 pub fn new(address: Address, env: &'a GuestEvmEnv<F>) -> Self {
158 Self { address, env }
159 }
160
161 /// Initializes a builder for executing a specific contract call (`S`) in the guest.
162 pub fn call_builder<S: SolCall>(&self, call: &S) -> CallBuilder<F::Tx, S, &GuestEvmEnv<F>> {
163 CallBuilder::new(F::new_tx(self.address, call.abi_encode().into()), self.env)
164 }
165}
166
167/// Represents a prepared EVM contract call, ready for configuration and execution.
168///
169/// Instances are created via [Contract::call_builder]. The primary interaction
170/// involves configuring the transaction parameters via the public [CallBuilder::tx] field,
171/// followed by invoking an execution method like `.call()`.
172///
173/// See the documentation on the [Contract] struct for a detailed explanation of the
174/// configuration workflow and examples.
175#[derive(Debug, Clone)]
176#[must_use = "CallBuilder does nothing unless an execution method like `.call()` is called"]
177pub struct CallBuilder<T, S, E> {
178 /// The transaction environment (`EvmFactory::Tx`) containing call parameters.
179 ///
180 /// **Configuration:** This field holds the transaction details (caller, value, gas, etc.).
181 /// It **must be configured directly** by modifying its members *before* calling an
182 /// execution method.
183 ///
184 /// Example: `builder.tx.caller = MY_ADDRESS; builder.tx.gas_limit = 100_000;`
185 pub tx: T,
186 /// The EVM environment (either host or guest).
187 env: E,
188 /// Phantom data for the `SolCall` type `S`.
189 phantom: PhantomData<S>,
190}
191
192impl<T, S: SolCall, E> CallBuilder<T, S, E> {
193 /// Compile-time assertion that the call has a return value.
194 const RETURNS: () = assert!(
195 std::mem::size_of::<S::Return>() > 0,
196 "Function call must have a return value"
197 );
198
199 fn new(tx: T, env: E) -> Self {
200 #[allow(clippy::let_unit_value)]
201 let _ = Self::RETURNS;
202
203 Self {
204 tx,
205 env,
206 phantom: PhantomData,
207 }
208 }
209}
210
211#[cfg(feature = "host")]
212mod host {
213 use super::*;
214 use crate::{
215 ethereum::EthEvmFactory,
216 host::{db::ProviderDb, HostEvmEnv},
217 };
218 use alloy::{
219 eips::eip2930::AccessList,
220 network::{Ethereum, Network, TransactionBuilder},
221 providers::Provider,
222 };
223 use anyhow::{anyhow, Context, Result};
224
225 impl<'a, F, D, C> Contract<&'a mut HostEvmEnv<D, F, C>>
226 where
227 F: EvmFactory,
228 {
229 /// Creates a `Contract` instance for use on the host for preflighting calls.
230 ///
231 /// This prepares the environment for simulating the call, fetching necessary
232 /// state via the `Provider` within `env`, and enabling proof generation
233 /// via [HostEvmEnv::into_input].
234 pub fn preflight(address: Address, env: &'a mut HostEvmEnv<D, F, C>) -> Self {
235 Self { address, env }
236 }
237
238 /// Initializes a builder for preflighting a specific contract call (`S`) on the host.
239 pub fn call_builder<S: SolCall>(
240 &mut self,
241 call: &S,
242 ) -> CallBuilder<F::Tx, S, &mut HostEvmEnv<D, F, C>> {
243 CallBuilder::new(F::new_tx(self.address, call.abi_encode().into()), self.env)
244 }
245 }
246
247 // Methods applicable when using ProviderDb on the host
248 impl<S, F, N, P, C> CallBuilder<F::Tx, S, &mut HostEvmEnv<ProviderDb<N, P>, F, C>>
249 where
250 N: Network,
251 P: Provider<N> + Send + Sync + 'static,
252 S: SolCall + Send + Sync + 'static,
253 <S as SolCall>::Return: Send,
254 F: EvmFactory,
255 {
256 /// Prefetches state for a given EIP-2930 `AccessList` on the host.
257 ///
258 /// Fetches EIP-1186 storage proofs for the items
259 /// in the `access_list`. This can reduce the number of individual RPC calls
260 /// (`eth_getStorageAt`) needed during subsequent execution simulation if the
261 /// accessed slots are known beforehand.
262 ///
263 /// This method *only* fetches data; it does *not* set the access list field
264 /// on the transaction itself (EIP-2930).
265 ///
266 /// ### Usage
267 /// Useful when an access list is already available. For automatic generation
268 /// and prefetching, see [`CallBuilder::call_with_prefetch`].
269 ///
270 /// ### Example
271 /// ```rust,no_run
272 /// # use risc0_steel::{ethereum::{ETH_MAINNET_CHAIN_SPEC, EthEvmEnv}, Contract};
273 /// # use alloy_primitives::address;
274 /// # use alloy_sol_types::sol;
275 /// # use alloy::eips::eip2930::AccessList;
276 /// # use url::Url;
277 /// # sol! { interface Test { function test() external view returns (uint); } }
278 /// # #[tokio::main(flavor = "current_thread")]
279 /// # async fn main() -> anyhow::Result<()> {
280 /// # let rpc_url = "https://ethereum-rpc.publicnode.com".parse()?;
281 /// # let mut env = EthEvmEnv::builder().rpc(rpc_url).chain_spec(Ð_MAINNET_CHAIN_SPEC).build().await?;
282 /// # let contract_address = address!("0x0000000000000000000000000000000000000000");
283 /// # let call = Test::testCall {};
284 /// # let access_list = AccessList::default();
285 /// let mut contract = Contract::preflight(contract_address, &mut env);
286 /// let builder = contract.call_builder(&call).prefetch_access_list(access_list).await?;
287 /// let result = builder.call().await?;
288 /// # Ok(())
289 /// # }
290 /// ```
291 pub async fn prefetch_access_list(self, access_list: AccessList) -> Result<Self> {
292 let db = self.env.db_mut();
293 db.add_access_list(access_list).await?;
294
295 Ok(self)
296 }
297
298 /// Executes the configured call during host preflight.
299 ///
300 /// This simulates the transaction execution using `revm` within a blocking thread
301 /// (via [`tokio::task::spawn_blocking`]) to avoid blocking the async runtime.
302 /// It uses the state fetched (and potentially prefetched) into the `ProviderDb`.
303 ///
304 /// Returns the decoded return value of the call or an error if execution fails.
305 pub async fn call(self) -> Result<S::Return> {
306 log::info!("Executing preflight calling '{}'", S::SIGNATURE);
307
308 // as mutable references are not possible, the DB must be moved in and out of the task
309 let mut db = self.env.db.take().unwrap();
310
311 let chain_id = self.env.chain_id;
312 let spec = self.env.spec;
313 let header = self.env.header.inner().clone();
314 let (result, db) = tokio::task::spawn_blocking(move || {
315 let exec_result = {
316 let mut evm = F::create_evm(&mut db, chain_id, spec, &header);
317 transact::<_, F, S>(self.tx, &mut evm)
318 };
319 (exec_result, db)
320 })
321 .await
322 .expect("EVM execution panicked");
323
324 // restore the DB before handling errors, so that we never return an env without a DB
325 self.env.db = Some(db);
326
327 result.map_err(|err| anyhow!("call '{}' failed: {}", S::SIGNATURE, err))
328 }
329 }
330
331 // Methods specific to Ethereum network + EthEvmFactory (e.g., eth_createAccessList)
332 impl<S, P, C>
333 CallBuilder<
334 <EthEvmFactory as EvmFactory>::Tx,
335 S,
336 &mut HostEvmEnv<ProviderDb<Ethereum, P>, EthEvmFactory, C>,
337 >
338 where
339 S: SolCall + Send + Sync + 'static,
340 <S as SolCall>::Return: Send,
341 P: Provider<Ethereum> + Send + Sync + 'static,
342 {
343 /// Automatically creates and prefetches an EIP-2930 access list, then executes the call.
344 ///
345 /// This method aims to optimize host preflight time for calls involving numerous
346 /// storage reads (`SLOAD`). It performs the following steps:
347 /// 1. Calls `eth_createAccessList` RPC to determine the storage slots and accounts the
348 /// transaction is likely to access.
349 /// 2. Calls [CallBuilder::prefetch_access_list] with the generated list to fetch the
350 /// required state efficiently (often in a single batch RPC).
351 /// 3. Executes the call simulation using [CallBuilder::call].
352 ///
353 /// ### Trade-offs
354 /// - **Node Compatibility:** Relies on the `eth_createAccessList` RPC, which might not be
355 /// available or fully supported on all Ethereum node software or chains.
356 /// - **Gas Estimation Issues:** Some node implementations might perform gas checks or
357 /// require sufficient balance in the `from` account for `eth_createAccessList`, even for
358 /// view calls. Setting a relevant `from` address might be necessary.
359 ///
360 /// ### Example
361 /// ```rust,no_run
362 /// # use risc0_steel::{ethereum::{ETH_MAINNET_CHAIN_SPEC, EthEvmEnv}, Contract};
363 /// # use alloy_primitives::address;
364 /// # use alloy_sol_types::sol;
365 /// # use url::Url;
366 /// # sol! { interface Test { function test() external view returns (uint); } }
367 /// # #[tokio::main(flavor = "current_thread")]
368 /// # async fn main() -> anyhow::Result<()> {
369 /// # let rpc_url = "https://ethereum-rpc.publicnode.com".parse()?;
370 /// # let mut env = EthEvmEnv::builder().rpc(rpc_url).chain_spec(Ð_MAINNET_CHAIN_SPEC).build().await?;
371 /// # let contract_address = address!("0x0000000000000000000000000000000000000000");
372 /// # let call = Test::testCall {};
373 /// let mut contract = Contract::preflight(contract_address, &mut env);
374 /// // Automatically generates access list, fetches state, and executes
375 /// let result = contract.call_builder(&call).call_with_prefetch().await?;
376 /// # Ok(())
377 /// # }
378 /// ```
379 pub async fn call_with_prefetch(self) -> Result<S::Return> {
380 let access_list = {
381 let tx_request = <Ethereum as Network>::TransactionRequest::default()
382 .with_from(self.tx.caller)
383 .with_gas_limit(self.tx.gas_limit)
384 .with_gas_price(self.tx.gas_price)
385 .with_kind(self.tx.kind)
386 .with_value(self.tx.value)
387 .with_input(self.tx.data.clone());
388
389 let db = self.env.db_mut();
390 let provider = db.inner().provider();
391 let hash = db.inner().block();
392
393 let access_list_result = provider
394 .create_access_list(&tx_request)
395 .hash(hash)
396 .await
397 .context("eth_createAccessList failed")?;
398
399 access_list_result.access_list
400 };
401
402 // Add the generated access list to the DB for prefetching
403 self.env
404 .db_mut()
405 .add_access_list(access_list)
406 .await
407 .context("failed to add generated access list")?;
408
409 self.call().await
410 }
411 }
412}
413
414impl<S, F> CallBuilder<F::Tx, S, &GuestEvmEnv<F>>
415where
416 S: SolCall,
417 F: EvmFactory,
418{
419 /// Executes the call within the guest environment, returning a `Result`.
420 ///
421 /// Use this if you need to handle potential EVM execution errors explicitly
422 /// (e.g., reverts, halts) within the guest. The error type is `String` for simplicity
423 /// in the guest context.
424 ///
425 /// For straightforward calls where failure should halt guest execution, prefer
426 /// [CallBuilder::call].
427 pub fn try_call(self) -> Result<S::Return, String> {
428 // create a temporary EVM instance for this call
429 let mut evm = F::create_evm(
430 // wrap the database and header for guest state access
431 WrapStateDb::new(self.env.db(), &self.env.header),
432 self.env.chain_id,
433 self.env.spec,
434 self.env.header.inner(),
435 );
436 // execute the transaction
437 transact::<_, F, S>(self.tx, &mut evm)
438 }
439
440 /// Executes the call within the guest environment, panicking on failure.
441 ///
442 /// This is a convenience wrapper around [CallBuilder::try_call]. It unwraps
443 /// the result, causing the guest to panic if the EVM call reverts, halts, or
444 /// encounters an error. Use this when a successful call is expected.
445 #[track_caller] // Improve panic message location
446 pub fn call(self) -> S::Return {
447 match self.try_call() {
448 Ok(value) => value,
449 Err(e) => panic!("Executing call '{}' failed: {}", S::SIGNATURE, e),
450 }
451 }
452}
453
454/// Executes a transaction using the provided EVM instance and decodes the result.
455/// Returns `Result<S::Return, String>` where `String` contains the error reason.
456fn transact<DB, F, S>(tx: F::Tx, evm: &mut F::Evm<DB>) -> Result<S::Return, String>
457where
458 DB: alloy_evm::Database,
459 F: EvmFactory,
460 S: SolCall,
461{
462 let ResultAndState { result, .. } = evm
463 .transact_raw(tx)
464 .map_err(|err| format!("EVM error: {:#}", anyhow!(err)))?;
465 let output_bytes = match result {
466 ExecutionResult::Success { reason, output, .. } => {
467 // ensure the transaction returned, not stopped or other success reason
468 if reason == SuccessReason::Return {
469 Ok(output)
470 } else {
471 Err(format!("succeeded but did not return (reason: {reason:?})"))
472 }
473 }
474 ExecutionResult::Revert { output, .. } => Err(format!("reverted with output: {output}")),
475 ExecutionResult::Halt { reason, .. } => Err(format!("halted: {reason:?}")),
476 }?;
477
478 // decode the successful return output
479 S::abi_decode_returns(&output_bytes.into_data()).map_err(|err| {
480 format!(
481 "Failed to decode return data, expected type '{}': {}",
482 <S::ReturnTuple<'_> as SolType>::SOL_NAME,
483 err
484 )
485 })
486}