Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 = <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`.
Expand Down
6 changes: 6 additions & 0 deletions bench_config.template
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 28 additions & 0 deletions contracts/BatchExecutor.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
12 changes: 11 additions & 1 deletion src/actors/producer/producer_actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,17 @@ impl Handler<UpdateSubmissionResult> 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!(
Expand Down
33 changes: 33 additions & 0 deletions src/config/bench_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<WorkloadType>,
#[serde(default)]
pub address_pool_type: AddressPoolType,
#[serde(default = "default_log_path")]
Expand Down Expand Up @@ -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
}
}
}
34 changes: 33 additions & 1 deletion src/eth/eth_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Bytes> {
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<TransactionReceipt> {
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<Account> {
// self.retry_with_backoff(|| async { self.inner[0].get_account(address).await })
// .await
Expand Down
19 changes: 12 additions & 7 deletions src/eth/txn_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
///
Expand Down
Loading
Loading