diff --git a/Cargo.lock b/Cargo.lock index 707fb72..db1233b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,6 +220,28 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.15", +] + [[package]] name = "async-trait" version = "0.1.68" @@ -271,6 +293,51 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "backoff" version = "0.4.0" @@ -515,6 +582,17 @@ 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" @@ -530,6 +608,23 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chainbound-artemis" +version = "0.1.0" +dependencies = [ + "anyhow", + "artemis-core", + "async-trait", + "ethers", + "fiber", + "futures", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "chrono" version = "0.4.24" @@ -1293,7 +1388,7 @@ dependencies = [ "ethers-etherscan", "eyre", "hex", - "prettyplease", + "prettyplease 0.2.4", "proc-macro2", "quote", "regex", @@ -1533,6 +1628,29 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiber" +version = "0.3.3" +source = "git+https://github.com/chainbound/fiber-rs#c2f28b28250d52ebb6591d7517e55ead98c041d0" +dependencies = [ + "async-stream", + "base64 0.13.1", + "chrono", + "ethers", + "futures-core", + "futures-util", + "hex", + "pin-project", + "prost", + "serde", + "serde_json", + "serde_repr", + "tokio", + "tokio-stream", + "tonic", + "tonic-build", +] + [[package]] name = "fixed-hash" version = "0.8.0" @@ -2041,12 +2159,24 @@ dependencies = [ "http", "hyper", "log", - "rustls 0.21.1", + "rustls 0.21.7", "rustls-native-certs", "tokio", "tokio-rustls 0.24.0", ] +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -2499,6 +2629,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "matchit" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed1202b2a6f884ae56f04cff409ab315c5ce26b5e58d7412e484f01fd52f52ef" + [[package]] name = "md-5" version = "0.10.5" @@ -2652,6 +2788,12 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + [[package]] name = "native-tls" version = "0.2.11" @@ -3132,6 +3274,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + [[package]] name = "prettyplease" version = "0.2.4" @@ -3199,6 +3351,60 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" +dependencies = [ + "bytes", + "heck", + "itertools", + "lazy_static", + "log", + "multimap", + "petgraph", + "prettyplease 0.1.25", + "prost", + "prost-types", + "regex", + "syn 1.0.109", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" +dependencies = [ + "prost", +] + [[package]] name = "quote" version = "1.0.27" @@ -3361,9 +3567,9 @@ checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" [[package]] name = "reqwest" -version = "0.11.18" +version = "0.11.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde824a14b7c14f85caff81225f411faacc04a2013f41670f41443742b1c1c55" +checksum = "3e9ad3fe7488d7e34558a2033d45a0c90b72d97b4f80705666fea71472e2e6a1" dependencies = [ "base64 0.21.0", "bytes", @@ -3384,7 +3590,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.21.1", + "rustls 0.21.7", "rustls-pemfile", "serde", "serde_json", @@ -3399,7 +3605,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 0.22.6", + "webpki-roots 0.25.2", "winreg", ] @@ -3514,13 +3720,13 @@ dependencies = [ [[package]] name = "rustls" -version = "0.21.1" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c911ba11bc8433e811ce56fde130ccf32f5127cab0e0194e9c68c5a5b671791e" +checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" dependencies = [ "log", "ring", - "rustls-webpki", + "rustls-webpki 0.101.5", "sct", ] @@ -3555,6 +3761,16 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.101.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a27e3b59326c16e23d30aeb7a36a24cc0d29e71d68ff611cdfb4a01d013bed" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.12" @@ -3752,6 +3968,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "serde_repr" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0a21fba416426ac927b1691996e82079f8b6156e920c85345f135b2e9ba2de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.15", +] + [[package]] name = "serde_spanned" version = "0.6.1" @@ -4053,6 +4280,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "tap" version = "1.0.1" @@ -4209,6 +4442,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.1.0" @@ -4247,7 +4490,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0d409377ff5b1e3ca6437aa86c1eb7d40c134bfec254e44c830defa92669db5" dependencies = [ - "rustls 0.21.1", + "rustls 0.21.7", "tokio", ] @@ -4288,7 +4531,7 @@ dependencies = [ "futures-util", "log", "native-tls", - "rustls 0.21.1", + "rustls 0.21.7", "tokio", "tokio-native-tls", "tokio-rustls 0.24.0", @@ -4345,6 +4588,51 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f219fad3b929bef19b1f86fbc0358d35daed8f2cac972037ac0dc10bbb8d5fb" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.13.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "prost-derive", + "tokio", + "tokio-stream", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "tracing-futures", +] + +[[package]] +name = "tonic-build" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf5e9b9c0f7e0a7c027dcfaba7b2c60816c7049171f679d99ee2ff65d0de8c4" +dependencies = [ + "prettyplease 0.1.25", + "proc-macro2", + "prost-build", + "quote", + "syn 1.0.109", +] + [[package]] name = "tower" version = "0.4.13" @@ -4353,8 +4641,13 @@ checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project", "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -4482,7 +4775,7 @@ dependencies = [ "log", "native-tls", "rand 0.8.5", - "rustls 0.21.1", + "rustls 0.21.7", "sha1", "thiserror", "url", @@ -4717,9 +5010,9 @@ checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" [[package]] name = "wasm-streams" -version = "0.2.3" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bbae3363c08332cadccd13b67db371814cd214c2524020932f0804b8cf7c078" +checksum = "b4609d447824375f43e1ffbc051b50ad8f4b3ae8219680c94452ea05eb240ac7" dependencies = [ "futures-util", "js-sys", @@ -4750,20 +5043,28 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.22.6" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" dependencies = [ - "webpki", + "rustls-webpki 0.100.1", ] [[package]] name = "webpki-roots" -version = "0.23.1" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" +checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" + +[[package]] +name = "which" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" dependencies = [ - "rustls-webpki", + "either", + "libc", + "once_cell", ] [[package]] @@ -4964,11 +5265,12 @@ dependencies = [ [[package]] name = "winreg" -version = "0.10.1" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ - "winapi", + "cfg-if", + "windows-sys 0.48.0", ] [[package]] diff --git a/crates/clients/chainbound/Cargo.toml b/crates/clients/chainbound/Cargo.toml new file mode 100644 index 0000000..3eeada9 --- /dev/null +++ b/crates/clients/chainbound/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "chainbound-artemis" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/paradigmxyz/artemis" +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" } +serde_json = { version = "1.0", features = ["arbitrary_precision"] } +tokio = { version = "1.18", features = ["full"] } +async-trait = "0.1.64" +serde = "1.0.152" +anyhow = "1.0.70" +futures = "0.3" +tracing = "0.1.37" +reqwest = "0.11.20" diff --git a/crates/clients/chainbound/README.md b/crates/clients/chainbound/README.md new file mode 100644 index 0000000..e28a67a --- /dev/null +++ b/crates/clients/chainbound/README.md @@ -0,0 +1,104 @@ +# Artemis x Chainbound integration + +> This crate gives you access to the [Chainbound][chainbound] suite of tools & services for MEV. +> It is built directly into the [Artemis][artemis] framework for seamless integration with your existing +> trading strategies. + +This crate offers two main components, which are implemented following the standard Artemis traits: + +- [Fiber][fiber] Collector: a low-latency, reliable `mempool` and `new_blocks` stream for Ethereum. +- [Echo][echo] Executor: a feature-rich RPC endpoint to propagate your MEV bundles to block builders. + +## Usage + +This example assumes you are using a new crate to implement your strategies. + +First, add the following to your `Cargo.toml`: + +```toml +[dependencies] +artemis-core = { git = "https://github.com/paradigmxyz/artemis.git" } +chainbound-artemis = { git = "https://github.com/paradigmxyz/artemis.git" } + +# the following dependencies are also used in this example +ethers = { version = "2", features = ["ws", "rustls"] } +tokio = { version = "1.18", features = ["full"] } +anyhow = "1.0.70" +``` + +Then, in your `main.rs`: + +```rs +use std::sync::Arc; + +use artemis_core::{engine::Engine, types::ExecutorMap}; +use chainbound_artemis::{Action, EchoExecutor, Event, FiberCollector, StreamType}; +use ethers::{prelude::rand, providers::Provider, signers::LocalWallet}; + +#[tokio::main] +pub async fn main() -> anyhow::Result<()> { + // Join the Chainbound Discord at https://discord.com/invite/J4KNdeCYGX + // or write to to get a free trial. + let api_key = std::env::var("CHAINBOUND_API_KEY")?; + + // You can select your desired object type to stream here. + // Please refer to the documentation at https://fiber.chainbound.io/docs/intro for more details. + // + // Possible values are: + // - StreamType::Transactions: new pending transactions in the mempool + // - StreamType::ExecutionHeaders: new execution headers (blocks) without the transactions + // - StreamType::ExecutionPayloads: new blocks with header + all transactions included + // - StreamType::BeaconBlocks: new beacon blocks (ETH2 consensus-layer blocks) + let stream_type = StreamType::Transactions; + + // Simply create a new collector + let fiber_collector = Box::new(FiberCollector::new(api_key.clone(), stream_type).await); + + // Now create the Echo Executor to send your bundles to your desired block builders. + // We also need to instantiate a regular HTTP provider middleware, and two signers + // (one to actually sign the transactions, one for Flashbots' authentication header) + // + // For more info, please refer to the documentation at https://echo.chainbound.io/docs/architecture + 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 executor_map = ExecutorMap::new(echo_executor, |action| match action { + Action::SendBundle(bundle) => Some(bundle), + }); + + // And add these components to your Artemis engine + let mut engine: Engine = Engine::default(); + engine.add_collector(fiber_collector); + engine.add_executor(Box::new(executor_map)); + + // --- bootstrap your trading strategy here --- + + // Finally, run the engine + if let Ok(mut set) = engine.run().await { + while let Some(res) = set.join_next().await { + println!("res: {:?}", res); + } + } + + Ok(()) +} +``` + +## Useful Links + +- [Chainbound website][chainbound] +- [Fiber documentation][fiber-docs] +- [Echo documentation][echo-docs] +- [Chainbound Discord][discord] +- [Chainbound Twitter][twitter] + +[artemis]: https://github.com/paradigmxyz/artemis +[chainbound]: https://chainbound.io/ +[echo]: https://echo.chainbound.io/ +[fiber]: https://fiber.chainbound.io/ +[fiber-docs]: https://fiber.chainbound.io/docs/intro +[echo-docs]: https://echo.chainbound.io/docs/architecture +[discord]: https://discord.com/invite/J4KNdeCYGX +[twitter]: https://twitter.com/chainbound_ diff --git a/crates/clients/chainbound/src/echo.rs b/crates/clients/chainbound/src/echo.rs new file mode 100644 index 0000000..e34cabf --- /dev/null +++ b/crates/clients/chainbound/src/echo.rs @@ -0,0 +1,154 @@ +use std::{sync::Arc, time::Duration}; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use ethers::{providers::Middleware, signers::Signer}; +use reqwest::{ + header::{HeaderMap, HeaderValue}, + Client, +}; +use tracing::{debug, error}; + +use artemis_core::types::Executor; + +use crate::SendBundleArgs; + +/// Possible actions that can be executed by the Echo executor +#[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] +#[allow(missing_docs)] +pub enum Action { + SendBundle(SendBundleArgs), +} + +const ECHO_RPC_URL: &str = "https://echo-rpc.chainbound.io"; + +/// 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, +} + +impl EchoExecutor { + /// Initialize a new Echo executor. + /// + /// ## Arguments + /// - `inner`: The native ethers middleware that can query the blockchain + /// - `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")); + + let echo_client = Client::builder() + .timeout(Duration::from_secs(300)) + .default_headers(headers) + .build() + .expect("Could not instantiate HTTP client"); + + Self { + echo_endpoint: ECHO_RPC_URL.into(), + echo_client, + inner, + tx_signer, + auth_signer, + } + } + + /// Optionally set the Echo RPC endpoint, overriding the default + pub fn set_rpc_endpoint(&mut self, endpoint: impl Into) { + self.echo_endpoint = endpoint.into(); + } + + /// Returns a reference to the native ethers middleware + pub fn provider(&self) -> Arc { + self.inner.clone() + } +} + +#[async_trait] +impl Executor for EchoExecutor +where + M: Middleware + 'static, + M::Error: 'static, + S: Signer + 'static, +{ + /// Send a bundle to transactions to the specified builders + async fn execute(&self, mut action: SendBundleArgs) -> Result<()> { + if action.unsigned_txs.is_empty() { + return Err(anyhow!( + "Bundle must contain at least one transaction. + To cancel a bundle, use the `eth_cancelBundle` method." + )); + } + + // Sign each transaction in bundle + for tx in action.unsigned_txs.iter() { + let signature = self.tx_signer.sign_transaction(&tx.clone().into()).await?; + let signed = tx.rlp_signed(&signature).to_string(); + action.standard_features.txs.push(signed); + } + + // 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); + 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?; + + // Create the `X-Flashbots-Signature` header + let flashbots_signature_header: HeaderValue = + format!("{:#x}:{}", self.auth_signer.address(), flashbots_signature).parse()?; + + // Prepare the full JSON-RPC request body + let bundle_json = serde_json::to_string(&action)?; + + let request_body = format!( + r#"{{"id":1,"jsonrpc":"2.0","method":"eth_sendBundle","params":[{}]}}"#, + bundle_json + ); + + // 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), + } + + Ok(()) + } +} diff --git a/crates/clients/chainbound/src/fiber.rs b/crates/clients/chainbound/src/fiber.rs new file mode 100644 index 0000000..f5c10b2 --- /dev/null +++ b/crates/clients/chainbound/src/fiber.rs @@ -0,0 +1,139 @@ +use anyhow::Result; +use async_trait::async_trait; +use ethers::types::Transaction; +use fiber::{ + eth::{CompactBeaconBlock, ExecutionPayload, ExecutionPayloadHeader}, + Client, +}; +use futures::StreamExt; + +use artemis_core::types::{Collector, CollectorStream}; + +const FIBER_DEFAULT_URL: &str = "beta.fiberapi.io:8080"; + +/// Possible events emitted by the Fiber collector. +#[derive(Clone, Debug)] +#[allow(clippy::large_enum_variant)] +#[allow(missing_docs)] +pub enum Event { + Transaction(Transaction), + ExectionHeader(ExecutionPayloadHeader), + ExecutionPayload(ExecutionPayload), + BeaconBlock(CompactBeaconBlock), +} + +/// Fiber collector stream type, used to specify which stream to subscribe to. +pub enum StreamType { + /// Subscribe to new pending transactions as seen by the Fiber network. + Transactions, + /// Subscribe to new [ExecutionPayloadHeader]s, which contain the block header without the + /// transaction objects. This stream is (on avg) 20-30ms faster than the [StreamType::ExecutionPayloads]. + ExecutionHeaders, + /// Subscribe to new [ExecutionPayload]s, which contain both the block header and the full + /// transaction objects as [ethers::types::Transaction]s. + ExecutionPayloads, + /// Subscribe to new [CompactBeaconBlock]s, which contain the consensus-layer block info. + /// Refer to the official [Fiber-rs client types](https://github.com/chainbound/fiber-rs/blob/c2f28b28250d52ebb6591d7517e55ead98c041d0/src/eth.rs#L173) + /// for more info on the streamed objects. + BeaconBlocks, +} + +/// A Fiber collector that subscribes to the specified stream type. +pub struct FiberCollector { + /// The Fiber-rs client + client: Client, + /// The Fiber API key + api_key: String, + /// The type of stream to subscribe to + ty: StreamType, +} + +impl FiberCollector { + /// Initialize a new Fiber collector. + /// + /// ## Arguments + /// - `api_key`: The Fiber API key to use + /// - `ty`: The type of stream to subscribe to + pub async fn new(api_key: String, ty: StreamType) -> Self { + let client = Client::connect(FIBER_DEFAULT_URL.into(), api_key.clone()) + .await + .expect("failed to connect to Fiber"); + + Self { + client, + api_key, + ty, + } + } + + /// Optionally set the Fiber endpoint, overriding the default + pub async fn set_fiber_endpoint(&mut self, endpoint: impl Into) { + self.client = Client::connect(endpoint.into(), self.api_key.clone()) + .await + .expect("failed to connect to Fiber"); + } + + /// Get the event stream for the specified stream type. + pub async fn get_event_stream(&self) -> Result> { + match self.ty { + StreamType::Transactions => { + let stream = self.client.subscribe_new_txs(None).await; + let stream = stream.map(Event::Transaction); + Ok(Box::pin(stream)) + } + StreamType::ExecutionHeaders => { + let stream = self.client.subscribe_new_execution_headers().await; + let stream = stream.map(Event::ExectionHeader); + Ok(Box::pin(stream)) + } + StreamType::ExecutionPayloads => { + let stream = self.client.subscribe_new_execution_payloads().await; + let stream = stream.map(Event::ExecutionPayload); + Ok(Box::pin(stream)) + } + StreamType::BeaconBlocks => { + let stream = self.client.subscribe_new_beacon_blocks().await; + let stream = stream.map(Event::BeaconBlock); + Ok(Box::pin(stream)) + } + } + } +} + +#[async_trait] +impl Collector for FiberCollector { + async fn get_event_stream(&self) -> Result> { + self.get_event_stream().await + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use artemis_core::engine::Engine; + use ethers::types::Action; + + use crate::Event; + use crate::FiberCollector; + use crate::StreamType; + + #[tokio::test] + async fn test_fiber_collector_txs() -> Result<()> { + if let Ok(api_key) = std::env::var("FIBER_TEST_KEY") { + let fiber_collector = FiberCollector::new(api_key, StreamType::Transactions).await; + + let mut engine: Engine = Engine::default(); + engine.add_collector(Box::new(fiber_collector)); + + if let Ok(mut set) = engine.run().await { + while let Some(res) = set.join_next().await { + println!("res: {:?}", res); + } + } + } else { + println!("Skipping Fiber test, no API key found in FIBER_TEST_KEY env var"); + } + + Ok(()) + } +} diff --git a/crates/clients/chainbound/src/lib.rs b/crates/clients/chainbound/src/lib.rs new file mode 100644 index 0000000..a813ff9 --- /dev/null +++ b/crates/clients/chainbound/src/lib.rs @@ -0,0 +1,94 @@ +#![warn(missing_docs, unreachable_pub)] +#![deny(unused_must_use, rust_2018_idioms)] +#![doc(test( + no_crate_inject, + attr(deny(warnings, rust_2018_idioms), allow(dead_code, unused_variables)) +))] + +//! # Chainbound Artemis +//! +//! This crate gives you access to the [Chainbound][chainbound] suite of tools & services for MEV. +//! It is built directly into the [Artemis][artemis] framework for seamless integration with your existing +//! trading strategies. +//! +//! This crate offers two main components, which are implemented following the standard Artemis traits: +//! +//! - Fiber Collector: a low-latency, reliable `mempool` and `new_blocks` stream for Ethereum. +//! - Echo Executor: a feature-rich RPC endpoint to propagate your MEV bundles to block builders. +//! +//! Please refer to the crate README file for an example on how to use these components. + +/// Fiber Network client module +pub mod fiber; +pub use fiber::{Event, FiberCollector, StreamType}; + +/// Echo RPC client module +pub mod echo; +pub use echo::{Action, EchoExecutor}; + +/// MEV bundle helper types +pub mod mev_bundle; +pub use mev_bundle::{BlockBuilder, BundleNotification, SendBundleArgs, SendBundleResponse}; + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use artemis_core::types::Executor; + use ethers::{ + prelude::rand, + providers::{Middleware, Provider}, + signers::{LocalWallet, Signer}, + types::{TransactionRequest, U256}, + }; + use futures::StreamExt; + + use crate::{BlockBuilder, EchoExecutor, Event, FiberCollector, SendBundleArgs, StreamType}; + + #[tokio::test] + async fn test_chainbound_client() { + if let Ok(api_key) = std::env::var("FIBER_TEST_KEY") { + // ==== Open a Fiber transaction stream, and verify that we receive transactions ==== + + let ty = StreamType::Transactions; + let fiber_tx_collector = FiberCollector::new(api_key.clone(), ty).await; + let fiber_tx_stream = fiber_tx_collector.get_event_stream().await.unwrap(); + let fiber_tx = fiber_tx_stream.into_future().await.0.unwrap(); + assert!(matches!(fiber_tx, Event::Transaction(_))); + + // ==== Create an Echo Executor, and send a random bundle to block builders ==== + + let provider = Arc::new(Provider::connect("wss://eth.llamarpc.com").await.unwrap()); + let tx_signer = LocalWallet::new(&mut rand::thread_rng()); + 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); + + // Fill in the bundle with a random transaction + let tx = TransactionRequest::new() + .to(account) + .from(account) + .value(42) + .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; + + // 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 { + panic!("Failed to send bundle: {}", e); + } + } 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/mev_bundle.rs new file mode 100644 index 0000000..6a1e748 --- /dev/null +++ b/crates/clients/chainbound/src/mev_bundle.rs @@ -0,0 +1,282 @@ +use ethers::types::TransactionRequest; +use serde::{Deserialize, Serialize}; + +/// An UUIDv4 identifier, useful for cancelling/replacing bundles. +pub type ReplacementUuid = String; + +/// The list of available MEV builders. +#[derive(Debug, Default, Clone)] +pub enum BlockBuilder { + /// RPC URL: + Flashbots, + /// RPC URL: + Beaverbuild, + /// RPC URL: + Rsync, + /// RPC URL: + Builder0x69, + /// RPC URL: + Titan, + /// RPC URL: + F1b, + /// RPC URL: + Blocknative, + /// RPC URL: + Nfactorial, + /// RPC URL: + Buildai, + + /// Custom builder name (must be supported by the Echo RPC). + /// This can be useful if a new Echo version comes out and this + /// library has not been updated yet. + Other(String), + + /// Use all available builders. This is the default behavior. + #[default] + All, +} + +impl ToString for BlockBuilder { + fn to_string(&self) -> String { + match self { + BlockBuilder::Flashbots => "flashbots".to_string(), + BlockBuilder::Beaverbuild => "beaverbuild".to_string(), + BlockBuilder::Rsync => "rsync".to_string(), + BlockBuilder::Builder0x69 => "builder0x69".to_string(), + BlockBuilder::Titan => "titan".to_string(), + BlockBuilder::F1b => "f1b".to_string(), + BlockBuilder::Blocknative => "blocknative".to_string(), + BlockBuilder::Nfactorial => "nfactorial".to_string(), + BlockBuilder::Buildai => "buildai".to_string(), + BlockBuilder::Other(name) => name.to_string(), + BlockBuilder::All => "all".to_string(), + } + } +} + +/// A request to send a bundle to the Echo RPC `eth_sendBundle` endpoint +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SendBundleArgs { + /// (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. + #[serde(skip_serializing, skip_deserializing)] + pub unsigned_txs: Vec, + + /// Standard bundle features include the basic interface that all builders support. + #[serde(flatten)] + pub standard_features: StandardBundleFeatures, + + /// Echo-specific features and bundle options. These are not sent to block builders. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub echo_features: Option, +} + +impl SendBundleArgs { + /// Create a new `SendBundleArgs` with the specified unsigned transactions. + pub fn with_txs(txs: Vec) -> Self { + Self { + unsigned_txs: txs, + ..Default::default() + } + } + + /// Add a transaction to the bundle. + pub fn add_tx(&mut self, tx: TransactionRequest) { + self.unsigned_txs.push(tx); + } + + /// Set the block number at which the bundle should be mined. + pub fn set_block_number(&mut self, block_number: u64) { + self.standard_features.block_number = Some(format!("{:#x}", block_number)); + } + + /// Set the minimum timestamp at which the bundle should be mined + pub fn set_min_timestamp(&mut self, min_timestamp: u64) { + self.standard_features.min_timestamp = Some(min_timestamp); + } + + /// Set the maximum timestamp at which the bundle should be mined + pub fn set_max_timestamp(&mut self, max_timestamp: u64) { + self.standard_features.max_timestamp = Some(max_timestamp); + } + + /// 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) { + self.standard_features.reverting_tx_hashes = Some(reverting_tx_hashes); + } + + /// Set the UUID of the bundle for later cancellation/replacement. + pub fn set_replacement_uuid(&mut self, replacement_uuid: ReplacementUuid) { + self.standard_features.replacement_uuid = Some(replacement_uuid); + } + + /// Set the percentage of the gas that should be refunded. + pub fn set_refund_percent(&mut self, refund_percent: u64) { + self.standard_features.refund_percent = Some(refund_percent); + } + + /// Set the address to which the refund should be sent. + pub fn set_refund_recipient(&mut self, refund_recipient: String) { + self.standard_features.refund_recipient = Some(refund_recipient); + } + + /// Set the index of the transaction of which the refund should be calculated. + pub fn set_refund_index(&mut self, refund_index: u64) { + self.standard_features.refund_index = Some(refund_index); + } + + /// 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.echo_features + .get_or_insert_with(Default::default) + .mev_builders = Some(mev_builders.into_iter().map(|b| b.to_string()).collect()); + } + + /// 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.echo_features + .get_or_insert_with(Default::default) + .use_public_mempool = use_public_mempool; + } + + /// 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.echo_features + .get_or_insert_with(Default::default) + .await_receipt = await_receipt; + } + + /// 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.echo_features + .get_or_insert_with(Default::default) + .await_receipt_timeout_ms = await_receipt_timeout_ms; + } +} + +/// Standard bundle features +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct StandardBundleFeatures { + /// (Required) Raw bundle transactions as RLP-encoded hex strings. + pub txs: Vec, + + /// (Required) The block number at which the bundle should be mined. + /// Encoded as hex string. + pub block_number: Option, + + /// (Optional) The minimum timestamp at which the bundle should be mined. + #[serde(skip_serializing_if = "Option::is_none")] + pub min_timestamp: Option, + + /// (Optional) The maximum timestamp at which the bundle should be mined. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_timestamp: Option, + + /// (Optional) The transaction hashes of transactions that can revert in the bundle, + /// without which the rest of the bundle can still be included. + #[serde(skip_serializing_if = "Option::is_none")] + pub reverting_tx_hashes: Option>, + + /// (Optional) The UUID of the bundle to be replaced. + #[serde(skip_serializing_if = "Option::is_none")] + pub replacement_uuid: Option, + + /// (Optional) The percentage of the gas that should be refunded. + #[serde(skip_serializing_if = "Option::is_none")] + pub refund_percent: Option, + + /// (Optional) The address to which the refund should be sent. + #[serde(skip_serializing_if = "Option::is_none")] + pub refund_recipient: Option, + + /// (Optional) The index of the transaction of which the refund should be calculated. + #[serde(skip_serializing_if = "Option::is_none")] + pub refund_index: Option, + + /// (Optional) The transaction hashes of which the refund should be calculated. + #[serde(skip_serializing_if = "Option::is_none")] + pub refund_tx_hashes: Option>, +} + +/// Echo-specific features and bundle options +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct EchoBundleFeatures { + /// The block builders to forward the bundle to. If not specified, the bundle + /// will be forwarded to all block builders configured with Echo + #[serde(skip_serializing_if = "Option::is_none")] + pub mev_builders: Option>, + + /// 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, + + /// Boolean flag indicating if the HTTP request should hang until the bundle is either + /// included, or the timeout is reached (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_sendBundle` endpoint +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendBundleResponse { + /// The bundle hash that was generated from the request body. Each block builder *can* + /// generate a different hash for the same bundle, so this is only used for identification. + #[serde(skip_serializing_if = "Option::is_none")] + pub bundle_hash: Option, + + /// 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(unused)] +/// A request to cancel a bundle using the Echo RPC `eth_cancelBundle` endpoint +pub struct CancelBundleArgs { + /// The UUID of the bundle to be cancelled. + pub replacement_uuid: ReplacementUuid, + + /// The block builders to which the cancellation request should be forwarded. + /// If not specified, these will be inferred by existing sendBundle requests with the same + /// `replacementUuid`. + #[serde(skip_serializing_if = "Option::is_none")] + pub mev_builders: Option>, +} + +fn default_await_receipt_timeout_ms() -> u64 { + 30000 +} + +/// A notification sent from the echo response handler +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "status", content = "data")] +#[allow(unused)] +#[allow(missing_docs)] +pub enum BundleNotification { + Included { + block_number: u64, + elapsed_ms: u128, + block_builder: Option, + }, + TimedOut { + elapsed_ms: u128, + }, +}