diff --git a/Cargo.toml b/Cargo.toml index f5f6ded..f7ef79c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ signet-constants = { version = "0.11.1" } signet-tx-cache = { version = "0.11.1", optional = true } # alloy -alloy = { version = "1.0.35", optional = true, default-features = false, features = ["std", "signer-local", "consensus", "network"] } +alloy = { version = "1.0.35", optional = true, default-features = false, features = ["std", "signer-local", "consensus", "network", "provider-mev-api"] } # Tracing tracing = "0.1.40" diff --git a/src/utils/flashbots.rs b/src/utils/flashbots.rs index 9d8cc11..3226097 100644 --- a/src/utils/flashbots.rs +++ b/src/utils/flashbots.rs @@ -1,7 +1,7 @@ //! A generic Flashbots bundle API wrapper. use crate::utils::signer::LocalOrAws; use alloy::{ - primitives::{keccak256, BlockNumber}, + primitives::keccak256, rpc::{ json_rpc::{Id, Response, ResponsePayload, RpcRecv, RpcSend}, types::mev::{EthBundleHash, MevSendBundle, SimBundleResponse}, @@ -10,7 +10,6 @@ use alloy::{ }; use init4_from_env_derive::FromEnv; use reqwest::header::CONTENT_TYPE; -use serde_json::json; use std::borrow::Cow; /// Configuration for the Flashbots provider. @@ -74,27 +73,15 @@ impl Flashbots { /// Sends a bundle via `mev_sendBundle`. pub async fn send_bundle(&self, bundle: &MevSendBundle) -> eyre::Result { - self.raw_call("mev_sendBundle", &[bundle]).await + let resp = self.raw_call("mev_sendBundle", &[bundle]).await?; + dbg!("sim bundle response", &resp); + Ok(resp) } /// 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?; - + dbg!("send bundle response ###", resp); Ok(()) } @@ -139,7 +126,6 @@ impl Flashbots { 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/tests/flashbots.rs b/tests/flashbots.rs index e5bb202..e25c93b 100644 --- a/tests/flashbots.rs +++ b/tests/flashbots.rs @@ -1,10 +1,12 @@ #![cfg(feature = "flashbots")] use alloy::{ - eips::Encodable2718, + consensus::constants::GWEI_TO_WEI, + eips::{BlockId, Encodable2718}, network::EthereumWallet, primitives::{B256, U256}, providers::{ + ext::MevApi, fillers::{ BlobGasFiller, ChainIdFiller, FillProvider, GasFiller, JoinFill, NonceFiller, WalletFiller, @@ -12,29 +14,43 @@ use alloy::{ Identity, Provider, ProviderBuilder, SendableTx, }, rpc::types::{ - mev::{BundleItem, MevSendBundle, ProtocolVersion}, + mev::{EthCallBundle, EthSendBundle}, TransactionRequest, }, - signers::{local::PrivateKeySigner, Signer}, + signers::Signer, +}; +use init4_bin_base::{ + deps::tracing_subscriber::{ + fmt, layer::SubscriberExt, registry, util::SubscriberInitExt, EnvFilter, Layer, + }, + utils::signer::LocalOrAws, +}; +use std::{ + env, + sync::LazyLock, + time::{Duration, Instant}, }; -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") +/// Hoodi endpoints +static TITANBUILDER_HOODI_RPC: LazyLock = LazyLock::new(|| { + Url::parse("https://rpc-hoodi.titanbuilder.xyz/").expect("valid flashbots url") }); -static BUILDER_KEY: LazyLock = LazyLock::new(|| { - LocalOrAws::Local(PrivateKeySigner::from_bytes(&B256::repeat_byte(0x02)).unwrap()) + +static HOODI_HOST_RPC: LazyLock = LazyLock::new(|| { + Url::parse("https://ethereum-hoodi-rpc.publicnode.com").expect("valid hoodi url") }); -static TEST_PROVIDER: LazyLock = LazyLock::new(get_test_provider); -fn get_test_provider() -> Flashbots { - Flashbots::new(FLASHBOTS_URL.clone(), BUILDER_KEY.clone()) -} +/// Pecorino endpoints +static PECORINO_RBUILDER: LazyLock = LazyLock::new(|| { + Url::parse("https://host-builder-rpc.pecorino.signet.sh").expect("valid pecorino rbuilder url") +}); -#[allow(clippy::type_complexity)] -fn get_sepolia() -> FillProvider< +static PECORINO_HOST_RPC: LazyLock = LazyLock::new(|| { + Url::parse("https://host-rpc.pecorino.signet.sh").expect("valid pecorino url") +}); + +type HoodiProvider = FillProvider< JoinFill< JoinFill< Identity, @@ -43,54 +59,215 @@ fn get_sepolia() -> FillProvider< WalletFiller, >, alloy::providers::RootProvider, -> { +>; + +#[allow(clippy::type_complexity)] +fn get_hoodi_host(builder_key: LocalOrAws) -> HoodiProvider { ProviderBuilder::new() - .wallet(BUILDER_KEY.clone()) - .connect_http( - "https://ethereum-sepolia-rpc.publicnode.com" - .parse() - .unwrap(), - ) + .wallet(builder_key.clone()) + .connect_http(HOODI_HOST_RPC.clone()) +} + +#[tokio::test] +#[ignore = "integration test"] +async fn test_send_valid_bundle_hoodi() { + setup_logging(); + + let key_from_env = env::var("BUILDER_KEY").expect("BUILDER_KEY must be set"); + let builder_key = LocalOrAws::load(&key_from_env, Some(560048)) + .await + .expect("failed to load builder key"); + + let flashbots = ProviderBuilder::new() + .wallet(builder_key.clone()) + .connect_http(TITANBUILDER_HOODI_RPC.clone()); + + let hoodi = get_hoodi_host(builder_key.clone()); + + let req = TransactionRequest::default() + .to(builder_key.address()) + .value(U256::from(0u64)) + .gas_limit(21_000) + .max_fee_per_gas((50 * GWEI_TO_WEI).into()) + .max_priority_fee_per_gas((2 * GWEI_TO_WEI).into()) + .from(builder_key.address()); + + let SendableTx::Envelope(tx) = hoodi.fill(req.clone()).await.unwrap() else { + panic!("expected filled tx"); + }; + + let block = hoodi.get_block(BlockId::latest()).await.unwrap().unwrap(); + let target_block = block.number() + 1; + + let bundle = EthSendBundle { + txs: vec![tx.encoded_2718().into()], + block_number: target_block, + ..Default::default() + }; + + let result = flashbots + .send_bundle(bundle) + .with_auth(builder_key.clone()) + .await; + dbg!(result.as_ref().unwrap()); + assert!(result.is_ok(), "should send bundle: {:#?}", result); + assert!(result.unwrap().is_some(), "should have bundle hash"); + // assert_tx_included(&hoodi, tx.tx_hash().clone(), 120).await; } +// +// Pecorino rbuilder tests +// #[tokio::test] #[ignore = "integration test"] -async fn test_simulate_valid_bundle_sepolia() { - let flashbots = &*TEST_PROVIDER; - let sepolia = get_sepolia(); +async fn test_sim_bundle_pecorino() { + setup_logging(); + + let raw_key = env::var("BUILDER_KEY").expect("BUILDER_KEY must be set"); + let builder_key = LocalOrAws::load(&raw_key, Some(3151908)) + .await + .expect("failed to load builder key"); + + let flashbots = ProviderBuilder::new() + .wallet(builder_key.clone()) + .connect_http(PECORINO_RBUILDER.clone()); + + let pecorino = ProviderBuilder::new() + .wallet(builder_key.clone()) + .connect_http(PECORINO_HOST_RPC.clone()); 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 { + .to(builder_key.address()) + .value(U256::from(0u64)) + .gas_limit(21_000) + .max_fee_per_gas((50 * GWEI_TO_WEI).into()) + .max_priority_fee_per_gas((2 * GWEI_TO_WEI).into()) + .from(builder_key.address()); + + let SendableTx::Envelope(tx) = pecorino.fill(req.clone()).await.unwrap() else { panic!("expected filled tx"); }; - let tx_bytes = tx.encoded_2718().into(); + dbg!("prepared transaction request", tx.clone()); - let latest_block = sepolia - .get_block_by_number(alloy::eips::BlockNumberOrTag::Latest) + let block = pecorino + .get_block(BlockId::latest()) .await .unwrap() - .unwrap() - .number(); + .unwrap(); + let target_block = block.number() + 1; + dbg!("preparing bundle for", target_block); + + let bundle = EthCallBundle { + txs: vec![tx.encoded_2718().into()], + block_number: target_block, + ..Default::default() + }; - 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); + // FAIL: This test currently fails - why? + // thread 'test_sim_bundle_pecorino' panicked at tests/flashbots.rs:610:17: + // called `Result::unwrap()` on an `Err` value: ErrorResp(ErrorPayload { code: -32601, message: "Method not found", data: None }) + let result = flashbots + .call_bundle(bundle) + .with_auth(builder_key.clone()) + .await; + dbg!(result.unwrap()); +} - let err = flashbots - .simulate_bundle(&bundle) +#[tokio::test] +#[ignore = "integration test"] +async fn test_send_bundle_pecorino() { + setup_logging(); + + let raw_key = env::var("BUILDER_KEY").expect("BUILDER_KEY must be set"); + let builder_key = LocalOrAws::load(&raw_key, Some(3151908)) .await - .unwrap_err() - .to_string(); - // If we have hit this point, we have succesfully authed to the flashbots - // api via header + .expect("failed to load builder key"); + + let flashbots = ProviderBuilder::new() + .wallet(builder_key.clone()) + .connect_http(PECORINO_RBUILDER.clone()); + + let pecorino = ProviderBuilder::new() + .wallet(builder_key.clone()) + .connect_http("https://rpc.pecorino.signet.sh".parse().unwrap()); + + let req = TransactionRequest::default() + .to(builder_key.address()) + .value(U256::from(0u64)) + .gas_limit(21_000) + .max_fee_per_gas((50 * GWEI_TO_WEI).into()) + .max_priority_fee_per_gas((2 * GWEI_TO_WEI).into()) + .from(builder_key.address()); + + let SendableTx::Envelope(tx) = pecorino.fill(req.clone()).await.unwrap() else { + panic!("expected filled tx"); + }; + dbg!("prepared transaction request", tx.clone()); + + let block = pecorino + .get_block(BlockId::latest()) + .await + .unwrap() + .unwrap(); + let target_block = block.number() + 1; + dbg!("preparing bundle for", target_block); + + let bundle = EthSendBundle { + txs: vec![tx.encoded_2718().into()], + block_number: target_block, + ..Default::default() + }; + + let result = flashbots + .send_bundle(bundle) + .with_auth(builder_key.clone()) + .await; + dbg!(result.as_ref().unwrap()); + assert!(result.is_ok(), "should send bundle: {:#?}", result); + assert!(result.unwrap().is_some(), "should have bundle hash"); +} + +/// Asserts that a tx was included in Sepolia within `deadline` seconds. +async fn assert_tx_included(sepolia: &HoodiProvider, tx_hash: B256, deadline: u64) { + let now = Instant::now(); + let deadline = now + Duration::from_secs(deadline); + let mut found = false; + + loop { + let n = Instant::now(); + if n >= deadline { + break; + } + + match sepolia.get_transaction_by_hash(tx_hash).await { + Ok(Some(_tx)) => { + found = true; + break; + } + Ok(None) => { + // Not yet present; wait and retry + dbg!("transaction not yet seen"); + tokio::time::sleep(Duration::from_secs(1)).await; + } + Err(err) => { + // Transient error querying the provider; log and retry + eprintln!("warning: error querying tx: {}", err); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + assert!( - err.contains("insufficient funds for gas"), - "unexpected error: {err}" + found, + "transaction was not seen by the provider within {:?} seconds", + deadline ); } + +/// Initializes logger for printing during testing +pub fn setup_logging() { + let filter = EnvFilter::from_default_env(); + let fmt = fmt::layer().with_filter(filter); + let registry = registry().with(fmt); + let _ = registry.try_init(); +}