Skip to content

feat(provider): Add eth_sendBundle support to provider #2556

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Jun 17, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/json-rpc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ mod packet;
pub use packet::{BorrowedResponsePacket, RequestPacket, ResponsePacket};

mod request;
pub use request::{PartiallySerializedRequest, Request, RequestMeta, SerializedRequest};
pub use request::{
HttpHeaderExtension, PartiallySerializedRequest, Request, RequestMeta, SerializedRequest,
};

mod response;
pub use response::{
Expand Down
24 changes: 23 additions & 1 deletion crates/json-rpc/src/packet.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::{ErrorPayload, Id, Response, ResponsePayload, SerializedRequest};
use crate::{ErrorPayload, HttpHeaderExtension, Id, Response, ResponsePayload, SerializedRequest};
use alloy_primitives::map::HashSet;
use http::{HeaderMap, HeaderName, HeaderValue};
use serde::{
de::{self, Deserializer, MapAccess, SeqAccess, Visitor},
Deserialize, Serialize,
Expand Down Expand Up @@ -125,6 +126,27 @@ impl RequestPacket {
pub fn method_names(&self) -> impl Iterator<Item = &str> + '_ {
self.requests().iter().map(|req| req.method())
}

/// Returns a [`HeaderMap`] from the request if `HttpHeaderExtension` is present;
/// otherwise, returns an empty map. Only supported for single requests.
pub fn headers(&self) -> HeaderMap {
if let Some(single_req) = self.as_single() {
if let Some(http_header_extension) =
single_req.meta().extensions().get::<HttpHeaderExtension>()
{
let mut headers = HeaderMap::new();
for (key, value) in http_header_extension {
if let (Ok(header_name), Ok(header_value)) =
(HeaderName::try_from(key), HeaderValue::try_from(value))
{
headers.insert(header_name, header_value);
}
}
return headers;
}
}
HeaderMap::new()
}
}

/// A [`ResponsePacket`] is a [`Response`] or a batch of responses.
Expand Down
13 changes: 12 additions & 1 deletion crates/json-rpc/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ use serde::{
Deserialize, Serialize,
};
use serde_json::value::RawValue;
use std::{borrow::Cow, marker::PhantomData, mem::MaybeUninit};
use std::{borrow::Cow, collections::HashMap, marker::PhantomData, mem::MaybeUninit};

/// A map of HTTP headers that extends the request's headers when supported by the transport.
pub type HttpHeaderExtension = HashMap<String, String>;

/// `RequestMeta` contains the [`Id`] and method name of a request.
#[derive(Clone, Debug)]
Expand Down Expand Up @@ -120,6 +123,14 @@ impl<Params> Request<Params> {
) -> Request<NewParams> {
Request { meta: self.meta, params: map(self.params) }
}

/// Change the metadata of the request.
pub fn map_meta<F>(self, f: F) -> Self
where
F: FnOnce(RequestMeta) -> RequestMeta,
{
Self { meta: f(self.meta), params: self.params }
}
}

/// A [`Request`] that has been partially serialized.
Expand Down
2 changes: 2 additions & 0 deletions crates/provider/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ alloy-rpc-types-txpool = { workspace = true, optional = true }
alloy-rpc-types-engine = { workspace = true, optional = true, features = [
"serde",
] }
alloy-rpc-types-mev = { workspace = true, optional = true }
alloy-rpc-types = { workspace = true, optional = true }
alloy-transport-http = { workspace = true, optional = true }
alloy-transport-ipc = { workspace = true, optional = true }
Expand Down Expand Up @@ -127,3 +128,4 @@ trace-api = ["dep:alloy-rpc-types-trace"]
rpc-api = ["dep:alloy-rpc-types"]
txpool-api = ["dep:alloy-rpc-types-txpool"]
throttle = ["alloy-transport/throttle"]
mev-api = ["dep:alloy-rpc-types-mev"]
30 changes: 30 additions & 0 deletions crates/provider/src/ext/mev/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
mod with_auth;

pub use self::with_auth::{sign_flashbots_payload, MevBuilder};
use crate::Provider;
use alloy_network::Network;
use alloy_rpc_types_mev::{EthBundleHash, EthSendBundle};

/// The HTTP header used for Flashbots signature authentication.
pub const FLASHBOTS_SIGNATURE_HEADER: &str = "X-Flashbots-Signature";

/// This module provides support for interacting with non-standard MEV-related RPC endpoints.
#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
pub trait MevApi<N>: Send + Sync {
/// Sends a MEV bundle using the `eth_sendBundle` RPC method.
/// Returns the resulting bundle hash on success.
fn send_bundle(&self, bundle: EthSendBundle) -> MevBuilder<(EthSendBundle,), EthBundleHash>;
}

