From e601de2a3fd31eb4dfe043825eaba19b426e5d82 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 23 Sep 2025 11:02:08 -0400 Subject: [PATCH 1/3] feat: add Flashbots Provider, ported from builder --- Cargo.toml | 5 +- src/lib.rs | 4 ++ src/utils/flashbots.rs | 124 +++++++++++++++++++++++++++++++++++++++++ src/utils/signer.rs | 9 +++ tests/flashbots.rs | 95 +++++++++++++++++++++++++++++++ 5 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 src/utils/flashbots.rs create mode 100644 tests/flashbots.rs diff --git a/Cargo.toml b/Cargo.toml index b5ac8bf..2844e53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ name = "init4-bin-base" description = "Internal utilities for binaries produced by the init4 team" keywords = ["init4", "bin", "base"] -version = "0.12.3" +version = "0.12.4" edition = "2021" rust-version = "1.81" authors = ["init4", "James Prestwich", "evalir"] @@ -59,6 +59,7 @@ aws-config = { version = "1.1.7", optional = true } aws-sdk-kms = { version = "1.15.0", optional = true } reqwest = { version = "0.12.15", optional = true } rustls = { version = "0.23.31", optional = true } +serde_json = { version = "1.0.145", optional = true } [dev-dependencies] ajj = "0.3.1" @@ -71,9 +72,11 @@ tokio = { version = "1.43.0", features = ["macros"] } [features] default = ["alloy", "rustls"] alloy = ["dep:alloy"] +flashbots = ["alloy", "aws", "alloy?/json-rpc", "dep:eyre", "dep:reqwest", "dep:serde_json"] aws = ["alloy", "alloy?/signer-aws", "dep:async-trait", "dep:aws-config", "dep:aws-sdk-kms"] perms = ["dep:oauth2", "dep:tokio", "dep:reqwest", "dep:signet-tx-cache", "dep:eyre", "dep:axum", "dep:tower"] rustls = ["dep:rustls", "rustls/aws-lc-rs"] +serde_json = ["dep:serde_json"] [[example]] name = "oauth" diff --git a/src/lib.rs b/src/lib.rs index 57d7052..6ef6a7d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,10 @@ pub mod utils { /// slot. pub mod calc; + /// A simple interface to flashbots RPC endpoints. + #[cfg(feature = "flashbots")] + pub mod flashbots; + /// [`FromEnv`], [`FromEnvVar`] traits and related utilities. /// /// [`FromEnv`]: from_env::FromEnv diff --git a/src/utils/flashbots.rs b/src/utils/flashbots.rs new file mode 100644 index 0000000..6fa6051 --- /dev/null +++ b/src/utils/flashbots.rs @@ -0,0 +1,124 @@ +//! A generic Flashbots bundle API wrapper. +use crate::utils::signer::LocalOrAws; +use alloy::{ + primitives::{keccak256, BlockNumber}, + rpc::{ + json_rpc::{Id, Response, ResponsePayload, RpcRecv, RpcSend}, + types::mev::{EthBundleHash, MevSendBundle, SimBundleResponse}, + }, + signers::Signer, +}; +use init4_from_env_derive::FromEnv; +use reqwest::header::CONTENT_TYPE; +use serde_json::json; +use std::borrow::Cow; + +/// Configuration for the Flashbots provider. +#[derive(Debug, Clone, FromEnv)] +#[from_env(crate)] +pub struct FlashbotsConfig { + /// Flashbots endpoint for privately submitting rollup blocks. + #[from_env( + var = "FLASHBOTS_ENDPOINT", + desc = "Flashbots endpoint for privately submitting rollup blocks", + optional + )] + pub flashbots_endpoint: Option, +} + +impl FlashbotsConfig { + /// Make a [`Flashbots`] instance from this config, using the specified signer. + pub fn build(&self, signer: LocalOrAws) -> Option { + self.flashbots_endpoint + .as_ref() + .map(|url| Flashbots::new(url.clone(), signer)) + } +} + +/// A wrapper over a `Provider` that adds Flashbots MEV bundle helpers. +#[derive(Debug)] +pub struct Flashbots { + /// The base URL for the Flashbots API. + pub relay_url: url::Url, + + /// Signer is loaded once at startup. + signer: LocalOrAws, +} + +impl Flashbots { + /// Wraps a provider with the URL and returns a new `FlashbotsProvider`. + pub const fn new(relay_url: url::Url, signer: LocalOrAws) -> Self { + Self { relay_url, signer } + } + + /// Sends a bundle via `mev_sendBundle`. + pub async fn send_bundle(&self, bundle: &MevSendBundle) -> eyre::Result { + self.raw_call("mev_sendBundle", &[bundle]).await + } + + /// Simulate a bundle via `mev_simBundle`. + pub async fn simulate_bundle(&self, bundle: &MevSendBundle) -> eyre::Result<()> { + let resp: SimBundleResponse = self.raw_call("mev_simBundle", &[bundle]).await?; + dbg!("successfully simulated bundle", &resp); + Ok(()) + } + + /// Fetches the bundle status by hash + pub async fn bundle_status( + &self, + hash: EthBundleHash, + block_number: BlockNumber, + ) -> eyre::Result<()> { + let params = json!({ "bundleHash": hash, "blockNumber": block_number }); + let _resp: serde_json::Value = self + .raw_call("flashbots_getBundleStatsV2", &[params]) + .await?; + + Ok(()) + } + + /// Makes a raw JSON-RPC call with the Flashbots signature header to the method with the given params. + async fn raw_call( + &self, + method: &str, + params: &Params, + ) -> eyre::Result { + let req = alloy::rpc::json_rpc::Request::new( + Cow::Owned(method.to_string()), + Id::Number(1), + params, + ); + let body_bz = serde_json::to_vec(&req)?; + drop(req); + + let value = self.compute_signature(&body_bz).await?; + + let client = reqwest::Client::new(); + let resp = client + .post(self.relay_url.as_str()) + .header(CONTENT_TYPE, "application/json") + .header("X-Flashbots-Signature", value) + .body(body_bz) + .send() + .await?; + + let resp: Response = resp.json().await?; + + match resp.payload { + ResponsePayload::Success(payload) => Ok(payload), + ResponsePayload::Failure(err) => { + eyre::bail!("flashbots error: {err}"); + } + } + } + + /// Builds an EIP-191 signature for the given body bytes. + async fn compute_signature(&self, body_bz: &[u8]) -> Result { + let payload = keccak256(body_bz).to_string(); + let signature = self.signer.sign_message(payload.as_ref()).await?; + dbg!(signature.to_string()); + let address = self.signer.address(); + let value = format!("{address}:{signature}"); + Ok(value) + } +} diff --git a/src/utils/signer.rs b/src/utils/signer.rs index f864fbe..f312b41 100644 --- a/src/utils/signer.rs +++ b/src/utils/signer.rs @@ -1,6 +1,7 @@ use crate::utils::from_env::FromEnv; use alloy::{ consensus::SignableTransaction, + network::{Ethereum, EthereumWallet, IntoWallet}, primitives::{Address, ChainId, B256}, signers::{ aws::{AwsSigner, AwsSignerError}, @@ -169,3 +170,11 @@ impl alloy::signers::Signer for LocalOrAws { } } } + +impl IntoWallet for LocalOrAws { + type NetworkWallet = EthereumWallet; + + fn into_wallet(self) -> Self::NetworkWallet { + EthereumWallet::from(self) + } +} diff --git a/tests/flashbots.rs b/tests/flashbots.rs new file mode 100644 index 0000000..6a85a84 --- /dev/null +++ b/tests/flashbots.rs @@ -0,0 +1,95 @@ +#![cfg(feature = "flashbots")] + +use alloy::{ + eips::Encodable2718, + network::EthereumWallet, + primitives::{B256, U256}, + providers::{ + fillers::{ + BlobGasFiller, ChainIdFiller, FillProvider, GasFiller, JoinFill, NonceFiller, + WalletFiller, + }, + Identity, Provider, ProviderBuilder, SendableTx, + }, + rpc::types::{ + mev::{BundleItem, MevSendBundle, ProtocolVersion}, + TransactionRequest, + }, + signers::{local::PrivateKeySigner, Signer}, +}; +use init4_bin_base::utils::{flashbots::Flashbots, signer::LocalOrAws}; +use std::sync::LazyLock; +use url::Url; + +const FLASHBOTS_URL: LazyLock = LazyLock::new(|| { + Url::parse("https://relay-sepolia.flashbots.net:443").expect("valid flashbots url") +}); +const BUILDER_KEY: LazyLock = LazyLock::new(|| { + LocalOrAws::Local(PrivateKeySigner::from_bytes(&B256::repeat_byte(0x02)).unwrap()) +}); +const TEST_PROVIDER: LazyLock = LazyLock::new(get_test_provider); + +fn get_test_provider() -> Flashbots { + Flashbots::new(FLASHBOTS_URL.clone(), BUILDER_KEY.clone()) +} + +fn get_sepolia() -> FillProvider< + JoinFill< + JoinFill< + Identity, + JoinFill>>, + >, + WalletFiller, + >, + alloy::providers::RootProvider, +> { + ProviderBuilder::new() + .wallet(BUILDER_KEY.clone()) + .connect_http( + "https://ethereum-sepolia-rpc.publicnode.com" + .parse() + .unwrap(), + ) +} + +#[tokio::test] +#[ignore = "integration test"] +async fn test_simulate_valid_bundle_sepolia() { + let flashbots = &*TEST_PROVIDER; + let sepolia = get_sepolia(); + + let req = TransactionRequest::default() + .to(BUILDER_KEY.address()) + .value(U256::from(1u64)) + .gas_limit(51_000) + .from(BUILDER_KEY.address()); + let SendableTx::Envelope(tx) = sepolia.fill(req).await.unwrap() else { + panic!("expected filled tx"); + }; + let tx_bytes = tx.encoded_2718().into(); + + let latest_block = sepolia + .get_block_by_number(alloy::eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .number(); + + let bundle_body = vec![BundleItem::Tx { + tx: tx_bytes, + can_revert: true, + }]; + let bundle = MevSendBundle::new(latest_block, Some(0), ProtocolVersion::V0_1, bundle_body); + + let err = flashbots + .simulate_bundle(&bundle) + .await + .unwrap_err() + .to_string(); + // If we have hit this point, we have succesfully authed to the flashbots + // api via header + assert!( + err.contains("insufficient funds for gas"), + "unexpected error: {err}" + ); +} From f5a60aa200e92bf3419d2802f22f57725ce6eef0 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 23 Sep 2025 11:08:19 -0400 Subject: [PATCH 2/3] fix: statics --- tests/flashbots.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/flashbots.rs b/tests/flashbots.rs index 6a85a84..febae04 100644 --- a/tests/flashbots.rs +++ b/tests/flashbots.rs @@ -21,13 +21,13 @@ use init4_bin_base::utils::{flashbots::Flashbots, signer::LocalOrAws}; use std::sync::LazyLock; use url::Url; -const FLASHBOTS_URL: LazyLock = LazyLock::new(|| { +static FLASHBOTS_URL: LazyLock = LazyLock::new(|| { Url::parse("https://relay-sepolia.flashbots.net:443").expect("valid flashbots url") }); -const BUILDER_KEY: LazyLock = LazyLock::new(|| { +static BUILDER_KEY: LazyLock = LazyLock::new(|| { LocalOrAws::Local(PrivateKeySigner::from_bytes(&B256::repeat_byte(0x02)).unwrap()) }); -const TEST_PROVIDER: LazyLock = LazyLock::new(get_test_provider); +static TEST_PROVIDER: LazyLock = LazyLock::new(get_test_provider); fn get_test_provider() -> Flashbots { Flashbots::new(FLASHBOTS_URL.clone(), BUILDER_KEY.clone()) From 8d390e7b125299a188c50c8819b5233db2f983df Mon Sep 17 00:00:00 2001 From: James Date: Tue, 23 Sep 2025 11:08:35 -0400 Subject: [PATCH 3/3] lint: clippy --- Cargo.toml | 3 +-- src/utils/flashbots.rs | 41 ++++++++++++++++++++++++++++++++--------- tests/flashbots.rs | 1 + 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2844e53..d89a973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ keywords = ["init4", "bin", "base"] version = "0.12.4" edition = "2021" -rust-version = "1.81" +rust-version = "1.83" authors = ["init4", "James Prestwich", "evalir"] license = "MIT OR Apache-2.0" homepage = "https://github.com/init4tech/bin-base" @@ -76,7 +76,6 @@ flashbots = ["alloy", "aws", "alloy?/json-rpc", "dep:eyre", "dep:reqwest", "dep: aws = ["alloy", "alloy?/signer-aws", "dep:async-trait", "dep:aws-config", "dep:aws-sdk-kms"] perms = ["dep:oauth2", "dep:tokio", "dep:reqwest", "dep:signet-tx-cache", "dep:eyre", "dep:axum", "dep:tower"] rustls = ["dep:rustls", "rustls/aws-lc-rs"] -serde_json = ["dep:serde_json"] [[example]] name = "oauth" diff --git a/src/utils/flashbots.rs b/src/utils/flashbots.rs index 6fa6051..9d8cc11 100644 --- a/src/utils/flashbots.rs +++ b/src/utils/flashbots.rs @@ -35,7 +35,7 @@ impl FlashbotsConfig { } } -/// A wrapper over a `Provider` that adds Flashbots MEV bundle helpers. +/// A basic provider for common Flashbots Relay endpoints. #[derive(Debug)] pub struct Flashbots { /// The base URL for the Flashbots API. @@ -43,12 +43,33 @@ pub struct Flashbots { /// Signer is loaded once at startup. signer: LocalOrAws, + + /// The reqwest client to use for requests. + client: reqwest::Client, } impl Flashbots { - /// Wraps a provider with the URL and returns a new `FlashbotsProvider`. - pub const fn new(relay_url: url::Url, signer: LocalOrAws) -> Self { - Self { relay_url, signer } + /// Instantiate a new provider from the URL and signer. + pub fn new(relay_url: url::Url, signer: LocalOrAws) -> Self { + Self { + relay_url, + client: Default::default(), + signer, + } + } + + /// Instantiate a new provider from the URL and signer, with a specific + /// Reqwest client. + pub const fn new_with_client( + relay_url: url::Url, + signer: LocalOrAws, + client: reqwest::Client, + ) -> Self { + Self { + relay_url, + client, + signer, + } } /// Sends a bundle via `mev_sendBundle`. @@ -63,7 +84,7 @@ impl Flashbots { Ok(()) } - /// Fetches the bundle status by hash + /// Fetch the bundle status by hash. pub async fn bundle_status( &self, hash: EthBundleHash, @@ -77,7 +98,8 @@ impl Flashbots { Ok(()) } - /// Makes a raw JSON-RPC call with the Flashbots signature header to the method with the given params. + /// Make a raw JSON-RPC call with the Flashbots signature header to the + /// method with the given params. async fn raw_call( &self, method: &str, @@ -93,8 +115,8 @@ impl Flashbots { let value = self.compute_signature(&body_bz).await?; - let client = reqwest::Client::new(); - let resp = client + let resp = self + .client .post(self.relay_url.as_str()) .header(CONTENT_TYPE, "application/json") .header("X-Flashbots-Signature", value) @@ -112,7 +134,8 @@ impl Flashbots { } } - /// Builds an EIP-191 signature for the given body bytes. + /// Builds an EIP-191 signature for the given body bytes. This signature is + /// used to authenticate to the relay API via a header async fn compute_signature(&self, body_bz: &[u8]) -> Result { let payload = keccak256(body_bz).to_string(); let signature = self.signer.sign_message(payload.as_ref()).await?; diff --git a/tests/flashbots.rs b/tests/flashbots.rs index febae04..e5bb202 100644 --- a/tests/flashbots.rs +++ b/tests/flashbots.rs @@ -33,6 +33,7 @@ fn get_test_provider() -> Flashbots { Flashbots::new(FLASHBOTS_URL.clone(), BUILDER_KEY.clone()) } +#[allow(clippy::type_complexity)] fn get_sepolia() -> FillProvider< JoinFill< JoinFill<