diff --git a/.gitignore b/.gitignore index e27345d..0d142a9 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ target/ /.helix/ # CLI default out dir for CSV files -out/ +output/ + +# Environment files +.env diff --git a/Cargo.lock b/Cargo.lock index 5b31fb3..3736c09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,6 +253,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + [[package]] name = "dyn-clone" version = "1.0.16" @@ -608,8 +629,12 @@ version = "0.1.1" dependencies = [ "anyhow", "clap", + "csv", "inquire", "mevboost-relay-api", + "serde", + "serde_json", + "tokio", "tracing", "tracing-subscriber", ] diff --git a/Cargo.toml b/Cargo.toml index 750a3b4..d97038e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,6 @@ members = ["bin/*", "crates/*"] resolver = "2" [workspace.package] -name = "mevboost-relay-api" version = "0.1.1" edition = "2021" license = "MIT" @@ -19,6 +18,7 @@ tracing-subscriber = "0.3.17" serde = { version = "1.0.192", features = ["derive"] } clap = { version = "4.4.7", features = ["derive"] } tokio = { version = "1.12.0", features = ["full"] } +# beacon-api-client = { git = "https://github.com/ralexstokes/ethereum-consensus.git" } [profile.dev] opt-level = 1 diff --git a/bin/cli/Cargo.toml b/bin/cli/Cargo.toml index 717c675..28a9712 100644 --- a/bin/cli/Cargo.toml +++ b/bin/cli/Cargo.toml @@ -12,3 +12,10 @@ anyhow.workspace = true inquire.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true + +csv = "1.3.0" +# url = "2.2.2" +# beacon-api-client = { git = "https://github.com/ralexstokes/ethereum-consensus.git" } diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 4dc71b5..7924d3a 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -1,22 +1,196 @@ -use clap::Parser; +use std::path::Path; + +use clap::{Parser, Subcommand, ValueEnum}; +use mevboost_relay_api::{ + types::{BuilderBidsReceivedOptions, PayloadDeliveredQueryOptions}, + Client, +}; #[derive(Parser)] -#[clap(author = "Chainbound")] +#[command(author, version, about, long_about = None)] +// #[command(propagate_version = true)] struct Args { - #[clap(long, short = 'r', default_value = "flashbots")] - relay_name: String, + /// The subcommand to execute. + #[clap(subcommand)] + command: Command, + /// The output method to use. Default: human readable text. + #[clap(long, short = 'o', default_value = "human")] + output: OutputMethod, + /// The path to write the output to. If not provided, + /// will default to the current working directory. + #[clap(long, short = 'p')] + path: Option, +} + +#[derive(Default, ValueEnum, Clone)] +enum OutputMethod { + /// Output in human readable format + #[default] + Human, + /// Output in CSV format + Csv, + /// Output in JSON format + Json, +} + +#[derive(Subcommand)] +enum Command { + /// Get the payloads delivered to proposers for a given slot. + #[clap(name = "payloads-delivered")] + PayloadsDelivered { slot: u64 }, + + /// Get the block bids received by the relay for a given slot. + #[clap(name = "block-bids")] + BlockBids { + #[clap(long)] + slot: Option, + #[clap(long)] + block_hash: Option, + }, + + /// Get the timestamp of the winning bid for a given slot. + #[clap(name = "winning-bid-timestamp")] + WinningBidTimestamp { slot: u64 }, } -fn main() -> anyhow::Result<()> { +#[tokio::main] +async fn main() -> anyhow::Result<()> { let args = Args::parse(); let _ = tracing_subscriber::fmt::try_init(); - let client = mevboost_relay_api::Client::default(); - if !client.relays.contains_key(args.relay_name.as_str()) { - anyhow::bail!("Relay `{}` not found in list of relays.", args.relay_name) + let client = Client::default(); + + let mut output_file_path = args + .path + .map(Into::into) + .unwrap_or(std::env::current_dir()?.join("output")); + + match args.command { + Command::PayloadsDelivered { slot } => { + let payloads = client + .get_payloads_delivered_bidtraces_on_all_relays(&PayloadDeliveredQueryOptions { + slot: Some(slot), + ..Default::default() + }) + .await?; + + match args.output { + OutputMethod::Human => println!("{:#?}", &payloads), + OutputMethod::Csv => unimplemented!(), + OutputMethod::Json => { + output_file_path = output_file_path + .join("payloads-delivered") + .join(format!("{}.json", slot)); + write_json(output_file_path.clone(), payloads)?; + } + } + } + + Command::BlockBids { slot, block_hash } => { + if slot.is_none() && block_hash.is_none() { + anyhow::bail!("Must provide either a slot or block hash"); + } + + let block_bids = client + .get_builder_blocks_received_on_all_relays(&BuilderBidsReceivedOptions { + slot, + block_hash: block_hash.clone(), + ..Default::default() + }) + .await?; + + let query_name = if let Some(slot) = slot { + format!("slot-{}", slot) + } else { + format!( + "block-hash-{}", + block_hash + .as_ref() + .unwrap() + .chars() + .take(8) + .collect::() + ) + }; + output_file_path = output_file_path.join(format!("block-bids-{}", query_name)); + + match args.output { + OutputMethod::Human => println!("{:#?}", &block_bids), + OutputMethod::Csv => unimplemented!(), + OutputMethod::Json => { + for (relay, bids) in block_bids { + if bids.is_empty() { + continue; + } + + let filename = format!("{}.json", relay); + println!("Writing {} bids to {}", bids.len(), filename); + write_json(output_file_path.join(filename), bids)?; + } + } + } + } + + Command::WinningBidTimestamp { slot } => { + let payloads = client + .get_payloads_delivered_bidtraces_on_all_relays(&PayloadDeliveredQueryOptions { + slot: Some(slot), + ..Default::default() + }) + .await?; + + for (relay, relay_payloads) in payloads { + if relay_payloads.is_empty() { + continue; + } + + let block_hash = relay_payloads[0].block_hash.clone(); + let block_bids = client + .get_builder_blocks_received( + relay, + &BuilderBidsReceivedOptions { + slot: Some(slot), + block_hash: Some(block_hash), + ..Default::default() + }, + ) + .await?; + + let timestamp = block_bids[0].timestamp_ms; + println!( + "The winning bid for slot {} was submitted to {} at: {}", + slot, relay, timestamp + ) + } + } + } + + Ok(()) +} + +#[allow(unused)] +fn write_csv(path: impl AsRef, data: Vec) -> anyhow::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; } - // TODO + let mut res = csv::Writer::from_path(path)?; + for row in data { + res.serialize(row)?; + } + res.flush()?; + Ok(()) +} + +#[allow(unused)] +fn write_json(path: impl AsRef, data: T) -> anyhow::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut res = std::fs::File::create(path)?; + serde_json::to_writer_pretty(&mut res, &data)?; Ok(()) } diff --git a/crates/mevboost-relay-api/src/lib.rs b/crates/mevboost-relay-api/src/lib.rs index 42ec520..c029357 100644 --- a/crates/mevboost-relay-api/src/lib.rs +++ b/crates/mevboost-relay-api/src/lib.rs @@ -26,7 +26,7 @@ pub mod types; #[derive(Debug)] pub struct Client<'a> { /// List of relay names and endpoints to use for queries. - pub relays: HashMap<&'a str, &'a str>, + relays: HashMap<&'a str, &'a str>, /// HTTP client used for requests. inner: reqwest::Client, } @@ -50,6 +50,13 @@ impl<'a> Client<'a> { Self { relays, inner } } + /// Check if the client contains a relay with the given name. + /// + /// This is useful for checking if a relay is available before performing a query. + pub fn contains(&self, relay_name: &str) -> bool { + self.relays.contains_key(relay_name) + } + /// Perform a relay query for validator registrations for the current and next epochs. /// /// [Visit the docs](https://flashbots.github.io/relay-specs/#/Builder/getValidators) for more info. @@ -92,7 +99,7 @@ impl<'a> Client<'a> { pub async fn get_payload_delivered_bidtraces( &self, relay_name: &str, - opts: types::PayloadDeliveredQueryOptions, + opts: &types::PayloadDeliveredQueryOptions, ) -> anyhow::Result> { let relay_url = self.get_relay_url(relay_name)?; let endpoint = format!( @@ -107,12 +114,38 @@ impl<'a> Client<'a> { .map_err(|e| anyhow::anyhow!("Failed to parse JSON response: {}", e)) } + /// Perform queries on all relays to get the payloads delivered by each relay to proposers. + /// Query options act as filters. Returns a hashmap of relay names to payload bidtraces. + pub async fn get_payloads_delivered_bidtraces_on_all_relays( + &self, + opts: &types::PayloadDeliveredQueryOptions, + ) -> anyhow::Result>> { + let mut payloads_delivered = HashMap::new(); + for relay_name in self.relays.keys() { + match self.get_payload_delivered_bidtraces(relay_name, opts).await { + Ok(relay_res) => { + payloads_delivered.insert(*relay_name, relay_res); + } + Err(e) => { + tracing::warn!( + "Failed to get payloads delivered for relay {}: {}", + relay_name, + e + ); + continue; + } + } + } + + Ok(payloads_delivered) + } + /// Perform a relay query to get the builder bid submissions. /// Query options act as filters. pub async fn get_builder_blocks_received( &self, relay_name: &str, - opts: types::BuilderBidsReceivedOptions, + opts: &types::BuilderBidsReceivedOptions, ) -> anyhow::Result> { let relay_url = self.get_relay_url(relay_name)?; let endpoint = format!( @@ -127,6 +160,32 @@ impl<'a> Client<'a> { .map_err(|e| anyhow::anyhow!("Failed to parse JSON response: {}", e)) } + /// Perform queries on all relays to get the builder bid submissions. + /// Query options act as filters. Returns a hashmap of relay names to builder block bidtraces. + pub async fn get_builder_blocks_received_on_all_relays( + &self, + opts: &types::BuilderBidsReceivedOptions, + ) -> anyhow::Result>> { + let mut builder_blocks_received = HashMap::new(); + for relay_name in self.relays.keys() { + match self.get_builder_blocks_received(relay_name, opts).await { + Ok(relay_res) => { + builder_blocks_received.insert(*relay_name, relay_res); + } + Err(e) => { + tracing::warn!( + "Failed to get builder blocks received for relay {}: {}", + relay_name, + e + ); + continue; + } + } + } + + Ok(builder_blocks_received) + } + /// Perform a relay query to check if a validator with the given pubkey /// is registered with any of the relays in the client. Returns a hashmap /// of relay names to validator entries. If an entry is not found for a @@ -297,7 +356,7 @@ mod tests { }; let response = client - .get_payload_delivered_bidtraces("ultrasound", opts) + .get_payload_delivered_bidtraces("ultrasound", &opts) .await?; assert!(!response.is_empty()); @@ -313,7 +372,7 @@ mod tests { }; let response = client - .get_builder_blocks_received("ultrasound", opts) + .get_builder_blocks_received("ultrasound", &opts) .await?; dbg!(&response); diff --git a/crates/mevboost-relay-api/src/types.rs b/crates/mevboost-relay-api/src/types.rs index ff043ee..f810d20 100644 --- a/crates/mevboost-relay-api/src/types.rs +++ b/crates/mevboost-relay-api/src/types.rs @@ -1,5 +1,5 @@ use chrono::prelude::*; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_aux::prelude::*; /// Validator info for a given slot. @@ -88,7 +88,7 @@ impl ToString for PayloadDeliveredQueryOptions { } /// Entry for the validator payload delivered response. -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[allow(missing_docs)] pub struct PayloadBidtrace { #[serde(deserialize_with = "deserialize_number_from_string")] @@ -150,12 +150,13 @@ impl ToString for BuilderBidsReceivedOptions { } /// Entry for the builder block bidtrace response. -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[allow(missing_docs)] pub struct BuilderBlockBidtrace { #[serde(flatten)] pub payload: PayloadBidtrace, - #[serde(deserialize_with = "deserialize_datetime_utc_from_milliseconds")] - pub timestamp_ms: DateTime, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp_ms: u128, + #[serde(skip_serializing_if = "Option::is_none")] pub optimistic_submission: Option, }