#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
impl<N, P> MevApi<N> for P
where
N: Network,
P: Provider<N>,
{
fn send_bundle(&self, bundle: EthSendBundle) -> MevBuilder<(EthSendBundle,), EthBundleHash> {
MevBuilder::new_rpc(self.client().request("eth_sendBundle", (bundle,)))
}
}
145 changes: 145 additions & 0 deletions crates/provider/src/ext/mev/with_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
use crate::{ext::FLASHBOTS_SIGNATURE_HEADER, ProviderCall};
use alloy_json_rpc::{HttpHeaderExtension, RpcRecv, RpcSend};
use alloy_primitives::{hex, keccak256};
use alloy_rpc_client::RpcCall;
use alloy_signer::Signer;
use alloy_transport::{TransportErrorKind, TransportResult};
use std::future::IntoFuture;

/// A builder for MEV RPC calls that allow optional Flashbots authentication.
pub struct MevBuilder<Params, Resp, Output = Resp, Map = fn(Resp) -> Output>
where
Params: RpcSend,
Resp: RpcRecv,
Map: Fn(Resp) -> Output,
{
inner: RpcCall<Params, Resp, Output, Map>,
signer: Option<Box<dyn Signer + Send + Sync>>,
}

impl<Params, Resp, Output, Map> std::fmt::Debug for MevBuilder<Params, Resp, Output, Map>
where
Params: RpcSend + std::fmt::Debug,
Resp: RpcRecv + std::fmt::Debug,
Output: std::fmt::Debug,
Map: Fn(Resp) -> Output + Clone + std::fmt::Debug,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MevBuilder").field("inner", &self.inner).finish()
}
}

impl<Params, Resp, Output, Map> MevBuilder<Params, Resp, Output, Map>
where
Params: RpcSend,
Resp: RpcRecv,
Map: Fn(Resp) -> Output + Clone,
{
/// Create a new [`MevBuilder`] from a [`RpcCall`].
pub const fn new_rpc(inner: RpcCall<Params, Resp, Output, Map>) -> Self {
Self { inner, signer: None }
}
}

impl<Params, Resp, Output, Map> From<RpcCall<Params, Resp, Output, Map>>
for MevBuilder<Params, Resp, Output, Map>
where
Params: RpcSend,
Resp: RpcRecv,
Map: Fn(Resp) -> Output + Clone,
{
fn from(inner: RpcCall<Params, Resp, Output, Map>) -> Self {
Self::new_rpc(inner)
}
}

impl<Params, Resp, Output, Map> MevBuilder<Params, Resp, Output, Map>
where
Params: RpcSend,
Resp: RpcRecv,
Map: Fn(Resp) -> Output,
{
/// Enables Flashbots authentication using the provided signer.
///
/// The signer is used to generate the `X-Flashbots-Signature` header, which will be included
/// in the request if the transport supports HTTP headers.
pub fn with_auth<S: Signer + Send + Sync + 'static>(mut self, signer: S) -> Self {
self.signer = Some(Box::new(signer));
self
}
}

impl<Params, Resp, Output, Map> IntoFuture for MevBuilder<Params, Resp, Output, Map>
where
Params: RpcSend + 'static,
Resp: RpcRecv,
Output: 'static,
Map: Fn(Resp) -> Output + Send + 'static,
{
type Output = TransportResult<Output>;
type IntoFuture = ProviderCall<Params, Resp, Output, Map>;

fn into_future(self) -> Self::IntoFuture {
if let Some(signer) = self.signer {
let fut = async move {
// Generate the Flashbots signature for the request body
let body = serde_json::to_string(&self.inner.request())
.map_err(TransportErrorKind::custom)?;
let signature = sign_flashbots_payload(body, &signer)
.await
.map_err(TransportErrorKind::custom)?;

// Add the Flashbots signature to the request headers
let headers: HttpHeaderExtension = HttpHeaderExtension::from_iter([(
FLASHBOTS_SIGNATURE_HEADER.to_string(),
signature,
)]);

// Patch the existing RPC call with the new headers
let rpc_call = self
.inner
.map_meta(|meta| {
let mut meta = meta;
meta.extensions_mut().insert(headers.clone());
meta
})
.map_err(TransportErrorKind::custom)?;

rpc_call.await
};
return ProviderCall::BoxedFuture(Box::pin(fut));
}
ProviderCall::RpcCall(self.inner)
}
}

/// Uses the provided signer to generate a signature for Flashbots authentication.
/// Returns the value for the `X-Flashbots-Signature` header.
///
/// See [here](https://docs.flashbots.net/flashbots-auction/advanced/rpc-endpoint#authentication) for more information.
pub async fn sign_flashbots_payload<S: Signer + Send + Sync>(
body: String,
signer: &S,
) -> Result<String, alloy_signer::Error> {
let message_hash = keccak256(body.as_bytes()).to_string();
let signature = signer.sign_message(message_hash.as_bytes()).await?;
Ok(format!("{}:{}", signer.address(), hex::encode_prefixed(signature.as_bytes())))
}

