diff --git a/README.md b/README.md index bea121d..db9fcfc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Gravity Bench -`gravity_bench` is a high-performance transaction generator for Ethereum-compatible blockchains. It's designed for benchmarking and stress-testing EVM-based networks by generating a high volume of transactions. The tool uses an actor-based model for concurrency and can be configured to simulate various transaction workloads, such as simple ERC20 transfers or more complex decentralized exchange (DEX) swaps. +`gravity_bench` is a high-performance transaction generator for Ethereum-compatible blockchains. It's designed for benchmarking and stress-testing EVM-based networks by generating a high volume of transactions. The tool uses an actor-based model for concurrency and can be configured to simulate various transaction workloads: simple ERC20 transfers, Uniswap V2 DEX swaps, or EIP-7702 type-4 SetCode self-sponsored delegations. ## Architecture @@ -17,6 +17,7 @@ The benchmark workloads are defined using `TxnPlan`s. These are modular definiti * Approving tokens for spending by a smart contract (e.g., a DEX router). * Executing ERC20 token transfers. * Swapping tokens on a Uniswap V2-style DEX. +* EIP-7702 SetCode self-sponsored delegations with ETH multi-send (type-4 txs). This design allows for creating flexible and complex benchmarking scenarios. @@ -85,6 +86,8 @@ nodes = [ { rpc_url = "http://localhost:8545", chain_id = 7771625 }, ] num_tokens=2 +# Workload: "erc20" | "swap" | "eip7702" +workload = "erc20" enable_swap_token = false # Faucet and deployer account configuration [faucet] @@ -117,10 +120,39 @@ duration_secs = 60 * `faucet.private_key`: **IMPORTANT** - This account must have a sufficient balance of the native currency (e.g., ETH) to fund all the generated test accounts. * `nodes.rpc_url`: The RPC endpoint of the Ethereum node. * `target_tps`: The desired number of transactions per second. -* `enable_swap_token`: Set to `true` to benchmark Uniswap V2 swaps, or `false` for simple ERC20 transfers. +* `workload`: `"erc20"` (default), `"swap"`, or `"eip7702"`. Controls the stress workload. + * `erc20` — ERC20 transfers (deploys tokens via `deploy.py`, cascade-funds ETH + tokens). + * `swap` — Uniswap V2 swaps (also deploys router/liquidity). + * `eip7702` — type-4 SetCode self-sponsored txs with **ETH multi-send**. Deploys `BatchExecutor` (`multiSend(address[],uint256[])`) from the faucet, cascade-funds ETH only (no ERC20). Each worker re-delegates to that template, then calls **itself** with `multiSend` to **4** pool recipients (1 gwei each by default). Funds circulate among workers. Sender nonce advances by **2** per inclusion. +* `enable_swap_token`: **Legacy**. Prefer `workload = "swap"`. Still honored when `workload` is omitted. * `faucet.wait_duration_secs`: The number of seconds to wait between faucet distribution levels. If you encounter `insufficient funds` errors during the setup phase, increasing this value can help by allowing more time for transactions to be mined and account balances to be updated. * `performance.duration_secs`: The duration of the benchmark in seconds. If set to `0`, the benchmark will run indefinitely. +#### EIP-7702 example (`bench_config.toml`) + +```toml +target_tps = 10 +workload = "eip7702" +num_tokens = 0 +nodes = [ + { rpc_url = "https://testnet-rpc.gravity.xyz", chain_id = }, +] +[faucet] +private_key = "..." +faucet_level = 5 +wait_duration_secs = 5 +faucet_eth_balance = "10.0" +[accounts] +num_accounts = 100 +[performance] +num_senders = 20 +max_pool_size = 1000 +duration_secs = 300 +sampling = 10 +``` + +Requires Prague/Beta (EIP-7702 not locked down) on the target chain. + ## Running the Benchmark Once the configuration is set up, you can run the benchmark using `cargo run`. diff --git a/bench_config.template b/bench_config.template index 5bc1319..a1b0e54 100644 --- a/bench_config.template +++ b/bench_config.template @@ -7,6 +7,12 @@ nodes = [ { rpc_url = "http://localhost:8545", chain_id = 1337 }, ] num_tokens=2 +# Workload type: "erc20" (default), "swap", or "eip7702" +# - erc20: EIP-1559 ERC20 transfers +# - swap: Uniswap V2 token swaps +# - eip7702: EIP-7702 type-4 SetCode self-sponsored delegations +workload = "erc20" +# Legacy flag (still honored when `workload` is omitted): true → swap, false → erc20 enable_swap_token = false # Address pool type: "random" (default) or "weighted" (hot/normal/long-tail distribution) address_pool_type = "random" diff --git a/contracts/BatchExecutor.sol b/contracts/BatchExecutor.sol new file mode 100644 index 0000000..ccba38c --- /dev/null +++ b/contracts/BatchExecutor.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @notice EIP-7702 delegation target: batch ETH transfers in the EOA context. +/// When an EOA sets code to this contract and is called with multiSend, +/// value is spent from the EOA balance (address(this) == EOA). +/// +/// receive/fallback are required so other delegated EOAs can still accept +/// plain ETH value transfers from multiSend (empty calldata must not revert). +contract BatchExecutor { + receive() external payable {} + + fallback() external payable {} + + /// @notice Transfer ETH to multiple recipients. + /// @dev Must be called as the delegated EOA (to == EOA after set-code). + function multiSend(address[] calldata recipients, uint256[] calldata amounts) external payable { + uint256 n = recipients.length; + require(n == amounts.length, "len"); + for (uint256 i = 0; i < n; ) { + (bool ok, ) = recipients[i].call{value: amounts[i]}(""); + require(ok, "send"); + unchecked { + ++i; + } + } + } +} diff --git a/src/actors/producer/producer_actor.rs b/src/actors/producer/producer_actor.rs index 9e8b703..ccb5cb2 100644 --- a/src/actors/producer/producer_actor.rs +++ b/src/actors/producer/producer_actor.rs @@ -428,7 +428,17 @@ impl Handler for Producer { let account_id = msg.metadata.from_account_id; match msg.result.as_ref() { SubmissionResult::Success(_) => { - address_pool.unlock_next_nonce(account_id); + // Self-sponsored EIP-7702 advances sender nonce by 2 + // (tx + authorization). Other workloads use 1. + if msg.metadata.nonce_increment == 1 { + address_pool.unlock_next_nonce(account_id); + } else { + let next_nonce = msg + .metadata + .nonce + .saturating_add(msg.metadata.nonce_increment as u64); + address_pool.unlock_correct_nonce(account_id, next_nonce as u32); + } } SubmissionResult::NonceTooLow { expect_nonce, .. } => { tracing::debug!( diff --git a/src/config/bench_config.rs b/src/config/bench_config.rs index a85262d..8d34162 100644 --- a/src/config/bench_config.rs +++ b/src/config/bench_config.rs @@ -12,6 +12,20 @@ pub enum AddressPoolType { Weighted, } +/// Benchmark workload type. +/// +/// - `erc20`: EIP-1559 ERC20 transfers (default) +/// - `swap`: Uniswap V2 token swaps (also enabled via legacy `enable_swap_token = true`) +/// - `eip7702`: EIP-7702 type-4 SetCode + ETH multiSend (self-sponsored) +#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum WorkloadType { + #[default] + Erc20, + Swap, + Eip7702, +} + /// Complete configuration structure #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BenchConfig { @@ -22,7 +36,13 @@ pub struct BenchConfig { pub contract_config_path: String, pub num_tokens: usize, pub target_tps: u64, + /// Legacy flag. Prefer `workload = "swap"` instead. + /// Still honored when `workload` is omitted: `true` → swap, `false` → erc20. + #[serde(default)] pub enable_swap_token: bool, + /// Explicit workload selector. When set, overrides `enable_swap_token`. + #[serde(default)] + pub workload: Option, #[serde(default)] pub address_pool_type: AddressPoolType, #[serde(default = "default_log_path")] @@ -134,4 +154,17 @@ impl BenchConfig { Ok(config) } + + /// Resolve the active workload, preferring explicit `workload` over the + /// legacy `enable_swap_token` flag. + pub fn resolved_workload(&self) -> WorkloadType { + if let Some(w) = self.workload { + return w; + } + if self.enable_swap_token { + WorkloadType::Swap + } else { + WorkloadType::Erc20 + } + } } diff --git a/src/eth/eth_cli.rs b/src/eth/eth_cli.rs index 7aefd4d..3beab22 100644 --- a/src/eth/eth_cli.rs +++ b/src/eth/eth_cli.rs @@ -2,7 +2,7 @@ use alloy::{ consensus::TxEnvelope, eips::Encodable2718, network::Ethereum, - primitives::{Address, TxHash, U256}, + primitives::{Address, Bytes, TxHash, U256}, providers::{Provider, ProviderBuilder, RootProvider}, rpc::{client::RpcClient, types::TransactionReceipt}, transports::http::Http, @@ -450,6 +450,38 @@ impl EthHttpCli { Ok(nonce) } + /// eth_getCode — used to verify EIP-7702 delegate deploy / recover. + pub async fn get_code_at(&self, address: Address) -> Result { + let start = Instant::now(); + let result = + self.retry_with_backoff(|| async { self.inner[0].get_code_at(address).await }).await; + self.update_metrics("eth_getCode", result.is_ok(), start.elapsed()).await; + result.with_context(|| format!("Failed to get code at {:?}", address)) + } + + /// Poll eth_getTransactionReceipt until present or timeout. + pub async fn wait_for_receipt( + &self, + tx_hash: TxHash, + timeout: Duration, + poll_interval: Duration, + ) -> Result { + let start = Instant::now(); + loop { + if let Some(receipt) = self.get_transaction_receipt(tx_hash).await? { + return Ok(receipt); + } + if start.elapsed() >= timeout { + return Err(anyhow::anyhow!( + "timed out waiting for receipt of {:?} after {:?}", + tx_hash, + timeout + )); + } + sleep(poll_interval).await; + } + } + // pub async fn get_account(&self, address: Address) -> Result { // self.retry_with_backoff(|| async { self.inner[0].get_account(address).await }) // .await diff --git a/src/eth/txn_builder.rs b/src/eth/txn_builder.rs index f955a30..38121f3 100644 --- a/src/eth/txn_builder.rs +++ b/src/eth/txn_builder.rs @@ -8,14 +8,19 @@ use alloy::{ use anyhow::Result; use tracing::debug; -/// Max fee per gas for bench transactions (5000 Gwei). +/// Max fee per gas for bench transactions (1000 Gwei). /// -/// Must stay above Gravity's 50 Gwei protocol minimum base fee with enough -/// headroom that the effective tip (max_priority_fee_per_gas) clears the -/// gravity-reth txpool promotion threshold. Empirically, transactions with -/// a 1 Gwei priority fee linger in the `queued` bucket and are never -/// promoted to `pending`; a tip in the hundreds of Gwei is required. -pub const BENCH_MAX_FEE_PER_GAS: u128 = 5_000_000_000_000; +/// Must stay above Gravity's ~50 Gwei min base fee with headroom so that +/// `max_priority_fee_per_gas` (500 Gwei) still fully applies: +/// `effective_tip = min(tip, maxFee - baseFee)`. Tip must stay in the +/// hundreds of Gwei range — empirically 1 Gwei never promotes out of +/// gravity-reth's `queued` bucket. +/// +/// Also kept low enough that worst-case EIP-7702 stress +/// (`EIP7702_SET_CODE_GAS_LIMIT` × this) stays under the public RPC +/// default `rpc.txfeecap` of 1 ETH: 350_000 × 1000 Gwei = 0.35 ETH. +/// (Previously 5000 Gwei made 7702 reserve 1.75 ETH and testnet rejected it.) +pub const BENCH_MAX_FEE_PER_GAS: u128 = 1_000_000_000_000; /// Priority fee (tip) for bench transactions (500 Gwei). /// diff --git a/src/main.rs b/src/main.rs index 885ba31..a363963 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ use actix::{Actor, Addr}; use alloy::{ - primitives::{Address, U256}, + eips::Encodable2718, + network::TransactionBuilder, + primitives::{Address, Bytes, U256}, signers::local::PrivateKeySigner, }; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Parser; use futures::stream::{self, StreamExt}; use indicatif::{ProgressBar, ProgressStyle}; @@ -17,17 +19,19 @@ use std::{ time::{Duration, Instant}, }; use tokio::io::{AsyncBufReadExt, BufReader as TokioBufReader}; -use tracing::{error, info}; +use tracing::{error, info, warn}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use crate::{ actors::{consumer::Consumer, producer::Producer, Monitor, RegisterTxnPlan}, - config::{BenchConfig, ContractConfig}, - eth::EthHttpCli, + config::{BenchConfig, ContractConfig, WorkloadType}, + eth::{EthHttpCli, TxnBuilder, BENCH_MAX_FEE_PER_GAS, BENCH_MAX_PRIORITY_FEE_PER_GAS}, txn_plan::{ addr_pool::AddressPool, - constructor::FaucetTreePlanBuilder, + constructor::{ + delegate_contract_bytecode, FaucetTreePlanBuilder, EIP7702_DELEGATE_DEPLOY_GAS_LIMIT, + }, faucet_txn_builder::{Erc20FaucetTxnBuilder, EthFaucetTxnBuilder, FaucetTxnBuilder}, PlanBuilder, TxnPlan, }, @@ -58,6 +62,9 @@ struct Args { struct Snapshot { seed: String, faucet_start_nonce: u64, + /// EIP-7702 delegate contract address (set when workload = eip7702). + #[serde(default)] + eip7702_delegate: Option, } // mod uniswap; @@ -229,6 +236,142 @@ async fn test_erc20_transfer( Ok(()) } +async fn test_eip7702( + chain_id: u64, + delegate: Address, + address_pool: Arc, + producer: &Addr, + tps: usize, + duration_secs: u64, +) -> Result<()> { + let start_time = Instant::now(); + loop { + if duration_secs > 0 && start_time.elapsed() >= Duration::from_secs(duration_secs) { + info!("Benchmark duration of {} seconds reached. Stopping.", duration_secs); + break; + } + let plan = PlanBuilder::eip7702_set_code(chain_id, delegate, address_pool.clone(), tps); + let rx = match run_plan(plan, producer).await { + Ok(rx) => rx, + Err(e) => { + info!("Failed to submit plan: {}. Retrying...", e); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + } + }; + tokio::spawn(async move { + if let Err(e) = rx.await { + error!("Plan execution failed: {:?}", e); + } + }); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + Ok(()) +} + +/// Deploy the BatchExecutor EIP-7702 delegate target from the faucet account. +/// Returns `(delegate_address, next_faucet_nonce)`. +async fn deploy_eip7702_delegate( + eth_client: &EthHttpCli, + faucet_key: &str, + chain_id: u64, + faucet_nonce: u64, +) -> Result<(Address, u64)> { + let signer = PrivateKeySigner::from_str(faucet_key) + .context("invalid faucet private key for delegate deploy")?; + let faucet_addr = signer.address(); + let bytecode = Bytes::from(delegate_contract_bytecode()); + + let tx_request = alloy::rpc::types::TransactionRequest::default() + .with_from(faucet_addr) + .with_deploy_code(bytecode) + .with_nonce(faucet_nonce) + .with_chain_id(chain_id) + // 500k × 1000 Gwei = 0.5 ETH < public RPC 1 ETH txfeecap + .with_max_priority_fee_per_gas(BENCH_MAX_PRIORITY_FEE_PER_GAS) + .with_max_fee_per_gas(BENCH_MAX_FEE_PER_GAS) + .with_gas_limit(EIP7702_DELEGATE_DEPLOY_GAS_LIMIT); + + let envelope = TxnBuilder::build_and_sign_transaction(tx_request, &signer)?; + let tx_hash = eth_client.send_raw_tx(envelope.encoded_2718()).await?; + info!("EIP-7702 delegate deploy submitted: {:?}", tx_hash); + + let receipt = eth_client + .wait_for_receipt(tx_hash, Duration::from_secs(120), Duration::from_millis(500)) + .await + .context("waiting for EIP-7702 delegate deploy receipt")?; + + let status = receipt.status(); + if !status { + return Err(anyhow::anyhow!( + "EIP-7702 delegate deploy failed (status=0), tx={:?}", + tx_hash + )); + } + + let contract_address = receipt.contract_address.ok_or_else(|| { + anyhow::anyhow!("EIP-7702 delegate deploy receipt missing contractAddress") + })?; + + // Sanity: expected CREATE address from (sender, nonce) + let expected = faucet_addr.create(faucet_nonce); + if contract_address != expected { + warn!( + "delegate address from receipt ({:#x}) != CREATE prediction ({:#x}); using receipt", + contract_address, expected + ); + } + + let code = eth_client.get_code_at(contract_address).await?; + if code.is_empty() { + return Err(anyhow::anyhow!("no code at deployed delegate {:#x}", contract_address)); + } + + info!("EIP-7702 delegate deployed at {:#x}", contract_address); + Ok((contract_address, faucet_nonce + 1)) +} + +/// Recover a previously deployed EIP-7702 delegate from snapshot or faucet CREATE history. +async fn recover_eip7702_delegate( + eth_client: &EthHttpCli, + snapshot_delegate: Option<&str>, + faucet_addr: Address, + faucet_start_nonce: u64, +) -> Result
{ + if let Some(addr_str) = snapshot_delegate { + let addr = Address::from_str(addr_str) + .with_context(|| format!("invalid eip7702_delegate in snapshot: {}", addr_str))?; + let code = eth_client.get_code_at(addr).await?; + if code.is_empty() { + return Err(anyhow::anyhow!( + "snapshot eip7702_delegate {:#x} has no code; re-run without --recover", + addr + )); + } + info!("Recovered EIP-7702 delegate from snapshot: {:#x}", addr); + return Ok(addr); + } + + // Fallback: assume delegate was the last CREATE from faucet before cascade + // (deploy uses faucet_start_nonce - 1 if we deployed once then saved snapshot + // after deploy... we always save snapshot with the address when possible). + if faucet_start_nonce == 0 { + return Err(anyhow::anyhow!( + "cannot recover EIP-7702 delegate: no snapshot address and faucet nonce is 0" + )); + } + let addr = faucet_addr.create(faucet_start_nonce - 1); + let code = eth_client.get_code_at(addr).await?; + if code.is_empty() { + return Err(anyhow::anyhow!( + "no code at predicted delegate {:#x}; re-run faucet without --recover", + addr + )); + } + info!("Recovered EIP-7702 delegate via CREATE prediction: {:#x}", addr); + Ok(addr) +} + fn run_command(command: &str) -> Result { let output = Command::new("bash").arg("-c").arg(command).output()?; // ? will return Err early if there's an error @@ -256,6 +399,7 @@ async fn start_bench() -> Result<()> { let args = Args::parse(); let benchmark_config = BenchConfig::load(&args.config).unwrap(); assert!(benchmark_config.accounts.num_accounts >= benchmark_config.target_tps as usize); + let workload = benchmark_config.resolved_workload(); // Initialize tracing let log_path = benchmark_config.log_path.trim(); @@ -301,10 +445,12 @@ async fn start_bench() -> Result<()> { Some(guard) }; - let (contract_config, seed, snapshot_start_nonce) = if args.recover { + info!("Resolved workload: {:?}", workload); + + // contract_config is only required for erc20 / swap workloads. + // eip7702 only needs ETH funding + a delegate target contract. + let (contract_config, seed, snapshot_start_nonce, snapshot_eip7702_delegate) = if args.recover { info!("Starting in recovery mode..."); - let contract_config = - ContractConfig::load_from_file(&benchmark_config.contract_config_path).unwrap(); let snapshot_json = std::fs::read_to_string("snapshot.json").unwrap_or_else(|e| { panic!("Failed to read snapshot.json in recovery mode: {}", e); }); @@ -316,30 +462,45 @@ async fn start_bench() -> Result<()> { let mut seed = [0u8; 32]; seed.copy_from_slice(&seed_bytes); info!("Recovered faucet_start_nonce: {}", snapshot.faucet_start_nonce); - (contract_config, seed, Some(snapshot.faucet_start_nonce)) + + let contract_config = match workload { + WorkloadType::Eip7702 => None, + WorkloadType::Erc20 | WorkloadType::Swap => Some( + ContractConfig::load_from_file(&benchmark_config.contract_config_path).unwrap(), + ), + }; + (contract_config, seed, Some(snapshot.faucet_start_nonce), snapshot.eip7702_delegate) } else { info!("Starting in normal mode..."); - let mut command = format!( - "python scripts/deploy.py --private-key \"{}\" --num-tokens {} --output-file \"{}\" --rpc-url \"{}\"", - benchmark_config.faucet.private_key, - benchmark_config.num_tokens, - benchmark_config.contract_config_path, - benchmark_config.nodes[0].rpc_url - ); - if benchmark_config.enable_swap_token { - command.push_str(" --enable-swap-token"); - } - let res = run_command(&command).unwrap(); - info!("{}", String::from_utf8_lossy(&res.stdout)); - let contract_config = ContractConfig::load_from_file( - &benchmark_config.contract_config_path, - ) - .unwrap_or_else(|e| { - panic!("Contract config file not found {}", e); - }); + let contract_config = match workload { + WorkloadType::Eip7702 => { + info!("Skipping deploy.py (EIP-7702 workload only needs ETH + delegate)"); + None + } + WorkloadType::Erc20 | WorkloadType::Swap => { + let mut command = format!( + "python scripts/deploy.py --private-key \"{}\" --num-tokens {} --output-file \"{}\" --rpc-url \"{}\"", + benchmark_config.faucet.private_key, + benchmark_config.num_tokens, + benchmark_config.contract_config_path, + benchmark_config.nodes[0].rpc_url + ); + if matches!(workload, WorkloadType::Swap) { + command.push_str(" --enable-swap-token"); + } + let res = run_command(&command).unwrap(); + info!("{}", String::from_utf8_lossy(&res.stdout)); + Some( + ContractConfig::load_from_file(&benchmark_config.contract_config_path) + .unwrap_or_else(|e| { + panic!("Contract config file not found {}", e); + }), + ) + } + }; let seed: [u8; 32] = rand::random(); - (contract_config, seed, None) + (contract_config, seed, None, None) }; tracing::info!("Account generator seed: 0x{}", hex::encode(seed)); @@ -390,6 +551,9 @@ async fn start_bench() -> Result<()> { // fees outside the cascade) to scale the cascade UP to what's actually // available, rather than under-using a well-funded faucet. // The FaucetTreePlanBuilder assert is the final backstop for absurd inputs. + // Always honor configured faucet_eth_balance (clamped to on-chain). + // Previously we scaled UP to 99% of on-chain when configured < balance; that + // drained a shared multi-process faucet in one leg and broke the others. let effective_faucet_eth = if on_chain_faucet_balance < configured_faucet_eth { tracing::warn!( "faucet_eth_balance ({}) exceeds on-chain balance ({}); clamping down.", @@ -397,15 +561,6 @@ async fn start_bench() -> Result<()> { on_chain_faucet_balance ); on_chain_faucet_balance - } else if configured_faucet_eth < on_chain_faucet_balance { - // Configured is smaller than on-chain. Scale the cascade up to (on-chain - 1%) - // so a well-funded faucet isn't under-used; the 1% headroom covers misc fees - // outside the cascade. - // Note: if on_chain == 0, headroom == 0 and usable == 0 — the cascade builder's - // assert will catch this with a clear panic before any U256 underflow. - let headroom = on_chain_faucet_balance / U256::from(100); - let usable = on_chain_faucet_balance - headroom; - usable } else { configured_faucet_eth }; @@ -419,15 +574,58 @@ async fn start_bench() -> Result<()> { let mut start_nonce = snapshot_start_nonce.unwrap_or(on_chain_nonce); info!("Faucet on-chain nonce: {}, using start_nonce: {}", on_chain_nonce, start_nonce); - // Save snapshot in normal mode (after we know the start_nonce) + // EIP-7702: deploy (or recover) the shared delegate target before the ETH + // cascade so the cascade's faucet_start_nonce is exclusive of the deploy tx. + let eip7702_delegate = if matches!(workload, WorkloadType::Eip7702) { + let delegate = if args.recover { + recover_eip7702_delegate( + eth_clients[0].as_ref(), + snapshot_eip7702_delegate.as_deref(), + faucet_address, + start_nonce, + ) + .await? + } else { + let (addr, next_nonce) = deploy_eip7702_delegate( + eth_clients[0].as_ref(), + &benchmark_config.faucet.private_key, + chain_id, + start_nonce, + ) + .await?; + start_nonce = next_nonce; + addr + }; + Some(delegate) + } else { + None + }; + + // Save snapshot in normal mode (after we know the cascade start_nonce and + // any EIP-7702 delegate address). if !args.recover { - let snapshot = Snapshot { seed: hex::encode(seed), faucet_start_nonce: start_nonce }; + let snapshot = Snapshot { + seed: hex::encode(seed), + faucet_start_nonce: start_nonce, + eip7702_delegate: eip7702_delegate.map(|a| format!("{:#x}", a)), + }; let snapshot_json = serde_json::to_string_pretty(&snapshot).unwrap(); std::fs::write("snapshot.json", &snapshot_json).unwrap_or_else(|e| { panic!("Failed to write snapshot.json: {}", e); }); info!("Snapshot saved to snapshot.json"); } + // ERC20/swap need extra gas headroom for later token ops. + // EIP-7702 multiSend circulates tiny ETH among workers; leave 0 so the + // cascade pushes full share to leaves (gas + multiSend principal). + let remained_eth = match workload { + WorkloadType::Eip7702 => U256::ZERO, + WorkloadType::Erc20 | WorkloadType::Swap => { + U256::from(benchmark_config.num_tokens) + * U256::from(21000) + * U256::from(1000_000_000_000u64) + } + }; let eth_faucet_builder = PlanBuilder::create_faucet_tree_plan_builder( benchmark_config.faucet.faucet_level as usize, effective_faucet_eth, @@ -435,9 +633,7 @@ async fn start_bench() -> Result<()> { start_nonce, account_addresses.clone(), Arc::new(EthFaucetTxnBuilder), - U256::from(benchmark_config.num_tokens) - * U256::from(21000) - * U256::from(1000_000_000_000u64), + remained_eth, &mut accout_generator, ) .await @@ -452,27 +648,30 @@ async fn start_bench() -> Result<()> { ) .start(); - let tokens = contract_config.get_all_token(); + let tokens = contract_config.as_ref().map(|c| c.get_all_token()).unwrap_or_default(); let mut tokens_plan = Vec::new(); - for token in &tokens { - start_nonce += benchmark_config.faucet.faucet_level as u64; - info!("distributing token: {}", token.address); - let token_address = Address::from_str(&token.address).unwrap(); - let faucet_token_balance = U256::from_str(&token.faucet_balance).unwrap(); - info!("balance of token: {}", faucet_token_balance); - let token_faucet_builder = PlanBuilder::create_faucet_tree_plan_builder( - benchmark_config.faucet.faucet_level as usize, - faucet_token_balance, - &benchmark_config.faucet.private_key, - start_nonce, - account_addresses.clone(), - Arc::new(Erc20FaucetTxnBuilder::new(token_address)), - U256::ZERO, - &mut accout_generator, - ) - .await - .unwrap(); - tokens_plan.push(token_faucet_builder); + // Skip ERC20 token cascade for EIP-7702 (workers only need ETH for gas). + if !matches!(workload, WorkloadType::Eip7702) { + for token in &tokens { + start_nonce += benchmark_config.faucet.faucet_level as u64; + info!("distributing token: {}", token.address); + let token_address = Address::from_str(&token.address).unwrap(); + let faucet_token_balance = U256::from_str(&token.faucet_balance).unwrap(); + info!("balance of token: {}", faucet_token_balance); + let token_faucet_builder = PlanBuilder::create_faucet_tree_plan_builder( + benchmark_config.faucet.faucet_level as usize, + faucet_token_balance, + &benchmark_config.faucet.private_key, + start_nonce, + account_addresses.clone(), + Arc::new(Erc20FaucetTxnBuilder::new(token_address)), + U256::ZERO, + &mut accout_generator, + ) + .await + .unwrap(); + tokens_plan.push(token_faucet_builder); + } } let account_manager = accout_generator.to_manager(); @@ -552,14 +751,35 @@ async fn start_bench() -> Result<()> { let tps = benchmark_config.target_tps as usize; let duration_secs = benchmark_config.performance.duration_secs; - if benchmark_config.enable_swap_token { - info!("bench uniswap"); - test_uniswap(address_pool, chain_id, contract_config, &producer, tps, duration_secs) - .await?; - } else { - info!("bench erc20 transfer"); - test_erc20_transfer(address_pool, chain_id, contract_config, &producer, tps, duration_secs) + match workload { + WorkloadType::Swap => { + info!("bench uniswap"); + let contract_config = contract_config.expect("swap workload requires contract config"); + test_uniswap(address_pool, chain_id, contract_config, &producer, tps, duration_secs) + .await?; + } + WorkloadType::Erc20 => { + info!("bench erc20 transfer"); + let contract_config = contract_config.expect("erc20 workload requires contract config"); + test_erc20_transfer( + address_pool, + chain_id, + contract_config, + &producer, + tps, + duration_secs, + ) .await?; + } + WorkloadType::Eip7702 => { + let delegate = eip7702_delegate.expect("eip7702 delegate must be set"); + info!( + "bench EIP-7702 SetCode + ETH multiSend (delegate={:#x}, batch={})", + delegate, + txn_plan::constructor::EIP7702_DEFAULT_BATCH_SIZE + ); + test_eip7702(chain_id, delegate, address_pool, &producer, tps, duration_secs).await?; + } } Ok(()) } diff --git a/src/txn_plan/constructor/eip7702.rs b/src/txn_plan/constructor/eip7702.rs new file mode 100644 index 0000000..3b73fd9 --- /dev/null +++ b/src/txn_plan/constructor/eip7702.rs @@ -0,0 +1,176 @@ +use crate::{ + eth::{BENCH_MAX_FEE_PER_GAS, BENCH_MAX_PRIORITY_FEE_PER_GAS}, + txn_plan::{addr_pool::AddressPool, FromTxnConstructor}, + util::gen_account::{AccountId, AccountManager}, +}; +use alloy::{ + eips::eip7702::Authorization, + network::{TransactionBuilder, TransactionBuilder7702}, + primitives::{Address, Bytes, U256}, + rpc::types::TransactionRequest, + signers::SignerSync, + sol, + sol_types::SolCall, +}; +use anyhow::Context; +use std::sync::Arc; + +sol! { + /// EIP-7702 delegation target: batch ETH transfers in the EOA context. + interface IBatchExecutor { + function multiSend(address[] calldata recipients, uint256[] calldata amounts) external payable; + } +} + +/// Default number of recipients per multiSend type-4 tx. +pub const EIP7702_DEFAULT_BATCH_SIZE: usize = 4; + +/// Default ETH amount sent to each recipient (1 gwei). +/// Funds circulate among workers, so the amount can stay tiny. +pub const EIP7702_DEFAULT_AMOUNT_PER_RECIPIENT: u64 = 1_000_000_000; + +/// Self-sponsored EIP-7702 SetCode (type-4) + ETH multi-send constructor. +/// +/// Each worker: +/// 1. Signs an authorization for itself (`authority == sender`) with +/// `auth.nonce = tx.nonce + 1` (Pectra: sender nonce is bumped before +/// authorization processing). +/// 2. Builds a type-4 tx **to itself** with calldata +/// `multiSend(recipients, amounts)` so the delegated code runs in the +/// EOA context and spends the EOA's ETH. +/// +/// Chain effect on the sender: nonce advances by **2** (tx + auth). +pub struct Eip7702Constructor { + pub chain_id: u64, + pub delegate: Address, + pub address_pool: Arc, + pub batch_size: usize, + pub amount_per_recipient: U256, +} + +impl Eip7702Constructor { + pub fn new( + chain_id: u64, + delegate: Address, + address_pool: Arc, + batch_size: usize, + amount_per_recipient: U256, + ) -> Self { + Self { chain_id, delegate, address_pool, batch_size, amount_per_recipient } + } + + pub fn with_defaults( + chain_id: u64, + delegate: Address, + address_pool: Arc, + ) -> Self { + Self::new( + chain_id, + delegate, + address_pool, + EIP7702_DEFAULT_BATCH_SIZE, + U256::from(EIP7702_DEFAULT_AMOUNT_PER_RECIPIENT), + ) + } +} + +impl FromTxnConstructor for Eip7702Constructor { + fn build_for_sender( + &self, + from_account_id: AccountId, + account_generator: AccountManager, + nonce: u64, + ) -> Result { + let from_address = account_generator.get_address_by_id(from_account_id); + let signer = account_generator.get_signer_by_id(from_account_id); + + let batch_size = self.batch_size.max(1); + let mut recipients = Vec::with_capacity(batch_size); + let mut amounts = Vec::with_capacity(batch_size); + for _ in 0..batch_size { + let to_id = self.address_pool.select_receiver(from_account_id); + recipients.push(account_generator.get_address_by_id(to_id)); + amounts.push(self.amount_per_recipient); + } + + let call_data = + Bytes::from(IBatchExecutor::multiSendCall { recipients, amounts }.abi_encode()); + + // Self-sponsored: auth nonce is sender's post-tx-check nonce (= N+1). + let auth = Authorization { + chain_id: U256::from(self.chain_id), + address: self.delegate, + nonce: nonce + 1, + }; + let sig = signer + .sign_hash_sync(&auth.signature_hash()) + .context("failed to sign EIP-7702 authorization")?; + let signed_auth = auth.into_signed(sig); + + // Critical: `to` must be the EOA so delegated runtime runs in EOA + // context and multiSend spends the EOA's ETH. Calling the template + // address would execute with address(this) == template. + let tx_request = TransactionRequest::default() + .with_from(from_address) + .with_to(from_address) + .with_input(call_data) + .with_nonce(nonce) + .with_chain_id(self.chain_id) + .with_max_priority_fee_per_gas(BENCH_MAX_PRIORITY_FEE_PER_GAS) + .with_max_fee_per_gas(BENCH_MAX_FEE_PER_GAS) + .with_gas_limit(EIP7702_SET_CODE_GAS_LIMIT) + .with_authorization_list(vec![signed_auth]); + + Ok(tx_request) + } + + fn description(&self) -> &'static str { + "EIP-7702 SetCode + ETH multiSend (self-sponsored)" + } + + fn nonce_increment(&self) -> u32 { + // Tx nonce check bumps once, then the matching self-auth bumps again. + 2 + } +} + +/// Gas limit for type-4 SetCode + multiSend (default K=4 cold-ish CALLs). +/// 7702 intrinsic/auth overhead + loop of value CALLs. +pub const EIP7702_SET_CODE_GAS_LIMIT: u64 = 350_000; + +/// Gas limit for deploying the BatchExecutor delegate target. +pub const EIP7702_DELEGATE_DEPLOY_GAS_LIMIT: u64 = 500_000; + +/// Creation bytecode for `contracts/BatchExecutor.sol` (solc 0.8.21, --optimize). +/// +/// Runtime exposes `multiSend(address[],uint256[])` (selector `0xbb4c9f0b`). +/// Designed as an EIP-7702 delegation target: after set-code, call the EOA +/// with multiSend so value transfers use the EOA balance. +pub fn delegate_contract_bytecode() -> Vec { + // solc --bin --optimize --optimize-runs 200 contracts/BatchExecutor.sol + // Includes receive/fallback so multiSend into already-delegated EOAs succeeds. + const HEX: &str = concat!( + "608060405234801561000f575f80fd5b506102778061001d5f395ff3fe608060", + "40526004361061001e575f3560e01c8063bb4c9f0b1461002757005b36610025", + "57005b005b610025610035366004610199565b82818114610070576040516246", + "1bcd60e51b81526020600482015260036024820152623632b760e91b60448201", + "526064015b60405180910390fd5b5f5b81811015610149575f86868381811061", + "008d5761008d610200565b90506020020160208101906100a29190610214565b", + "6001600160a01b03168585848181106100bd576100bd610200565b9050602002", + "01356040515f6040518083038185875af1925050503d805f8114610101576040", + "519150601f19603f3d011682016040523d82523d5f602084013e610106565b60", + "6091505b50509050806101405760405162461bcd60e51b815260040161006790", + "6020808252600490820152631cd95b9960e21b604082015260600190565b5060", + "0101610072565b505050505050565b5f8083601f840112610161575f80fd5b50", + "813567ffffffffffffffff811115610178575f80fd5b60208301915083602082", + "60051b8501011115610192575f80fd5b9250929050565b5f805f806040858703", + "12156101ac575f80fd5b843567ffffffffffffffff808211156101c3575f80fd", + "5b6101cf88838901610151565b909650945060208701359150808211156101e7", + "575f80fd5b506101f487828801610151565b95989497509550505050565b634e", + "487b7160e01b5f52603260045260245ffd5b5f60208284031215610224575f80", + "fd5b81356001600160a01b038116811461023a575f80fd5b939250505056fea2", + "646970667358221220a2fc7415ad69a852d05958c007f06988587d8766968d3b", + "7a734a15534d07c51064736f6c63430008150033", + ); + hex::decode(HEX).expect("invalid BatchExecutor creation bytecode hex") +} diff --git a/src/txn_plan/constructor/faucet.rs b/src/txn_plan/constructor/faucet.rs index 2b44a04..95dea15 100644 --- a/src/txn_plan/constructor/faucet.rs +++ b/src/txn_plan/constructor/faucet.rs @@ -15,9 +15,10 @@ use std::{ }; use tracing::info; -// Per-transaction gas cost budget (in wei). Bumped in lockstep with -// BENCH_MAX_FEE_PER_GAS = 5000 Gwei × worst-case 100k gas = 5e17 wei = 0.5 ETH/txn. -const GAS_COST_PER_TXN_BUDGET: u64 = 500_000_000_000_000_000; +// Per-transaction gas cost budget (in wei). Keep in lockstep with +// BENCH_MAX_FEE_PER_GAS × worst-case gas (EIP-7702 SetCode multiSend): +// 1000 Gwei × 350_000 = 3.5e17 wei = 0.35 ETH/txn. +const GAS_COST_PER_TXN_BUDGET: u64 = 350_000_000_000_000_000; static NONCE_MAP: std::sync::OnceLock>>>> = std::sync::OnceLock::new(); diff --git a/src/txn_plan/constructor/mod.rs b/src/txn_plan/constructor/mod.rs index 0c4b4d2..5d66b5a 100644 --- a/src/txn_plan/constructor/mod.rs +++ b/src/txn_plan/constructor/mod.rs @@ -1,11 +1,16 @@ mod approve; mod distribute_token; +mod eip7702; mod erc20_transfer; mod faucet; mod swap_token_2_token; pub use approve::ApproveTokenConstructor; pub use distribute_token::SwapEthToTokenConstructor; +pub use eip7702::{ + delegate_contract_bytecode, Eip7702Constructor, EIP7702_DEFAULT_BATCH_SIZE, + EIP7702_DELEGATE_DEPLOY_GAS_LIMIT, +}; pub use erc20_transfer::Erc20TransferConstructor; pub use faucet::FaucetTreePlanBuilder; pub use swap_token_2_token::SwapTokenToTokenConstructor; diff --git a/src/txn_plan/faucet_plan.rs b/src/txn_plan/faucet_plan.rs index 1fcb36d..92f075c 100644 --- a/src/txn_plan/faucet_plan.rs +++ b/src/txn_plan/faucet_plan.rs @@ -167,6 +167,7 @@ impl TxnPlan for LevelFaucetPlan { let metadata = Arc::new(TxnMetadata { from_account: Arc::new(sender_signer.address()), nonce, + nonce_increment: 1, from_account_id: *sender_signer_id, txn_id: Uuid::new_v4(), plan_id: plan_id.clone(), diff --git a/src/txn_plan/plan.rs b/src/txn_plan/plan.rs index 99a7926..a75e350 100644 --- a/src/txn_plan/plan.rs +++ b/src/txn_plan/plan.rs @@ -100,6 +100,7 @@ impl TxnPlan for ManyToOnePlan { let metadata = Arc::new(TxnMetadata { from_account: Arc::new(address), nonce: *nonce as u64, + nonce_increment: constructor.nonce_increment(), txn_id: Uuid::new_v4(), from_account_id: *from_account_id, plan_id: plan_id.clone(), @@ -210,6 +211,7 @@ impl TxnPlan for OneToManyPlan { ), from_account_id, nonce: 0, + nonce_increment: 1, txn_id: Uuid::new_v4(), plan_id: plan_id.clone(), }); diff --git a/src/txn_plan/plan_builder.rs b/src/txn_plan/plan_builder.rs index a0e6f7c..806c02b 100644 --- a/src/txn_plan/plan_builder.rs +++ b/src/txn_plan/plan_builder.rs @@ -10,8 +10,8 @@ use crate::{ txn_plan::{ addr_pool::AddressPool, constructor::{ - ApproveTokenConstructor, Erc20TransferConstructor, FaucetTreePlanBuilder, - SwapEthToTokenConstructor, SwapTokenToTokenConstructor, + ApproveTokenConstructor, Eip7702Constructor, Erc20TransferConstructor, + FaucetTreePlanBuilder, SwapEthToTokenConstructor, SwapTokenToTokenConstructor, }, faucet_txn_builder::FaucetTxnBuilder, plan::ManyToOnePlan, @@ -116,4 +116,20 @@ impl PlanBuilder { let plan = plan.with_size(size); Box::new(plan) } + + /// Create EIP-7702 self-sponsored SetCode + ETH multiSend plan. + /// + /// Each type-4 tx re-delegates the sender to `delegate` and calls the + /// sender EOA with `multiSend` to `batch_size` pool recipients. + pub fn eip7702_set_code( + chain_id: u64, + delegate: Address, + address_pool: Arc, + size: usize, + ) -> Box { + let constructor = Eip7702Constructor::with_defaults(chain_id, delegate, address_pool); + let plan = ManyToOnePlan::new(constructor, PlanExecutionMode::Partial(size)); + let plan = plan.with_size(size); + Box::new(plan) + } } diff --git a/src/txn_plan/traits.rs b/src/txn_plan/traits.rs index 38fc8a9..b5862f9 100644 --- a/src/txn_plan/traits.rs +++ b/src/txn_plan/traits.rs @@ -22,7 +22,11 @@ pub struct TxnMetadata { pub plan_id: PlanId, pub from_account: Arc
, pub from_account_id: AccountId, + /// Tx sender nonce used for this transaction. pub nonce: u64, + /// How many nonces the chain advances for the sender after this tx is + /// included. Most txs use 1; self-sponsored EIP-7702 uses 2 (tx + auth). + pub nonce_increment: u32, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -59,6 +63,12 @@ pub trait FromTxnConstructor: Send + Sync + 'static { /// Provide transaction description. fn description(&self) -> &'static str; + + /// Nonce advance applied to the sender after this tx is included. + /// Override for workloads where the chain bumps more than once (e.g. EIP-7702 self-sponsor). + fn nonce_increment(&self) -> u32 { + 1 + } } pub trait ToTxnConstructor: Send + Sync + 'static {