diff --git a/Cargo.lock b/Cargo.lock index db1233b..cc6a60f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -582,17 +582,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "cb-test" -version = "0.1.0" -dependencies = [ - "anyhow", - "artemis-core", - "chainbound-artemis", - "ethers", - "tokio", -] - [[package]] name = "cc" version = "1.0.79" @@ -622,6 +611,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tokio-tungstenite 0.20.1", "tracing", ] @@ -4539,6 +4529,20 @@ dependencies = [ "webpki-roots 0.23.1", ] +[[package]] +name = "tokio-tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.20.1", +] + [[package]] name = "tokio-util" version = "0.7.7" @@ -4783,6 +4787,26 @@ dependencies = [ "webpki", ] +[[package]] +name = "tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.16.0" diff --git a/crates/clients/chainbound/Cargo.toml b/crates/clients/chainbound/Cargo.toml index 3eeada9..0769ca9 100644 --- a/crates/clients/chainbound/Cargo.toml +++ b/crates/clients/chainbound/Cargo.toml @@ -9,9 +9,10 @@ readme = "README.md" [dependencies] artemis-core = { path = "../../artemis-core" } ethers = { version = "2", features = ["ws", "rustls"] } -fiber = { version = "0.3.3", git = "https://github.com/chainbound/fiber-rs" } +fiber = { git = "https://github.com/chainbound/fiber-rs" } serde_json = { version = "1.0", features = ["arbitrary_precision"] } tokio = { version = "1.18", features = ["full"] } +tokio-tungstenite = { version = "0.20.1", features = ["native-tls"] } async-trait = "0.1.64" serde = "1.0.152" anyhow = "1.0.70" diff --git a/crates/clients/chainbound/README.md b/crates/clients/chainbound/README.md index e28a67a..0b5fc28 100644 --- a/crates/clients/chainbound/README.md +++ b/crates/clients/chainbound/README.md @@ -62,11 +62,11 @@ pub async fn main() -> anyhow::Result<()> { let provider = Arc::new(Provider::connect("wss://eth.llamarpc.com").await.unwrap()); let tx_signer = LocalWallet::new(&mut rand::thread_rng()); // or any other signer let auth_signer = LocalWallet::new(&mut rand::thread_rng()); // or any other signer - let echo_executor = Box::new(EchoExecutor::new(provider, tx_signer, auth_signer, api_key)); + let echo_exec = Box::new(EchoExecutor::new(provider, tx_signer, auth_signer, api_key).await); - let executor_map = ExecutorMap::new(echo_executor, |action| match action { - Action::SendBundle(bundle) => Some(bundle), - }); + // We can simply map all Action types in an Option + // since `EchoExecutor` implements `Executor`. + let executor_map = ExecutorMap::new(echo_exec, Some); // And add these components to your Artemis engine let mut engine: Engine = Engine::default(); diff --git a/crates/clients/chainbound/src/echo.rs b/crates/clients/chainbound/src/echo.rs index e34cabf..f333a2d 100644 --- a/crates/clients/chainbound/src/echo.rs +++ b/crates/clients/chainbound/src/echo.rs @@ -1,17 +1,19 @@ -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; use anyhow::{anyhow, Result}; use async_trait::async_trait; use ethers::{providers::Middleware, signers::Signer}; -use reqwest::{ - header::{HeaderMap, HeaderValue}, - Client, -}; +use futures::{SinkExt, StreamExt, TryStreamExt}; +use tokio::sync::broadcast; +use tokio_tungstenite::tungstenite::Message; use tracing::{debug, error}; use artemis_core::types::Executor; -use crate::SendBundleArgs; +use crate::{ + utils::{generate_fb_signature, generate_jsonrpc_request}, + SendBundleArgs, SendPrivateTransactionArgs, +}; /// Possible actions that can be executed by the Echo executor #[derive(Debug, Clone)] @@ -19,22 +21,25 @@ use crate::SendBundleArgs; #[allow(missing_docs)] pub enum Action { SendBundle(SendBundleArgs), + SendPrivateTransaction(SendPrivateTransactionArgs), } -const ECHO_RPC_URL: &str = "https://echo-rpc.chainbound.io"; +const ECHO_RPC_URL_WS: &str = "wss://echo-rpc.chainbound.io/ws"; /// An Echo executor that sends transactions to the specified block builders pub struct EchoExecutor { /// The Echo RPC endpoint echo_endpoint: String, - /// The HTTP client to send requests to the Echo RPC - echo_client: Client, /// The native ethers middleware inner: Arc, /// The signer to sign transactions before sending to the builders tx_signer: S, /// the signer to compute the `X-Flashbots-Signature` of the bundle payload auth_signer: S, + /// Channel to send websocket messages + api_requests_tx: broadcast::Sender, + /// Channel to receive websocket messages + api_responses_rx: broadcast::Receiver, } impl EchoExecutor { @@ -45,23 +50,70 @@ impl EchoExecutor { /// - `tx_signer`: The actual signer of the bundle transactions /// - `auth_signer`: The signer to compute the `X-Flashbots-Signature` of the bundle payload /// - `api_key`: The Echo API key to use - pub fn new(inner: Arc, tx_signer: S, auth_signer: S, api_key: impl Into) -> Self { - let mut headers = HeaderMap::new(); - headers.insert("Content-Type", "application/json".parse().unwrap()); - headers.insert("X-Api-Key", api_key.into().parse().expect("Broken API key")); + pub async fn new( + inner: Arc, + tx_signer: S, + auth_signer: S, + api_key: impl Into, + ) -> Self { + let request = tokio_tungstenite::tungstenite::http::Request::builder() + .uri(ECHO_RPC_URL_WS) + .header("x-api-key", api_key.into()) + .header("host", "echo-artemis-client") + .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") + .header("sec-websocket-version", "13") + .header("upgrade", "websocket") + .header("connection", "upgrade") + .body(()) + .unwrap(); + + let (ws_client, _) = tokio_tungstenite::connect_async(request) + .await + .expect("Failed to connect to Echo via Websocket."); + debug!("Echo websocket handshake succeeded."); + + let (api_requests_tx, mut api_requests_rx) = broadcast::channel(1024); + let (api_responses_tx, api_responses_rx) = broadcast::channel(1024); + + // Spawn a task to manage the websocket connection with request and response channels + tokio::spawn(async move { + let (mut outgoing, incoming) = ws_client.split(); + + // send all incoming messages to the responses channel in a separate task + tokio::spawn(async move { + let responses_tx = api_responses_tx.clone(); + incoming.try_for_each(|msg| async { + let text = msg.into_text().unwrap_or_else(|e| { + error!(error = ?e, "Error converting Echo API response to text"); + Default::default() + }); + + responses_tx.send(text).unwrap_or_else(|e| { + error!(error = ?e, "Error sending Echo API response to the responses channel"); + Default::default() + }); + + Ok(()) + }).await.ok(); + }); + + // send all messages from the intake channel into the websocket + while let Ok(msg) = api_requests_rx.recv().await { + if let Err(e) = outgoing.send(Message::Text(msg)).await { + error!(error = ?e, "Error sending Echo API request to the websocket") + } + } - let echo_client = Client::builder() - .timeout(Duration::from_secs(300)) - .default_headers(headers) - .build() - .expect("Could not instantiate HTTP client"); + error!("Echo API request channel has stopped sending messages"); + }); Self { - echo_endpoint: ECHO_RPC_URL.into(), - echo_client, + echo_endpoint: ECHO_RPC_URL_WS.into(), inner, tx_signer, auth_signer, + api_requests_tx, + api_responses_rx, } } @@ -72,7 +124,12 @@ impl EchoExecutor { /// Returns a reference to the native ethers middleware pub fn provider(&self) -> Arc { - self.inner.clone() + Arc::clone(&self.inner) + } + + /// Returns a reference to the API receipts channel + pub fn receipts_channel(&self) -> broadcast::Receiver { + self.api_responses_rx.resubscribe() } } @@ -102,53 +159,84 @@ where // Set block number to the next block if not specified if action.standard_features.block_number.is_none() { let block_number = self.inner.get_block_number().await?; - let next_block_number_hex = format!("0x{:#x}", block_number.as_u64() + 1); + let next_block_number_hex = format!("{:#x}", block_number.as_u64() + 1); action.standard_features.block_number = Some(next_block_number_hex); } // TODO: Simulate bundle // Sign bundle payload (without the Echo-specific features) - let signable_payload = serde_json::to_string(&action.standard_features)?; - let flashbots_signature = self.auth_signer.sign_message(&signable_payload).await?; + let method = "eth_sendBundle"; + let fb_payload = generate_jsonrpc_request(action.id, method, &action.standard_features); + let fb_signature = generate_fb_signature(&self.auth_signer, &fb_payload).await; + + // Websocket usage format: + // https://echo.chainbound.io/docs/usage/api-interface#flashbots-authentication + let request_body = format!( + r#"{{"x-flashbots-signature":"{}","payload":{}}}"#, + fb_signature, + generate_jsonrpc_request(action.id, method, action) + ); - // Create the `X-Flashbots-Signature` header - let flashbots_signature_header: HeaderValue = - format!("{:#x}:{}", self.auth_signer.address(), flashbots_signature).parse()?; + // Send bundle request + self.api_requests_tx.send(request_body)?; + debug!("bundle sent to Echo."); - // Prepare the full JSON-RPC request body - let bundle_json = serde_json::to_string(&action)?; + Ok(()) + } +} +#[async_trait] +impl Executor for EchoExecutor +where + M: Middleware + 'static, + M::Error: 'static, + S: Signer + 'static, +{ + /// Send a transaction to the specified builders + async fn execute(&self, mut action: SendPrivateTransactionArgs) -> Result<()> { + let tx = &action.unsigned_tx; + + // Sign the transaction + let signature = self.tx_signer.sign_transaction(&tx.clone().into()).await?; + let signed = tx.rlp_signed(&signature).to_string(); + action.standard_features.tx = signed; + + // TODO: Simulate transaction + + // Sign payload (without the Echo-specific features) + let method = "eth_sendPrivateRawTransaction"; + let fb_payload = generate_jsonrpc_request(action.id, method, &action.standard_features); + let fb_signature = generate_fb_signature(&self.auth_signer, &fb_payload).await; + + // Websocket usage format: + // https://echo.chainbound.io/docs/usage/api-interface#flashbots-authentication let request_body = format!( - r#"{{"id":1,"jsonrpc":"2.0","method":"eth_sendBundle","params":[{}]}}"#, - bundle_json + r#"{{"x-flashbots-signature":"{}","payload":{}}}"#, + fb_signature, + generate_jsonrpc_request(action.id, method, action) ); - // Send bundle - let echo_response = self - .echo_client - .post(&self.echo_endpoint) - .body(request_body) - .header("X-Flashbots-Signature", flashbots_signature_header) - .send() - .await; - - match echo_response { - Ok(send_response) => { - let status = send_response.status(); - let body = send_response.text().await?; - - dbg!(body.clone()); - - if status.is_success() { - debug!("Echo bundle response: {:?}", body); - } else { - error!("Error in Echo bundle response: {:?}", body); - } - } - Err(send_error) => error!("Error while sending bundle to Echo: {:?}", send_error), - } + // Send transaction request + self.api_requests_tx.send(request_body)?; + debug!("transaction sent to Echo."); Ok(()) } } + +#[async_trait] +impl Executor for EchoExecutor +where + M: Middleware + 'static, + M::Error: 'static, + S: Signer + 'static, +{ + /// Send a transaction or bundle to the specified builders + async fn execute(&self, action: Action) -> Result<()> { + match action { + Action::SendBundle(bundle) => self.execute(bundle).await, + Action::SendPrivateTransaction(tx) => self.execute(tx).await, + } + } +} diff --git a/crates/clients/chainbound/src/fiber.rs b/crates/clients/chainbound/src/fiber.rs index f5c10b2..45880f3 100644 --- a/crates/clients/chainbound/src/fiber.rs +++ b/crates/clients/chainbound/src/fiber.rs @@ -1,10 +1,10 @@ -use anyhow::Result; -use async_trait::async_trait; -use ethers::types::Transaction; -use fiber::{ +use ::fiber::{ eth::{CompactBeaconBlock, ExecutionPayload, ExecutionPayloadHeader}, Client, }; +use anyhow::Result; +use async_trait::async_trait; +use ethers::types::Transaction; use futures::StreamExt; use artemis_core::types::{Collector, CollectorStream}; diff --git a/crates/clients/chainbound/src/lib.rs b/crates/clients/chainbound/src/lib.rs index a813ff9..3a51430 100644 --- a/crates/clients/chainbound/src/lib.rs +++ b/crates/clients/chainbound/src/lib.rs @@ -20,15 +20,21 @@ /// Fiber Network client module pub mod fiber; -pub use fiber::{Event, FiberCollector, StreamType}; +pub use self::fiber::{Event, FiberCollector, StreamType}; /// Echo RPC client module pub mod echo; -pub use echo::{Action, EchoExecutor}; +pub use self::echo::{Action, EchoExecutor}; /// MEV bundle helper types -pub mod mev_bundle; -pub use mev_bundle::{BlockBuilder, BundleNotification, SendBundleArgs, SendBundleResponse}; +pub mod types; +pub use self::types::{ + BlockBuilder, EchoApiResponse, InclusionNotification, SendBundleArgs, + SendPrivateTransactionArgs, +}; + +/// Utility functions +pub mod utils; #[cfg(test)] mod tests { @@ -63,7 +69,7 @@ mod tests { let auth_signer = LocalWallet::new(&mut rand::thread_rng()); let account = tx_signer.address(); - let echo_executor = EchoExecutor::new(provider, tx_signer, auth_signer, api_key); + let echo_exec = EchoExecutor::new(provider, tx_signer, auth_signer, api_key).await; // Fill in the bundle with a random transaction let tx = TransactionRequest::new() @@ -73,20 +79,28 @@ mod tests { .gas_price(U256::from_dec_str("100000000000000000").unwrap()); // Set the block as the next one - let next_block = echo_executor.provider().get_block_number().await.unwrap() + 1; + let next_block = echo_exec.provider().get_block_number().await.unwrap() + 1; // Build the bundle with the selected transaction and options. // Look at the `SendBundleArgs` struct for info on available methods. - let mut bundle = SendBundleArgs::with_txs(vec![tx]); - bundle.set_block_number(next_block.as_u64()); - bundle.set_mev_builders(vec![BlockBuilder::Flashbots, BlockBuilder::Titan]); - bundle.set_replacement_uuid("a34daefc-e640-48fc-a1c7-352fc518720f".to_string()); - bundle.set_refund_percent(90); - bundle.set_refund_index(0); - - if let Err(e) = echo_executor.execute(bundle).await { + let bundle = SendBundleArgs::with_txs(vec![tx]) + .set_request_id(2) // id of the request, used to match the response + .set_block_number(next_block.as_u64()) + .set_mev_builders(vec![BlockBuilder::Flashbots, BlockBuilder::Titan]) + .set_replacement_uuid("a34daefc-e640-48fc-a1c7-352fc518720f".to_string()) + .set_refund_percent(90) + .set_refund_index(0); + + if let Err(e) = echo_exec.execute(bundle).await { panic!("Failed to send bundle: {}", e); } + + // ==== Expect a reply by the websocket in the response channel ==== + + let res = echo_exec.receipts_channel().recv().await.unwrap(); + let res = serde_json::from_str::(&res).unwrap(); + assert!(&res["id"] == 2); + assert!(&res["result"]["bundleHash"] != "0x"); } else { println!("Skipping test_chainbound_clients because FIBER_TEST_KEY is not set"); } diff --git a/crates/clients/chainbound/src/mev_bundle.rs b/crates/clients/chainbound/src/types.rs similarity index 53% rename from crates/clients/chainbound/src/mev_bundle.rs rename to crates/clients/chainbound/src/types.rs index 6a1e748..eb5d02c 100644 --- a/crates/clients/chainbound/src/mev_bundle.rs +++ b/crates/clients/chainbound/src/types.rs @@ -1,6 +1,24 @@ use ethers::types::TransactionRequest; use serde::{Deserialize, Serialize}; +use crate::utils::{deserialize_opt_u64_or_hex, serialize_opt_u64_as_hex}; + +/// An error response from the Echo RPC endpoints +pub struct RpcError { + /// The HTTP status code of the response + pub status: reqwest::StatusCode, + /// The stringified response body + pub body: String, +} + +/// The possible API responses sent in the receipts channel +pub enum EchoApiResponse { + /// A response from the `eth_sendBundle` endpoint + SendBundle(Result), + /// A response from the `eth_sendPrivateRawTransaction` endpoint + SendPrivateTransaction(Result), +} + /// An UUIDv4 identifier, useful for cancelling/replacing bundles. pub type ReplacementUuid = String; @@ -25,6 +43,8 @@ pub enum BlockBuilder { Nfactorial, /// RPC URL: Buildai, + /// RPC URL: + Smithbuilder, /// Custom builder name (must be supported by the Echo RPC). /// This can be useful if a new Echo version comes out and this @@ -48,6 +68,7 @@ impl ToString for BlockBuilder { BlockBuilder::Blocknative => "blocknative".to_string(), BlockBuilder::Nfactorial => "nfactorial".to_string(), BlockBuilder::Buildai => "buildai".to_string(), + BlockBuilder::Smithbuilder => "smithbuilder".to_string(), BlockBuilder::Other(name) => name.to_string(), BlockBuilder::All => "all".to_string(), } @@ -58,6 +79,11 @@ impl ToString for BlockBuilder { #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct SendBundleArgs { + /// (Internal) JSON-RPC request ID. This is not sent to block builders. + /// Only used for accounting purposes on the websocket request/reply. + #[serde(skip_serializing, skip_deserializing)] + pub id: u64, + /// (Internal) Bundle transactions that have yet to be signed. /// These are not sent to block builders. they will be replaced by the "txs" field /// inside the `standard_features` struct. @@ -83,81 +109,108 @@ impl SendBundleArgs { } /// Add a transaction to the bundle. - pub fn add_tx(&mut self, tx: TransactionRequest) { + pub fn add_tx(mut self, tx: TransactionRequest) -> Self { self.unsigned_txs.push(tx); + self + } + + /// Set the request ID. This is only used for accounting purposes on the JSON-RPC request/reply. + pub fn set_request_id(mut self, id: u64) -> Self { + self.id = id; + self } /// Set the block number at which the bundle should be mined. - pub fn set_block_number(&mut self, block_number: u64) { + pub fn set_block_number(mut self, block_number: u64) -> Self { self.standard_features.block_number = Some(format!("{:#x}", block_number)); + self } /// Set the minimum timestamp at which the bundle should be mined - pub fn set_min_timestamp(&mut self, min_timestamp: u64) { + pub fn set_min_timestamp(mut self, min_timestamp: u64) -> Self { self.standard_features.min_timestamp = Some(min_timestamp); + self } /// Set the maximum timestamp at which the bundle should be mined - pub fn set_max_timestamp(&mut self, max_timestamp: u64) { + pub fn set_max_timestamp(mut self, max_timestamp: u64) -> Self { self.standard_features.max_timestamp = Some(max_timestamp); + self } /// Set the transaction hashes of transactions that can revert in the bundle, /// without which the rest of the bundle can still be included. - pub fn set_reverting_tx_hashes(&mut self, reverting_tx_hashes: Vec) { + pub fn set_reverting_tx_hashes(mut self, reverting_tx_hashes: Vec) -> Self { self.standard_features.reverting_tx_hashes = Some(reverting_tx_hashes); + self } /// Set the UUID of the bundle for later cancellation/replacement. - pub fn set_replacement_uuid(&mut self, replacement_uuid: ReplacementUuid) { + pub fn set_replacement_uuid(mut self, replacement_uuid: ReplacementUuid) -> Self { self.standard_features.replacement_uuid = Some(replacement_uuid); + self } /// Set the percentage of the gas that should be refunded. - pub fn set_refund_percent(&mut self, refund_percent: u64) { + pub fn set_refund_percent(mut self, refund_percent: u64) -> Self { self.standard_features.refund_percent = Some(refund_percent); + self } /// Set the address to which the refund should be sent. - pub fn set_refund_recipient(&mut self, refund_recipient: String) { + pub fn set_refund_recipient(mut self, refund_recipient: String) -> Self { self.standard_features.refund_recipient = Some(refund_recipient); + self } /// Set the index of the transaction of which the refund should be calculated. - pub fn set_refund_index(&mut self, refund_index: u64) { + pub fn set_refund_index(mut self, refund_index: u64) -> Self { self.standard_features.refund_index = Some(refund_index); + self } /// Set the block builders to forward the bundle to. If not specified, the bundle /// will be forwarded to all block builders configured with Echo - pub fn set_mev_builders(&mut self, mev_builders: Vec) { + pub fn set_mev_builders(mut self, mev_builders: Vec) -> Self { self.echo_features .get_or_insert_with(Default::default) .mev_builders = Some(mev_builders.into_iter().map(|b| b.to_string()).collect()); + self } /// Set the boolean flag indicating if the bundle should also be propagated to the public /// mempool by using Fiber's internal network (default: false) - pub fn set_use_public_mempool(&mut self, use_public_mempool: bool) { + pub fn set_use_public_mempool(mut self, use_public_mempool: bool) -> Self { self.echo_features .get_or_insert_with(Default::default) .use_public_mempool = use_public_mempool; + self } /// Set the boolean flag indicating if the HTTP request should hang until the bundle is either /// included, or the timeout is reached (default: false) - pub fn set_await_receipt(&mut self, await_receipt: bool) { + pub fn set_await_receipt(mut self, await_receipt: bool) -> Self { self.echo_features .get_or_insert_with(Default::default) .await_receipt = await_receipt; + self } /// Set the timeout in milliseconds for the HTTP request to hang until the bundle is either /// included, or the timeout is reached - pub fn set_await_receipt_timeout_ms(&mut self, await_receipt_timeout_ms: u64) { + pub fn set_await_receipt_timeout_ms(mut self, await_receipt_timeout_ms: u64) -> Self { self.echo_features .get_or_insert_with(Default::default) .await_receipt_timeout_ms = await_receipt_timeout_ms; + self + } + + /// Set the target block until which the bundle should be retried + pub fn set_retry_until(mut self, block_number: u64) -> Self { + self.echo_features + .get_or_insert_with(Default::default) + .retry_until = Some(block_number); + self } } @@ -229,6 +282,15 @@ pub struct EchoBundleFeatures { /// included, or the timeout is reached #[serde(default = "default_await_receipt_timeout_ms")] pub await_receipt_timeout_ms: u64, + + /// Retry sending the bundle on each new block until the specified block number + #[serde( + default = "Option::default", + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_opt_u64_as_hex", + deserialize_with = "deserialize_opt_u64_or_hex" + )] + pub retry_until: Option, } /// A response from the Echo RPC `eth_sendBundle` endpoint @@ -242,7 +304,7 @@ pub struct SendBundleResponse { /// The receipt notification that can be used to track the bundle's inclusion status (included / timed out) #[serde(skip_serializing_if = "Option::is_none")] - pub receipt_notification: Option, + pub receipt_notification: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -270,7 +332,7 @@ fn default_await_receipt_timeout_ms() -> u64 { #[serde(tag = "status", content = "data")] #[allow(unused)] #[allow(missing_docs)] -pub enum BundleNotification { +pub enum InclusionNotification { Included { block_number: u64, elapsed_ms: u128, @@ -280,3 +342,143 @@ pub enum BundleNotification { elapsed_ms: u128, }, } + +/// A request to send a private transaction to the Echo RPC `eth_sendPrivateRawTransaction` endpoint +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SendPrivateTransactionArgs { + /// (Internal) JSON-RPC request ID. This is not sent to block builders. + /// Only used for accounting purposes on the websocket request/reply. + #[serde(skip_serializing, skip_deserializing)] + pub id: u64, + + /// (Internal) Transaction that has yet to be signed. + /// This is not sent to block builders. It will be replaced by the "tx" field + /// inside the `standard_features` struct. + #[serde(skip_serializing, skip_deserializing)] + pub unsigned_tx: TransactionRequest, + + /// Standard transaction features include the basic interface that all builders support. + #[serde(flatten)] + pub standard_features: StandardTransactionFeatures, + + /// Echo-specific features and transaction options. These are not sent to block builders. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub echo_features: Option, +} + +/// Standard transaction features +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct StandardTransactionFeatures { + /// The raw, signed, RLP encoded transaction to send + pub tx: String, +} + +/// Echo-specific features and transaction options +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EchoTransactionFeatures { + /// The block builders to which the transaction should be forwarded. + #[serde(skip_serializing_if = "Option::is_none")] + pub mev_builders: Option>, + + /// If the transaction should be sent as a bundle instead of a single + /// transaction (default: false) + #[serde(default = "bool::default")] + pub send_as_bundle: bool, + + /// Boolean flag indicating if the bundle should also be propagated to the public + /// mempool by using Fiber's internal network (default: false) + #[serde(default = "bool::default")] + pub use_public_mempool: bool, + + /// Retry sending the bundle on each new block until the specified block number + /// NOTE: this is only used if `send_as_bundle` is true. + #[serde( + default = "Option::default", + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_opt_u64_as_hex", + deserialize_with = "deserialize_opt_u64_or_hex" + )] + pub retry_until: Option, + + /// Boolean flag indicating if the HTTP request should hang until all builders have + /// returned a response. If false, Echo will return immediately instead. (default: false) + #[serde(default = "bool::default")] + pub await_receipt: bool, + + /// Timeout in milliseconds for the HTTP request to hang until the bundle is either + /// included, or the timeout is reached + #[serde(default = "default_await_receipt_timeout_ms")] + pub await_receipt_timeout_ms: u64, +} + +/// A response from the Echo RPC `eth_sendPrivateRawTransaction` endpoint +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SendPrivateTransactionResponse { + /// The transaction hash of the transaction that was sent. + pub tx_hash: String, + + /// The receipt notification that can be used to track the transaction's inclusion status (included / timed out) + #[serde(skip_serializing_if = "Option::is_none")] + pub receipt_notification: Option, + + /// The bundle hash that was generated from the original SendBundleArgs body, + /// if the request was sent as a bundle (with the `send_as_bundle` flag as true). + /// The only reason we return this is for allowing users to cancel private transactions + /// that were sent as bundles before the bundle is mined. + #[serde(skip_serializing_if = "Option::is_none")] + pub bundle_hash: Option, +} + +impl SendPrivateTransactionArgs { + /// Create a new `SendBundleArgs` with the specified unsigned transactions. + pub fn with_tx(tx: TransactionRequest) -> Self { + Self { + unsigned_tx: tx, + ..Default::default() + } + } + + /// Set the request ID. This is only used for accounting purposes on the JSON-RPC request/reply. + pub fn set_request_id(mut self, id: u64) -> Self { + self.id = id; + self + } + + /// Set the block builders to forward the bundle to. If not specified, the bundle + /// will be forwarded to all block builders configured with Echo + pub fn set_mev_builders(mut self, mev_builders: Vec) -> Self { + self.echo_features + .get_or_insert_with(Default::default) + .mev_builders = Some(mev_builders.into_iter().map(|b| b.to_string()).collect()); + self + } + + /// Set the boolean flag indicating if the bundle should also be propagated to the public + /// mempool by using Fiber's internal network (default: false) + pub fn set_use_public_mempool(mut self, use_public_mempool: bool) -> Self { + self.echo_features + .get_or_insert_with(Default::default) + .use_public_mempool = use_public_mempool; + self + } + + /// Set the boolean flag indicating if the HTTP request should hang until the bundle is either + /// included, or the timeout is reached (default: false) + pub fn set_await_receipt(mut self, await_receipt: bool) -> Self { + self.echo_features + .get_or_insert_with(Default::default) + .await_receipt = await_receipt; + self + } + + /// Set the timeout in milliseconds for the HTTP request to hang until the bundle is either + /// included, or the timeout is reached + pub fn set_await_receipt_timeout_ms(mut self, await_receipt_timeout_ms: u64) -> Self { + self.echo_features + .get_or_insert_with(Default::default) + .await_receipt_timeout_ms = await_receipt_timeout_ms; + self + } +} diff --git a/crates/clients/chainbound/src/utils.rs b/crates/clients/chainbound/src/utils.rs new file mode 100644 index 0000000..74bd765 --- /dev/null +++ b/crates/clients/chainbound/src/utils.rs @@ -0,0 +1,103 @@ +use std::fmt; + +use ethers::{signers::Signer, types::H256, utils::keccak256}; +use serde::{ + de::{self, Visitor}, + Deserializer, Serialize, +}; +use serde_json::to_string; + +/// Generate a JSON-RPC request string. +pub fn generate_jsonrpc_request(id: u64, method: &str, params: T) -> String +where + T: Serialize, +{ + format!( + r#"{{"id":{},"jsonrpc":"2.0","method":"{}","params":[{}]}}"#, + id, + method, + to_string(¶ms).unwrap() + ) +} + +/// Generate a Flashbots signature for a given payload +pub async fn generate_fb_signature(signer: &S, payload: T) -> String +where + S: Signer, + T: Serialize, +{ + let msg = format!( + "0x{:x}", + H256::from(keccak256( + serde_json::to_string(&payload).unwrap().as_bytes() + )) + ); + + let signature = signer.sign_message(msg).await.unwrap(); + + format!("{:?}:0x{}", signer.address(), signature) +} + +/// Serialize an optional u64 into a hex string starting with 0x. +pub fn serialize_opt_u64_as_hex(value: &Option, serializer: S) -> Result +where + S: serde::Serializer, +{ + if let Some(value) = value { + serializer.serialize_str(&format!("0x{:x}", value)) + } else { + serializer.serialize_none() + } +} + +/// Deserialize an optional u64 from a hex string starting with 0x. +pub fn deserialize_opt_u64_or_hex<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct OptU64OrHex; + + impl<'de> Visitor<'de> for OptU64OrHex { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("u64 or string starting with 0x") + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(None) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(self) + } + + fn visit_u64(self, v: u64) -> Result + where + E: de::Error, + { + Ok(Some(v)) + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + if let Some(hex) = v.strip_prefix("0x") { + u64::from_str_radix(hex, 16) + .map_err(de::Error::custom) + .map(Some) + } else { + Err(de::Error::custom("Expected string to start with 0x")) + } + } + } + + deserializer.deserialize_any(OptU64OrHex) +}