diff --git a/Cargo.toml b/Cargo.toml index b5ac8bf..d89a973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,9 @@ 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" +rust-version = "1.83" authors = ["init4", "James Prestwich", "evalir"] license = "MIT OR Apache-2.0" homepage = "https://github.com/init4tech/bin-base" @@ -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,6 +72,7 @@ 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"] 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..9d8cc11 --- /dev/null +++ b/src/utils/flashbots.rs @@ -0,0 +1,147 @@ +//! 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 basic provider for common Flashbots Relay endpoints. +#[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, + + /// The reqwest client to use for requests. + client: reqwest::Client, +} + +impl Flashbots { + /// 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`. + 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(()) + } + + /// Fetch 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(()) + } + + /// 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, + 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 resp = self + .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. 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?; + 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..e5bb202 --- /dev/null +++ b/tests/flashbots.rs @@ -0,0 +1,96 @@ +#![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; + +static FLASHBOTS_URL: LazyLock = LazyLock::new(|| { + Url::parse("https://relay-sepolia.flashbots.net:443").expect("valid flashbots url") +}); +static BUILDER_KEY: LazyLock = LazyLock::new(|| { + LocalOrAws::Local(PrivateKeySigner::from_bytes(&B256::repeat_byte(0x02)).unwrap()) +}); +static TEST_PROVIDER: LazyLock = LazyLock::new(get_test_provider); + +fn get_test_provider() -> Flashbots { + Flashbots::new(FLASHBOTS_URL.clone(), BUILDER_KEY.clone()) +} + +#[allow(clippy::type_complexity)] +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}" + ); +}