-
Notifications
You must be signed in to change notification settings - Fork 796
Object Safe Ethers Middleware #592
Comments
I have a different and somewhat bold proposal, which is likely going to be controversial as it would break existing APIs quite extensively and definitely deserves further discussion. The gist of it is basically this: Make the Why?To me, the design of the
AlternativeFirst, I would redesign the two most low-level transport-related traits as follows: // errors are boxed, to keep the return slot small
pub type RequestPayload = Result<Box<RawValue>, Box<TransportErr>>;
pub trait Transport {
fn request_id(&self) -> u64;
// without async fn in traits yet
fn send_request(&self, request: Box<RawValue>) -> Pin<Box<dyn Future<Output = RequestPayload>>>;
} This design trades convenience in the base trait due to generics for full object safety. Note, that convenience can be easily added on top with the help of extension traits and blanket impls, e.g. like so (generics in extension traits are ok, as long as the base trait does not use any): pub struct Request<'a, T> {
pub id: u64,
pub method: &'a str,
pub params: T,
}
impl<'a, T: Serialize> Request<'a, T> {
pub fn to_json(&self) -> Box<RawValue> {
todo!()
}
}
pub trait TransportExt: Transport {
fn build_request<'a, T>(&self, method: &'a str, params: T) -> Request<'a, T> {
Request { id: self.request_id(), method, params }
}
}
impl<T: Transport> TransportExt for T {} There should also be a blanket impl for pointer/reference types: impl<T, D> Transport for D
where
T: Transport,
D: Deref<Target = T>,
{
// ...
} That way, any of the following instantiations for a hypothetical type like pub struct EthClient<T> {
// ...
}
type A = EthClient<Ipc>; // e.g. when no sharing is required at all, note that Ipc does not need to be `Clone`
type B<'a> = EthClient<&'a Ipc>;
type C = EthClient<Arc<Ws>>; // when sharing is required
type D = EthClient<Arc<dyn Transport>>; // object-safe!
type E = EthClient<Rc<LocalHttp>>; // even thread-local impls are possible An analogue definition of the Here is an example how the JSONRPC API could be implemented using these trait definitions: impl<T: Transport> Client<T> {
// async fn outside of trait, much nicer to use currently
pub async fn get_balance(&self, address: &Address) -> Result<U256, Box<ClientErr>> {
// uses the extension trait, note that only a single allocation is required and a reference
// to the address is sufficient
let request = self.transport.build_request("eth_getBalance", [address]).to_json();
let result = self.transport.send_request(request).await.map_err(|err| {
let context = "failed `eth_getBalance` call, failed RPC call".into();
ClientErr::transport(err, Some(context))
})?;
let balance: U256 = serde_json::from_str(result.get()).map_err(|err| {
let context = "failed `eth_getBalance` call, unable to deserialize `U256`".into();
ClientErr::json(err, Some(context))
})?;
Ok(balance)
}
}
impl<T: DuplexTransport> EthClient<T> {
// all subscription-related functionality goes here
} Note, that the error handling is just examplary here, but in my mind every failure should allow the caller to diagnose what precisely went wrong in which call. Replacing the
|
thanks for this write-up, First priority is definitely making the What's probably missing here is a, which I think should work, even if T : Serialize impl TransportExt for dyn T {} This is great because it removes the requirement to directly pass a raw value or worse a value to the transport. That should cover Transports (Http, Ws, Ipc) and yeah a pubsub transport would return a Stream type instead. This would of course require changes to the middleware which would now have to handle deserializing a response, which is fine, we could solve this similar to reqwest with a A big benefit the current imo we'd need a trait akin to the but there's a pattern for stacking a bunch of pub struct Provider<T:Transport> {
transport :T, // or even `Arc<dyn Transport>`?
stack: Box<[Arc<dyn Middleware>]>, // assuming the stack will be determined when the provider is created
}
impl Provider {
async fn send_transaction(&self, tx: impl Into<Transaction) -> Result<...> {
let next = Next::new(&self.transport, &self.stack);
next.send_transaction(tx.into().await
}
}
#[derive(Clone)]
pub struct Next<'a> {
transport: &'a Transport // maybe even Arc<dyn Transport>
middlewares: &'a [Arc<dyn Middleware>],
}
impl<'a> Next<'a> {
pub async fn send_transaction(
mut self,
tx: Transaction,
) -> Result<...> {
if let Some((current, rem)) = self.middlewares.split_first() {
self.middlewares = rem;
current.send_transaction(req, self).await
} else {
let request = self.transport.build_request("eth_sendTransaction", [tx]).to_json();
self.transport.send_request(req).await
}
}
} Basically peeling off the middleware layers of the stack. also tagging @prestwich here |
Sorry, I am not entirely sure what you meant by this, but I assume you intended something like impl TransportExt for dyn Transport + '_ {} I just checked and you are correct, this would indeed be required for instantiations like There is some debate to be had, though, whether it would make sense to use an even less specific type like
To me, an all-encompassing trait like |
Thanks for the input / brainstorm @oliver-giersch. I am +1 w Seitz re easy extensibility of Middleware trait. Curious to hear what other abstraction you think can satisfy our needs.. |
Ok, so I managed to get an overview over the full extent of the pub trait Middleware {
//////////// 1) Middleware trait related methods (3) ///////////////////////////////////////////
fn inner(&self);
fn provider(&self);
fn default_sender(&self);
async fn is_signer(&self) -> ..;
//////////// 2) Higher-level abstractions (13) /////////////////////////////////////////////////
async fn fill_transaction(&self, ..) -> ..;
async fn send_transaction(&self, ..) -> ..;
async fn send_escalating(&self, ..) -> ..;
async fn resolve_name(&self, ..) -> ..;
async fn lookup_address(&self, ..) -> ..;
async fn resolve_avatar(&self, ..) -> ..;
async fn resolve_nft(&self, ..) -> ..;
async fn resolve_field(&self, ..) -> ..;
async fn sign_transaction(&self, ..) -> ..;
async fn watch(&self, ..) -> ..;
async fn watch_pending_transactions(&self) -> ..;
async fn watch_blocks(&self) -> ..;
//////////// 3) Low-Level Geth API (46) ////////////////////////////////////////////////////////
async fn client_version(&self) -> ..;
async fn get_block_number(&self) -> ..;
async fn get_block(&self, ..) -> ..;
async fn get_block_with_txs(&self, ..) -> ..;
async fn get_uncle_count(&self, ..) -> ..;
async fn get_uncle(&self, ..) -> ..;
async fn get_transaction_count(&self, ..) -> ..;
async fn estimate_gas(&self, .) -> ..;
async fn call(&self, ..) -> ..;
async fn syncing(&self) -> ..;
async fn get_chainid(&self) -> ..;
async fn get_net_version(&self) -> ..;
async fn get_balance(&self, ..) -> ..;
async fn get_transaction(&self, ..) -> ..;
async fn get_transaction_receipt(&self, ..) -> ..;
async fn get_block_receipts(&self, ..) -> ..;
async fn get_gas_price(&self) -> ..;
async fn get_accounts(&self) -> ..;
async fn send_raw_transaction(&self, ..) -> ..;
async fn sign(&self, ..) -> ..;
async fn get_logs(&self, ..) -> ..;
async fn new_filter(&self, ..) -> ..;
async fn uninstall_filter(&self, ..) -> ..;
async fn get_code(&self, ..) -> ..;
async fn get_storage_at(&self, ..) -> ..;
async fn get_proof(&self, ..) -> ..;
async fn txpool_content(&self) -> ..;
async fn txpool_inspect(&self) -> ..;
async fn txpool_status(&self) -> ..;
async fn trace_call(&self, ..) -> ..;
async fn trace_call_many(&self, ..) -> ..;
async fn trace_raw_transaction(&self, ..) -> ..;
async fn trace_replay_transaction(&self, ..) -> ..;
async fn trace_replay_block_transactions(&self, ..) -> ..;
async fn trace_block(&self, ..) -> ..;
async fn trace_filter(&self, ..) -> ..;
async fn trace_get(&self, ..) -> ..;
async fn trace_transaction(&self, ..) -> ..;
async fn parity_block_receipts(&self, ..) -> ..;
async fn subscribe(&self, ..) -> ..;
async fn unsubscribe(&self, ..) -> ..;
async fn subscribe_blocks(&self) -> ..;
async fn subscribe_pending_txs(&self) -> ..;
async fn subscribe_logs(&self, ..) -> ..;
async fn fee_history(&self, ..) -> ..;
async fn create_access_list(&self, ..) -> ..; So there is roughly 3 categories of methods. There are low-level functions, which more or less directly map to some JSONRPC API exposed by geth/parity in a type-safe manner (category 3). I also went and took a look at all the custom middlewares in The flashbots example mentioned by @mattsse I would also call semantically dubious, i.e., a misuse of the Most "legitimate" uses of this trait seem to revolve around providing a nice wrapper for sending transactions without having to fiddle with nonces, gas limits, gas prices and so on, with customizable behavior such as using different gas oracles and this could imo be realized with a much simpler design. I am thinking of something that roughly handles like the following: let etherscan = Etherscan::new(..);
let txn_sender = SenderBuilder::new(provider)
.signer(LocalWallet::try_new(..)?) // no signer: sign transactions by RPC call
.gas_oracle(etherscan) // generic, takes anything that impls GasOracle
.nonce_manager(LocalNonceManager::new())
.build();
let mut txn = TransactionRequest::new(...).value(100).to(..);
txn_sender.fill_transaction(&mut txn).await?; // sets from address, gas limits, gas prices, ..
let _ = txn_sender.send_transaction(txn).await?.await?; The whole gas escalator stuff seems sufficiently specialized to warrant yet another separate wrapper struct around such a hypothetical Ofc, I will freely admit that such a design is more limiting than and not as customizable as the current design, I am, however, doubtful, whether this customizability does in fact justify the resulting complexity, given my perceived lack for any real use-cases beyond the ones I have addressed above (please correct me if I am wrong on that). For now, my incremental proposal would be, to implement all of the category 3 methods as inherent methods of the Whether to split up the |
thanks for this write up, I think it makes sense how clustered it, what we'll probably end up with is
let txn_sender = SenderBuilder::new(provider)
.signer(LocalWallet::try_new(..)?) // no signer: sign transactions by RPC call
.gas_oracle(etherscan) // generic, takes anything that impls GasOracle
.nonce_manager(LocalNonceManager::new())
.build(); would this be equivalent to the final struct Provider {
tx,
gas,
nonce,
} ? this could be a bit tricky because if we have multiple handlers for the endpoints, I can image you'll end up using the I think I understand and we bootstrap this process with: struct Provider {
pub async fn get_balance() {}
//... basically _all_ functions we have
}
trait Middleware {
// all higher level abstraction functions + eth api
}
trait Geth: Middleware {
// geth specific endpoints
}
impl MiddleWare for Provider {
// delegating all functions
} I guess this way we could also get rid of lots of generic |
This is an amazing analysis - thank you @oliver-giersch The |
After implementing the experimental middleware, I think we get relatively little from this amount of abstraction. there's essentially no user-driven need for distinguishing between Geth and Parity/whatever at compile time, and working it into the type system adds a lot of complexity and mental overhead |
yeh, sounds like we can replace the |
No, the resulting pub struct TransactionSender<T, S, G, N> {
provider: Provider<T>,
signer: S
gas_oracle: G,
nonce_manager: N,
// + potential other fields I haven't considered yet
}
impl<T: Transport, S: Signer, G: GasOracle, N: NonceManager> {
pub async fn fill_transaction(&self, txn: &mut TransactionRequest) -> Result<(), Error> {
if txn.gas_price.is_none() {
// use gas oracle to get an appropriate value (simplified, needs provisions for Eip1159 txns ofc
}
if txn.gas.is_none() {
// use `provider` to call `eth_estimateGas`
}
// ...and so on
}
} Something along those lines, anyways... It would probably be possible to emulate the different API namespaces by introducing thin wrapper types like this: pub struct Provider<T> {
transport: T,
}
impl<T: Transport> Provider<T> {
pub fn debug(&self) -> DebugNamespace<'_, T> { ... }
}
pub struct DebugNamespace<'a, T> {
transport: &'a T
}
impl<T: Transport> DebugNamespace<'_, T> {
pub async fn trace_transaction(&self, ..) {}
pub async fn trace_replay_transaction(&self, ..) {}
// ..
} So all methods would be called liked this: let balance = provider.eth().get_balance(&addr).await?;
let trace = provider.debug().trace_transaction(txn).await?; There is something to it, but it might also obscure things unnecessarily and hamper the discoverability of these methods in the docs, etc. So with everyone's sentiment being generally favourable, I'd go ahead and prepare a PR that
|
I think this will break a lot of the types that rely on struct MyContract<M:Middleware>(ethers::contract::Contract<M>) unsure currently how this would look like with the I also think the namespace getters aren't very convenient, considering most of the time |
I am definitely opposed to the namespace getters meta-point: |
yeah similarly against namespace getters for the same reason |
Re using namespaces, I agree with the overall sentiment, I just wanted to bring up the possibility. I've begun working on a PR, but I expect that to take a couple of weeks (this is the branch, if anyone wants to take a look). I've tweaked the proposed API to this for now: pub type RequestFuture<'a> = Pin<Box<dyn Future<Output = ResponsePayload> + 'a>>;
pub type ResponsePayload = Result<Box<RawValue>, Box<TransportError>>;
pub trait Transport {
/// Returns a unique request ID.
fn request_id(&self) -> u64;
/// Sends a JSON-RPC request to the underlying API provider and returns its
/// response.
///
/// The caller has to ensure that `id` is identical to the id encoded in
/// `request` and that the latter represents a valid JSONRPC 2.0 request
/// whose contents match the specification defined by the Ethereum
/// [JSON-RPC API](https://eth.wiki/json-rpc/API).
fn send_raw_request(&self, id: u64, request: String) -> RequestFuture<'_>;
}
/// A trait providing additional convenience methods for the [`Transport`] trait.
pub trait TransportExt: Transport {
/// Serializes and sends an RPC request for `method` and using `params`.
///
/// In order to match the JSON-RPC specification, `params` must serialize
/// either to `null` (e.g., with `()`), an array or a map.
fn send_request<T: Serialize>(&self, method: &str, params: T) -> RequestFuture<'_> {
let id = self.request_id();
let request = Request { id, method, params }.to_json();
self.send_raw_request(id, request)
}
} Together with a small private helper function, this makes writing the impl<T: Transport> Provider<T> {
/// Returns the value from a storage position at a given address.
pub async fn get_storage_at(
&self,
address: &Address,
pos: &U256,
block: Option<&BlockNumber>,
) -> Result<U256, Box<ProviderError>> {
self.send_request("eth_getStorageAt", (address, pos, block)).await
}
// ...
async fn send_request<P, R>(&self, method: &str, params: P) -> Result<R, Box<ProviderError>>
where
P: Serialize,
R: for<'de> Deserialize<'de>,
{
// send the request & await its (raw) response
let raw = self.transport.send_request(method, params).await.map_err(|err| {
transport_err(err)
.with_ctx(format!("failed RPC call to `{method}` (rpc request failed)"))
})?;
// decode the response to the expected result type
let decoded = serde_json::from_str(raw.get()).map_err(|err| {
ProviderError::json(err).with_ctx(format!(
"failed RPC call to `{method}` (response deserialization failed)"
))
})?;
Ok(decoded)
}
} I am still experimenting with the best way to realize |
@oliver-giersch this is great! Could you also please take a look at @prestwich work in #1215 to make sure you're not doing duplicate work? |
I like this approach. However, one thing I'd suggest is that
|
I disagree with using Note that, even though the pub trait TransportExt: Transport {
fn send_request<T: Serialize>(&self, method: &str, params: T) -> RequestFuture<'_>;
} (even this is not perfectly type-safe, btw, because the JSON-RPC spec only allows BTW, Being a relentless optimizer, I had initially intended to use either |
Ethereum's JSON-RPC also has poor developer experience. This carries over into the devex of the After trying to improve the robustness and devex of gas oracles I'm starting to think the |
I just come across this issue when I was fighting again the error type in ethers-rs. I just want to bring up another perspective on writing the middleware in a composable way instead of a big trait. I would take tower-rs as a very good example for making a mini composable trait api. The middleware is serving a very simple function which take I in and output O like trait Middleware<In>{
type Output;
fn call(&mut self, in:In) -> Self::Output;
} The above codes is highly inspired from tower-rs service codes. Then you could just implement all kinds of Middleware to compose the way you want. Even the user could define the middleware themselves. |
Is your feature request related to a problem? Please describe.
The middleware stack right now, while flexible, is not object safe. This means that doing conditional instantiation of middlewares can be hard
Describe the solution you'd like
A trait design which is both Object Safe, and is nicely extensible the way it is right now, allowing consumers to only override the methods they specifically want, with everything else being delegated to the
fn inner()
.Describe alternatives you've considered
@prestwich has a very promising candidate design here (big shoutout as this has been discussed for months): https://github.com/prestwich/ethers-middleware-experimental
The text was updated successfully, but these errors were encountered: