Skip to content
Draft
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
11,871 changes: 712 additions & 11,159 deletions Cargo.lock

Large diffs are not rendered by default.

56 changes: 42 additions & 14 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
[workspace]
resolver = "2"
members = ["crates/mempool-rebroadcaster", "crates/flashblocks-archiver", "crates/sidecrush"]
members = ["crates/mempool-rebroadcaster", "crates/sidecrush", "crates/gobrr", "bin/gobrr"]

[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"

[workspace.lints.rust]
missing-debug-implementations = "warn"
unreachable-pub = "warn"
unused-must-use = "deny"

[workspace.lints.clippy]
all = { level = "warn", priority = -1 }

[workspace.dependencies]
clap = { version = "4.0", features = ["derive", "env"] }
Expand All @@ -13,26 +26,41 @@ metrics = "0.24.1"
metrics-derive = "0.1"
cadence = "1.4"
cadence-macros = "1.4"
axum = "0.8"
tower-http = { version = "0.6", features = ["trace"] }
anyhow = "1.0"
url = "2.5"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
dotenvy = "0.15.7"
async-trait = "0.1"

# alloy
alloy-primitives = { version = "1.4.1", default-features = false, features = [
alloy-primitives = { version = "1.0", default-features = false, features = [
"map-foldhash",
"serde",
] }
alloy-genesis = { version = "1.0.41", default-features = false }
alloy-eips = { version = "1.0.41", default-features = false }
alloy-rpc-types = { version = "1.0.41", default-features = false }
alloy-rpc-types-engine = { version = "1.0.41", default-features = false }
alloy-rpc-types-eth = { version = "1.0.41" }
alloy-consensus = { version = "1.0.41" }
alloy-trie = { version = "0.9.1", default-features = false }
alloy-provider = { version = "1.0.41" }
alloy-hardforks = { version = "0.4.4" }
alloy-rpc-client = { version = "1.0.41" }
alloy-transport-http = { version = "1.0.41" }
alloy-genesis = { version = "1.0", default-features = false }
alloy-eips = { version = "1.0", default-features = false }
alloy-rpc-types = { version = "1.0", default-features = false }
alloy-rpc-types-engine = { version = "1.0", default-features = false }
alloy-rpc-types-eth = { version = "1.0" }
alloy-consensus = { version = "1.0" }
alloy-trie = { version = "0.9", default-features = false }
alloy-provider = { version = "1.0" }
alloy-hardforks = { version = "0.4" }
alloy-rpc-client = { version = "1.0" }
alloy-transport-http = { version = "1.0" }
alloy-signer = { version = "1.0" }
alloy-signer-local = { version = "1.0", features = ["mnemonic"] }
alloy-network = { version = "1.0" }

# op-alloy
op-alloy-rpc-types = { version = "0.22.0", default-features = false }
op-alloy-rpc-types-engine = { version = "0.22.0", default-features = false }
op-alloy-rpc-jsonrpsee = { version = "0.22.0", default-features = false }
op-alloy-network = { version = "0.22.0", default-features = false }
op-alloy-consensus = { version = "0.22.0", default-features = false }
op-alloy-network = { version = "0.22.0" }

# internal
gobrr = { path = "crates/gobrr" }
20 changes: 20 additions & 0 deletions bin/gobrr/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "gobrr-bin"
version.workspace = true
edition.workspace = true
license.workspace = true

[lints]
workspace = true

[[bin]]
name = "gobrr"
path = "main.rs"

[dependencies]
gobrr = { workspace = true }
clap = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
15 changes: 15 additions & 0 deletions bin/gobrr/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
use clap::Parser;
use gobrr::{Args, run_load_test};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.init();

let args = Args::parse();
run_load_test(args).await
}
32 changes: 32 additions & 0 deletions crates/gobrr/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[package]
name = "gobrr"
version.workspace = true
edition.workspace = true
license.workspace = true

[lints]
workspace = true

[lib]
path = "src/lib.rs"

[dependencies]
clap = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
rand = { workspace = true }
url = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
alloy-primitives = { workspace = true }
alloy-provider = { workspace = true }
alloy-network = { workspace = true }
alloy-signer = { workspace = true }
alloy-signer-local = { workspace = true }
alloy-consensus = { workspace = true }
alloy-rpc-types-eth = { workspace = true }
alloy-rpc-client = { workspace = true }
alloy-transport-http = { workspace = true }
op-alloy-network = { workspace = true }
63 changes: 63 additions & 0 deletions crates/gobrr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# gobrr

Ethereum/OP Stack load tester that derives wallets from a mnemonic, funds them, and spams RPC endpoints with transactions.

## Build

```bash
cargo build --release -p gobrr-bin
```

## Usage

```bash
./target/release/gobrr \
--rpc "https://sepolia.base.org" \
--mnemonic "your twelve word mnemonic phrase here" \
--funder-key "0xYOUR_PRIVATE_KEY" \
--funding-amount 10000000000000000 \
--sender-count 10 \
--duration 5m
```

## Key Options

| Option | Default | Description |
|--------|---------|-------------|
| `--rpc` | - | Single RPC endpoint |
| `--rpc-endpoints` | - | Comma-separated endpoints for load distribution |
| `--endpoint-distribution` | `round-robin` | `round-robin`, `random`, or `weighted` |
| `--network` | `custom` | `sepolia` (base-sepolia.cbhq.net), `sepolia-alpha` (base-sepolia-alpha.cbhq.net), or `custom` |
| `--mnemonic` | - | HD wallet mnemonic for sender accounts |
| `--funder-key` | - | Private key with ETH to fund senders |
| `--funding-amount` | - | Wei to fund each sender |
| `--sender-count` | `10` | Number of concurrent senders |
| `--in-flight-per-sender` | `16` | Max concurrent requests per sender |
| `--rpc-methods` | `eth_sendRawTransaction` | Comma-separated RPC methods |
| `--tx-percentage` | `80` | Percentage of requests that are transactions |
| `--replay-mode` | `none` | `none`, `same-tx`, or `same-method` |
| `--replay-percentage` | `0` | Percentage of requests that are replays |
| `--duration` | - | Test duration (e.g., `60s`, `5m`). Omit to run until Ctrl+C |

## Examples

```bash
# Multi-endpoint load test
./target/release/gobrr \
--rpc-endpoints "https://rpc1.com,https://rpc2.com" \
--endpoint-distribution round-robin \
--mnemonic "..." --funder-key "0x..." --funding-amount 10000000000000000

# Mixed read/write load
./target/release/gobrr \
--rpc "https://sepolia.base.org" \
--rpc-methods "eth_sendRawTransaction,eth_getBalance,eth_blockNumber" \
--tx-percentage 50 \
--mnemonic "..." --funder-key "0x..." --funding-amount 10000000000000000

# Replay mode (test caching/deduplication)
./target/release/gobrr \
--rpc "https://sepolia.base.org" \
--replay-mode same-tx --replay-percentage 20 \
--mnemonic "..." --funder-key "0x..." --funding-amount 10000000000000000
```
127 changes: 127 additions & 0 deletions crates/gobrr/src/blocks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
use alloy_primitives::B256;
use alloy_provider::Provider;
use alloy_rpc_types_eth::BlockNumberOrTag;
use anyhow::{Context, Result};
use tokio::sync::broadcast;
use tracing::{debug, info, warn};

use crate::client::{self, Provider as OpProvider};

/// Block event broadcast to consumers
#[derive(Debug, Clone)]
pub(crate) struct BlockEvent {
pub(crate) block_num: u64,
pub(crate) tx_hashes: Vec<B256>,
pub(crate) gas_used: u64,
pub(crate) gas_limit: u64,
}

/// Watches for new blocks and broadcasts events to consumers
pub(crate) struct BlockWatcher {
provider: OpProvider,
}

impl BlockWatcher {
/// Creates a new `BlockWatcher`
pub(crate) fn new(http_client: reqwest::Client, rpc_url: &str) -> Result<Self> {
let provider = client::create_provider(http_client, rpc_url)?;
Ok(Self { provider })
}

/// Runs the block watcher loop, broadcasting block events
pub(crate) async fn run(
self,
block_tx: broadcast::Sender<BlockEvent>,
mut shutdown: broadcast::Receiver<()>,
) -> Result<()> {
// Get the starting block number
let mut last_block =
self.provider.get_block_number().await.context("Failed to get initial block number")?;

debug!(block = last_block, "Block watcher started");

loop {
tokio::select! {
biased;
_ = shutdown.recv() => {
debug!("Block watcher shutting down");
break;
}
_ = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
// Poll for new blocks
match self.provider.get_block_number().await {
Ok(current_block) => {
// Process any new blocks we haven't seen
while last_block < current_block {
last_block += 1;
if let Err(e) = self.broadcast_block(last_block, &block_tx).await {
warn!(block = last_block, error = %e, "Failed to fetch block");
}
}
}
Err(e) => {
warn!(error = %e, "Failed to get block number");
}
}
}
}
}

Ok(())
}

/// Fetches a block and broadcasts it to consumers
async fn broadcast_block(
&self,
block_num: u64,
block_tx: &broadcast::Sender<BlockEvent>,
) -> Result<()> {
let block = self
.provider
.get_block_by_number(BlockNumberOrTag::Number(block_num))
.await
.context("Failed to get block")?
.context("Block not found")?;

let tx_hashes: Vec<B256> = block.transactions.hashes().collect();

let event = BlockEvent {
block_num,
tx_hashes,
gas_used: block.header.gas_used,
gas_limit: block.header.gas_limit,
};

// Broadcast to all subscribers
if let Err(e) = block_tx.send(event) {
warn!(block = block_num, error = %e, "Failed to broadcast block event");
}

Ok(())
}
}

/// Runs a block logger that subscribes to block events and logs them.
/// Exits when the block channel closes, which happens after the drain period.
pub(crate) async fn run_block_logger(mut block_rx: broadcast::Receiver<BlockEvent>) {
loop {
match block_rx.recv().await {
Ok(block) => {
info!(
blockNum = block.block_num,
gasUsed = block.gas_used,
gasLimit = block.gas_limit,
txnCount = block.tx_hashes.len(),
"New block"
);
}
Err(broadcast::error::RecvError::Lagged(n)) => {
debug!(missed = n, "Block logger lagged behind");
}
Err(broadcast::error::RecvError::Closed) => {
debug!("Block broadcast channel closed, logger shutting down");
break;
}
}
}
}
28 changes: 28 additions & 0 deletions crates/gobrr/src/calldata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
use alloy_primitives::Bytes;
use rand::Rng;

const SMALL_CALLDATA_SIZE: usize = 20;

/// Generates random calldata of the specified size.
/// Uses high-entropy random bytes that are uncompressible.
fn generate_calldata(size: usize) -> Bytes {
let mut rng = rand::thread_rng();
let data: Vec<u8> = (0..size).map(|_| rng.r#gen()).collect();
Bytes::from(data)
}

/// Generates small calldata (20 bytes)
pub(crate) fn generate_small_calldata() -> Bytes {
generate_calldata(SMALL_CALLDATA_SIZE)
}

/// Generates large calldata of the specified max size
pub(crate) fn generate_large_calldata(max_size: usize) -> Bytes {
generate_calldata(max_size)
}

/// Decides whether to use large calldata based on the load percentage
pub(crate) fn should_use_large_calldata(load_percentage: u8) -> bool {
let mut rng = rand::thread_rng();
rng.gen_range(0..100) < load_percentage
}
Loading