#[cfg(test)]
mod tests {
use super::*;
use alloy_primitives::b256;
use alloy_signer_local::PrivateKeySigner;

#[tokio::test]
async fn test_sign_flashbots_payload() {
let signer = PrivateKeySigner::from_bytes(&b256!(
"0x0000000000000000000000000000000000000000000000000000000000123456"
))
.unwrap();
let body = "sign this message".to_string();
let signature = sign_flashbots_payload(body.clone(), &signer).await.unwrap();
assert_eq!(signature, "0xd5F5175D014F28c85F7D67A111C2c9335D7CD771:0x983dc7c520db0d287faff3cd0aef81d5a7f4ffd3473440d3f705da16299724271f660b6fe367f455b205bc014eff3e20defd011f92000f94d39365ca0bc786721b");
}
}
6 changes: 6 additions & 0 deletions crates/provider/src/ext/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ mod erc4337;
#[cfg(feature = "erc4337-api")]
pub use erc4337::Erc4337Api;

#[cfg(feature = "mev-api")]
mod mev;

#[cfg(feature = "mev-api")]
pub use mev::{sign_flashbots_payload, MevApi, MevBuilder, FLASHBOTS_SIGNATURE_HEADER};

#[cfg(test)]
pub(crate) mod test {
#[allow(dead_code)] // dead only when all features off
Expand Down
35 changes: 33 additions & 2 deletions crates/rpc-client/src/call.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
use alloy_json_rpc::{
transform_response, try_deserialize_ok, Request, RequestPacket, ResponsePacket, RpcRecv,
RpcResult, RpcSend,
transform_response, try_deserialize_ok, Request, RequestMeta, RequestPacket, ResponsePacket,
RpcRecv, RpcResult, RpcSend,
};
use alloy_transport::{BoxTransport, IntoBoxTransport, RpcFut, TransportError, TransportResult};
use core::panic;
use futures::FutureExt;
use serde_json::value::RawValue;
use std::{
fmt,
fmt::Formatter,
future::Future,
marker::PhantomData,
pin::Pin,
task::{self, ready, Poll::Ready},
};
use tower::Service;

/// Error returned when attempting to modify a request that has already been sent.
/// This typically occurs after the RPC call has progressed beyond its initial prepared state.
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct RequestAlreadySentError;

impl fmt::Display for RequestAlreadySentError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str("Cannot get request after request has been sent")
}
}

impl core::error::Error for RequestAlreadySentError {}

/// The states of the [`RpcCall`] future.
#[must_use = "futures do nothing unless you `.await` or poll them"]
#[pin_project::pin_project(project = CallStateProj)]
Expand Down Expand Up @@ -276,6 +291,22 @@ where
_pd: PhantomData,
}
}

/// Maps the metadata of the request using the provided function.
pub fn map_meta(
self,
f: impl FnOnce(RequestMeta) -> RequestMeta,
) -> Result<Self, RequestAlreadySentError> {
let CallState::Prepared { request, connection } = self.state else {
return Err(RequestAlreadySentError);
};
let request = request.expect("no request in prepared").map_meta(f);
Ok(Self {
state: CallState::Prepared { request: Some(request), connection },
map: self.map,
_pd: PhantomData,
})
}
}

impl<Params, Resp, Output, Map> RpcCall<&Params, Resp, Output, Map>
Expand Down
2 changes: 1 addition & 1 deletion crates/rpc-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ mod builtin;
pub use builtin::BuiltInConnectionString;

mod call;
pub use call::RpcCall;
pub use call::{RequestAlreadySentError, RpcCall};

mod client;
pub use client::{ClientRef, NoParams, RpcClient, RpcClientInner, WeakClient};
Expand Down
20 changes: 14 additions & 6 deletions crates/transport-http/src/hyper_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,24 @@ where
{
async fn do_hyper(self, req: RequestPacket) -> TransportResult<ResponsePacket> {
debug!(count = req.len(), "sending request packet to server");

let mut builder = hyper::Request::builder()
.method(hyper::Method::POST)
.uri(self.url.as_str())
.header(header::CONTENT_TYPE, header::HeaderValue::from_static("application/json"));

// Add any additional headers from the request packet.
for (key, value) in req.headers() {
if let Some(key) = key {
builder = builder.header(key, value);
}
}

let ser = req.serialize().map_err(TransportError::ser_err)?;
// convert the Box<RawValue> into a hyper request<B>
let body = ser.get().as_bytes().to_owned().into();

let req = hyper::Request::builder()
.method(hyper::Method::POST)
.uri(self.url.as_str())
.header(header::CONTENT_TYPE, header::HeaderValue::from_static("application/json"))
.body(body)
.expect("request parts are invalid");
let req = builder.body(body).expect("request parts are invalid");

let mut service = self.client.service;
let resp = service.call(req).await.map_err(TransportErrorKind::custom)?;
Expand Down
1 change: 1 addition & 0 deletions crates/transport-http/src/reqwest_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ impl Http<Client> {
.client
.post(self.url)
.json(&req)
.headers(req.headers())
.send()
.await
.map_err(TransportErrorKind::custom)?;
Expand Down
Loading