diff --git a/Cargo.lock b/Cargo.lock index 37496615df..79dff5bc1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1464,6 +1464,20 @@ dependencies = [ "revm", ] +[[package]] +name = "example-erc20-gas" +version = "0.0.0" +dependencies = [ + "alloy-provider", + "alloy-sol-types", + "alloy-transport-http", + "anyhow", + "reqwest", + "revm", + "revm-database", + "tokio", +] + [[package]] name = "example-uniswap-get-reserves" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 91d9fcc32b..ccf0f86cf9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "examples/database_components", "examples/uniswap_get_reserves", "examples/uniswap_v2_usdc_swap", + "examples/erc20_gas", #"examples/custom_opcodes", ] resolver = "2" diff --git a/examples/erc20_gas/Cargo.toml b/examples/erc20_gas/Cargo.toml new file mode 100644 index 0000000000..2d9648712b --- /dev/null +++ b/examples/erc20_gas/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "example-erc20-gas" +version = "0.0.0" +publish = false +authors.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true +readme.workspace = true + + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints.rust] +unreachable_pub = "warn" +unused_must_use = "deny" +rust_2018_idioms = "deny" + +[lints.rustdoc] +all = "warn" + +[dependencies] +revm.workspace = true +database = { workspace = true, features = ["std", "alloydb"] } + +# tokio +tokio = { version = "1.40", features = ["rt-multi-thread", "macros"] } + +# alloy +alloy-sol-types = { version = "0.8.2", default-features = false, features = [ + "std", +] } +alloy-transport-http = "0.6" +alloy-provider = "0.6" +reqwest = { version = "0.12" } +anyhow = "1.0.89" diff --git a/examples/erc20_gas/src/handlers/mod.rs b/examples/erc20_gas/src/handlers/mod.rs new file mode 100644 index 0000000000..cc25fb1e21 --- /dev/null +++ b/examples/erc20_gas/src/handlers/mod.rs @@ -0,0 +1,34 @@ +pub mod post_execution; +pub mod pre_execution; +pub mod validation; + +pub use post_execution::Erc20PostExecution; +pub use pre_execution::Erc20PreExecution; +pub use validation::Erc20Validation; + +use revm::{ + context::{block::BlockEnv, tx::TxEnv, CfgEnv, Context}, + context_interface::result::{EVMError, InvalidTransaction}, + database_interface::Database, + handler::{EthExecution, EthHandler}, + Evm, +}; + +pub type Erc20GasError = EVMError<::Error, InvalidTransaction>; + +pub type Erc20GasContext = Context; + +pub type CustomHandler< + CTX, + ERROR, + VAL = Erc20Validation, + PREEXEC = Erc20PreExecution, + EXEC = EthExecution, + POSTEXEC = Erc20PostExecution, +> = EthHandler; + +pub type CustomEvm = Evm< + Erc20GasError, + Erc20GasContext, + CustomHandler, Erc20GasError>, +>; diff --git a/examples/erc20_gas/src/handlers/post_execution.rs b/examples/erc20_gas/src/handlers/post_execution.rs new file mode 100644 index 0000000000..d145dfe1d3 --- /dev/null +++ b/examples/erc20_gas/src/handlers/post_execution.rs @@ -0,0 +1,109 @@ +use crate::{token_operation, TREASURY}; +use revm::{ + context::Cfg, + context_interface::{ + result::{HaltReason, HaltReasonTrait, InvalidHeader, InvalidTransaction, ResultAndState}, + Block, JournalDBError, Transaction, TransactionGetter, + }, + handler::{EthPostExecution, EthPostExecutionContext, EthPostExecutionError, FrameResult}, + handler_interface::PostExecutionHandler, + precompile::PrecompileErrors, + primitives::U256, + specification::hardfork::SpecId, +}; + +pub struct Erc20PostExecution { + inner: EthPostExecution, +} + +impl Erc20PostExecution { + pub fn new() -> Self { + Self { + inner: EthPostExecution::new(), + } + } +} + +impl Default for Erc20PostExecution { + fn default() -> Self { + Self::new() + } +} + +impl PostExecutionHandler for Erc20PostExecution +where + CTX: EthPostExecutionContext, + ERROR: EthPostExecutionError + + From + + From + + From> + + From, + HALTREASON: HaltReasonTrait, +{ + type Context = CTX; + type Error = ERROR; + type ExecResult = FrameResult; + type Output = ResultAndState; + + fn refund( + &self, + context: &mut Self::Context, + exec_result: &mut Self::ExecResult, + eip7702_refund: i64, + ) { + self.inner.refund(context, exec_result, eip7702_refund) + } + + fn reimburse_caller( + &self, + context: &mut Self::Context, + exec_result: &mut Self::ExecResult, + ) -> Result<(), Self::Error> { + let basefee = context.block().basefee() as u128; + let caller = context.tx().common_fields().caller(); + let effective_gas_price = context.tx().effective_gas_price(basefee); + let gas = exec_result.gas(); + + let reimbursement = + effective_gas_price.saturating_mul((gas.remaining() + gas.refunded() as u64) as u128); + token_operation::(context, TREASURY, caller, U256::from(reimbursement))?; + + Ok(()) + } + + fn reward_beneficiary( + &self, + context: &mut Self::Context, + exec_result: &mut Self::ExecResult, + ) -> Result<(), Self::Error> { + let tx = context.tx(); + let beneficiary = context.block().beneficiary(); + let basefee = context.block().basefee() as u128; + let effective_gas_price = tx.effective_gas_price(basefee); + let gas = exec_result.gas(); + + let coinbase_gas_price = if context.cfg().spec().into().is_enabled_in(SpecId::LONDON) { + effective_gas_price.saturating_sub(basefee) + } else { + effective_gas_price + }; + + let reward = + coinbase_gas_price.saturating_mul((gas.spent() - gas.refunded() as u64) as u128); + token_operation::(context, TREASURY, beneficiary, U256::from(reward))?; + + Ok(()) + } + + fn output( + &self, + context: &mut Self::Context, + result: Self::ExecResult, + ) -> Result { + self.inner.output(context, result) + } + + fn clear(&self, context: &mut Self::Context) { + self.inner.clear(context) + } +} diff --git a/examples/erc20_gas/src/handlers/pre_execution.rs b/examples/erc20_gas/src/handlers/pre_execution.rs new file mode 100644 index 0000000000..b9cfc3c87c --- /dev/null +++ b/examples/erc20_gas/src/handlers/pre_execution.rs @@ -0,0 +1,65 @@ +use crate::{token_operation, TREASURY}; +use revm::{ + context_interface::{ + result::InvalidHeader, transaction::Eip4844Tx, Block, Transaction, TransactionGetter, + TransactionType, + }, + handler::{EthPreExecution, EthPreExecutionContext, EthPreExecutionError}, + handler_interface::PreExecutionHandler, + precompile::PrecompileErrors, + primitives::U256, +}; + +pub struct Erc20PreExecution { + inner: EthPreExecution, +} + +impl Erc20PreExecution { + pub fn new() -> Self { + Self { + inner: EthPreExecution::new(), + } + } +} + +impl Default for Erc20PreExecution { + fn default() -> Self { + Self::new() + } +} + +impl PreExecutionHandler for Erc20PreExecution +where + CTX: EthPreExecutionContext, + ERROR: EthPreExecutionError + From + From, +{ + type Context = CTX; + type Error = ERROR; + + fn load_accounts(&self, context: &mut Self::Context) -> Result<(), Self::Error> { + self.inner.load_accounts(context) + } + + fn apply_eip7702_auth_list(&self, context: &mut Self::Context) -> Result { + self.inner.apply_eip7702_auth_list(context) + } + + fn deduct_caller(&self, context: &mut Self::Context) -> Result<(), Self::Error> { + let basefee = context.block().basefee() as u128; + let blob_price = context.block().blob_gasprice().unwrap_or_default(); + let effective_gas_price = context.tx().effective_gas_price(basefee); + + let mut gas_cost = + (context.tx().common_fields().gas_limit() as u128).saturating_mul(effective_gas_price); + + if context.tx().tx_type().into() == TransactionType::Eip4844 { + let blob_gas = context.tx().eip4844().total_blob_gas() as u128; + gas_cost = gas_cost.saturating_add(blob_price.saturating_mul(blob_gas)); + } + + let caller = context.tx().common_fields().caller(); + token_operation::(context, caller, TREASURY, U256::from(gas_cost))?; + + Ok(()) + } +} diff --git a/examples/erc20_gas/src/handlers/validation.rs b/examples/erc20_gas/src/handlers/validation.rs new file mode 100644 index 0000000000..3d7e64d34d --- /dev/null +++ b/examples/erc20_gas/src/handlers/validation.rs @@ -0,0 +1,104 @@ +use crate::TOKEN; +use alloy_sol_types::SolValue; +use revm::{ + context::Cfg, + context_interface::{ + result::InvalidTransaction, transaction::Eip4844Tx, Journal, Transaction, + TransactionGetter, TransactionType, + }, + handler::{EthValidation, EthValidationContext, EthValidationError}, + handler_interface::ValidationHandler, + primitives::{keccak256, U256}, +}; +use std::cmp::Ordering; + +pub struct Erc20Validation { + inner: EthValidation, +} + +impl Erc20Validation { + pub fn new() -> Self { + Self { + inner: EthValidation::new(), + } + } +} + +impl Default for Erc20Validation { + fn default() -> Self { + Self::new() + } +} + +impl ValidationHandler for Erc20Validation +where + CTX: EthValidationContext, + ERROR: EthValidationError, +{ + type Context = CTX; + type Error = ERROR; + + fn validate_env(&self, context: &Self::Context) -> Result<(), Self::Error> { + self.inner.validate_env(context) + } + + fn validate_tx_against_state(&self, context: &mut Self::Context) -> Result<(), Self::Error> { + let caller = context.tx().common_fields().caller(); + let caller_nonce = context.journal().load_account(caller)?.data.info.nonce; + let token_account = context.journal().load_account(TOKEN)?.data.clone(); + + if !context.cfg().is_nonce_check_disabled() { + let tx_nonce = context.tx().common_fields().nonce(); + let state_nonce = caller_nonce; + match tx_nonce.cmp(&state_nonce) { + Ordering::Less => { + return Err(ERROR::from(InvalidTransaction::NonceTooLow { + tx: tx_nonce, + state: state_nonce, + })) + } + Ordering::Greater => { + return Err(ERROR::from(InvalidTransaction::NonceTooHigh { + tx: tx_nonce, + state: state_nonce, + })) + } + _ => (), + } + } + + let mut balance_check = U256::from(context.tx().common_fields().gas_limit()) + .checked_mul(U256::from(context.tx().max_fee())) + .and_then(|gas_cost| gas_cost.checked_add(context.tx().common_fields().value())) + .ok_or(InvalidTransaction::OverflowPaymentInTransaction)?; + + if context.tx().tx_type().into() == TransactionType::Eip4844 { + let tx = context.tx().eip4844(); + let data_fee = tx.calc_max_data_fee(); + balance_check = balance_check + .checked_add(data_fee) + .ok_or(InvalidTransaction::OverflowPaymentInTransaction)?; + } + + let account_balance_slot: U256 = keccak256((caller, U256::from(3)).abi_encode()).into(); + let account_balance = token_account + .storage + .get(&account_balance_slot) + .expect("Balance slot not found") + .present_value(); + + if account_balance < balance_check && !context.cfg().is_balance_check_disabled() { + return Err(InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(balance_check), + balance: Box::new(account_balance), + } + .into()); + }; + + Ok(()) + } + + fn validate_initial_tx_gas(&self, context: &Self::Context) -> Result { + self.inner.validate_initial_tx_gas(context) + } +} diff --git a/examples/erc20_gas/src/main.rs b/examples/erc20_gas/src/main.rs new file mode 100644 index 0000000000..126f301f30 --- /dev/null +++ b/examples/erc20_gas/src/main.rs @@ -0,0 +1,213 @@ +//! Example of a custom handler for ERC20 gas calculation. +//! +//! Gas is going to be deducted from ERC20 token. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +use alloy_provider::{network::Ethereum, ProviderBuilder, RootProvider}; +use alloy_sol_types::{sol, SolCall, SolValue}; +use alloy_transport_http::Http; +use anyhow::{anyhow, Result}; +use database::{AlloyDB, BlockId, CacheDB}; +use reqwest::{Client, Url}; +use revm::{ + context_interface::{ + result::{ExecutionResult, InvalidHeader, InvalidTransaction, Output}, + Journal, JournalDBError, JournalGetter, + }, + database_interface::WrapDatabaseAsync, + handler::EthExecution, + precompile::PrecompileErrors, + primitives::{address, keccak256, Address, Bytes, TxKind, U256}, + state::{AccountInfo, EvmStorageSlot}, + Context, EvmCommit, MainEvm, +}; + +pub mod handlers; +use handlers::{CustomEvm, CustomHandler, Erc20PostExecution, Erc20PreExecution, Erc20Validation}; + +type AlloyCacheDB = + CacheDB, Ethereum, RootProvider>>>>; + +// Constants +pub const TOKEN: Address = address!("1234567890123456789012345678901234567890"); +pub const TREASURY: Address = address!("0000000000000000000000000000000000000001"); + +#[tokio::main] +async fn main() -> Result<()> { + // Set up the HTTP transport which is consumed by the RPC client. + let rpc_url: Url = "https://mainnet.infura.io/v3/c60b0bb42f8a4c6481ecd229eddaca27".parse()?; + + let client = ProviderBuilder::new().on_http(rpc_url); + + let alloy = WrapDatabaseAsync::new(AlloyDB::new(client, BlockId::latest())).unwrap(); + let mut cache_db = CacheDB::new(alloy); + + // Random empty account: From + let account = address!("18B06aaF27d44B756FCF16Ca20C1f183EB49111f"); + // Random empty account: To + let account_to = address!("21a4B6F62E51e59274b6Be1705c7c68781B87C77"); + + let usdc = address!("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + + // USDC has 6 decimals + let hundred_tokens = U256::from(100_000_000_000_000_000u128); + + let balance_slot = keccak256((account, U256::from(3)).abi_encode()).into(); + + cache_db + .insert_account_storage(usdc, balance_slot, hundred_tokens) + .unwrap(); + cache_db.insert_account_info( + account, + AccountInfo { + nonce: 0, + balance: hundred_tokens, + code_hash: keccak256(Bytes::new()), + code: None, + }, + ); + + let balance_before = balance_of(usdc, account, &mut cache_db).unwrap(); + + // Transfer 100 tokens from account to account_to + // Magic happens here with custom handlers + transfer(account, account_to, hundred_tokens, usdc, &mut cache_db)?; + + let balance_after = balance_of(usdc, account, &mut cache_db)?; + + println!("Balance before: {balance_before}"); + println!("Balance after: {balance_after}"); + + Ok(()) +} + +/// Helpers +pub fn token_operation( + context: &mut CTX, + sender: Address, + recipient: Address, + amount: U256, +) -> Result<(), ERROR> +where + CTX: JournalGetter, + ERROR: From + + From + + From> + + From, +{ + let token_account = context.journal().load_account(TOKEN)?.data; + + let sender_balance_slot: U256 = keccak256((sender, U256::from(3)).abi_encode()).into(); + let sender_balance = token_account + .storage + .get(&sender_balance_slot) + .expect("Balance slot not found") + .present_value(); + + if sender_balance < amount { + return Err(ERROR::from( + InvalidTransaction::MaxFeePerBlobGasNotSupported, + )); + } + // Subtract the amount from the sender's balance + let sender_new_balance = sender_balance.saturating_sub(amount); + token_account.storage.insert( + sender_balance_slot, + EvmStorageSlot::new_changed(sender_balance, sender_new_balance), + ); + + // Add the amount to the recipient's balance + let recipient_balance_slot: U256 = keccak256((recipient, U256::from(3)).abi_encode()).into(); + let recipient_balance = token_account + .storage + .get(&recipient_balance_slot) + .expect("To balance slot not found") + .present_value(); + let recipient_new_balance = recipient_balance.saturating_add(amount); + token_account.storage.insert( + recipient_balance_slot, + EvmStorageSlot::new_changed(recipient_balance, recipient_new_balance), + ); + + Ok(()) +} + +fn balance_of(token: Address, address: Address, alloy_db: &mut AlloyCacheDB) -> Result { + sol! { + function balanceOf(address account) public returns (uint256); + } + + let encoded = balanceOfCall { account: address }.abi_encode(); + + let mut evm = MainEvm::new( + Context::builder() + .with_db(alloy_db) + .modify_tx_chained(|tx| { + // 0x1 because calling USDC proxy from zero address fails + tx.caller = address!("0000000000000000000000000000000000000001"); + tx.transact_to = TxKind::Call(token); + tx.data = encoded.into(); + tx.value = U256::from(0); + }), + CustomHandler::default(), + ); + + let ref_tx = evm.exec_commit().unwrap(); + let value = match ref_tx { + ExecutionResult::Success { + output: Output::Call(value), + .. + } => value, + result => return Err(anyhow!("'balanceOf' execution failed: {result:?}")), + }; + + let balance = ::abi_decode(&value, false)?; + + Ok(balance) +} + +fn transfer( + from: Address, + to: Address, + amount: U256, + token: Address, + cache_db: &mut AlloyCacheDB, +) -> Result<()> { + sol! { + function transfer(address to, uint amount) external returns (bool); + } + + let encoded = transferCall { to, amount }.abi_encode(); + + let mut evm = CustomEvm::new( + Context::builder() + .with_db(cache_db) + .modify_tx_chained(|tx| { + tx.caller = from; + tx.transact_to = TxKind::Call(token); + tx.data = encoded.into(); + tx.value = U256::from(0); + }), + CustomHandler::new( + Erc20Validation::new(), + Erc20PreExecution::new(), + EthExecution::new(), + Erc20PostExecution::new(), + ), + ); + let ref_tx = evm.exec_commit().unwrap(); + let success: bool = match ref_tx { + ExecutionResult::Success { + output: Output::Call(value), + .. + } => ::abi_decode(&value, false)?, + result => return Err(anyhow!("'transfer' execution failed: {result:?}")), + }; + + if !success { + return Err(anyhow!("'transfer' failed")); + } + + Ok(()) +}