Lesson 1 — Building the Provider trait step by step
Question
Provider is alloy's top abstraction over "talking to an Ethereum node". Build it step by step from a naive RPC client. Six steps; each removes one assumption from the naive version.
Principle (minimum model)
- Step 0 — naive.
async fn get_balance(addr) -> Result<U256>that hard-codes the URL, transport, and chain. Three things hardcoded; three things to abstract. - Step 1 — Transport abstraction.
<T: Transport>removes URL/transport hardcoding. Now HTTP / WebSocket / IPC / Anvil-fork all work behind the same generic. - Step 2 — Network abstraction.
<N: Network>removes Ethereum hardcoding. Ethereum / Optimism / custom L2 all work behindN::TxEnvelopeetc. - Step 3 — RootProvider + root() indirection. Wrappers forward via
self.root(); one indirection enables FillProvider layering. - Step 4 — FillProvider stacking.
FillProvider<F, P, N>wraps a Provider with fillers (gas / nonce / signer / chain id). Layered composition; each filler is independent. - Step 5 —
auto_impl(&, &mut, Box, Rc, Arc). Five wrapper types automatically implement the trait.Arc<P>is a shared provider across tasks.
Worked example + steps
Building the Provider trait step by step
Every Rust program that talks to an Ethereum node — your MEV bot, your indexer, your dapp backend, your Reth-SDK app — routes through alloy-rs/alloy's Provider. It's the single abstraction over RPC. Open crates/provider/src/provider/trait.rs and the trait header looks like this (excerpted):
#[auto_impl(&, &mut, Box, Rc, Arc)]
pub trait Provider<N: Network = Ethereum>: Send + Sync {
fn root(&self) -> &RootProvider<N>;
fn client(&self) -> ClientRef<'_> { self.root().client() }
fn weak_client(&self) -> WeakClient { self.root().weak_client() }
fn get_block_number(&self) -> ProviderCall<NoParams, U64, BlockNumber> { /* ... */ }
fn get_balance(&self, address: Address) -> RpcWithBlock<Address, U256> { /* ... */ }
fn call(&self, tx: N::TransactionRequest) -> EthCall<N> { /* ... */ }
fn send_transaction(&self, tx: N::TransactionRequest) -> SendTransaction<N> { /* ... */ }
// ...many more RPC methods
}
Several things are happening at once: a generic over N: Network with a default (the chain — Ethereum, Optimism, custom L2), a root() accessor returning a separate RootProvider type, methods that return wrapper types you've never seen (ProviderCall, RpcWithBlock, EthCall), and auto_impl (a macro that derives the trait for &P, Box<P>, Arc<P>, etc.) covering five wrapper types.
Walk it cold and you get six new ideas at once. Easier path: build it up. Start from the dumbest RPC client you could write, then earn each piece of complexity. By the end you'll have built the real shape — Network parameterization, transport indirection, layered providers, and all.
📂 Open
alloy-rs/alloyin another tab. Cross-check at every step. The exact module paths drift across releases; the structural shape we'll build is durable.
Step 0 — The naive RPC client
If you were writing a Rust ↔ Ethereum bridge without thinking, your get_balance would look like:
async fn get_balance(addr: Address) -> Result<U256, Box<dyn Error>> {
let body = serde_json::json!({
"jsonrpc": "2.0",
"method": "eth_getBalance",
"params": [addr, "latest"],
"id": 1,
});
let client = reqwest::Client::new();
let resp = client.post("http://localhost:8545").json(&body).send().await?;
let parsed: serde_json::Value = resp.json().await?;
Ok(U256::from_str_radix(&parsed["result"].as_str().unwrap()[2..], 16)?)
}
A free function. Hardcoded URL. Hardcoded transport (HTTP). Hardcoded chain (Ethereum-shaped JSON-RPC).
The three:
- URL hardcoded. No way to point at Anvil, mainnet via Alchemy, a private node, or a fork harness without rewriting the function.
- Transport hardcoded. Only HTTP. WebSocket subscriptions and IPC connections are different transport mechanics; baking HTTP in means writing the function three times for the three transports.
- Chain hardcoded. Optimism transactions have an
L1Costfield. Custom L2s have their own envelope shapes. JSON-RPC method names overlap but parameter and response types differ. Hardcoding Ethereum's shape means you can't talk to anything else.
The fix: abstract these three axes into traits. Each axis becomes a generic parameter, the user picks once at construction, the trait method body stays the same.
Step 1 — First trait: a Provider over RPC methods
#[async_trait]
pub trait Provider {
async fn get_balance(&self, address: Address) -> Result<U256>;
async fn get_block_number(&self) -> Result<u64>;
async fn call(&self, tx: TransactionRequest) -> Result<Bytes>;
async fn send_transaction(&self, tx: TransactionRequest) -> Result<TxHash>;
// ... 30+ more methods, one per Ethereum RPC verb
}
Provider is now a trait. Implementations pick how to actually fulfill the calls (HTTP, WebSocket, mock, etc.). The user works against the trait and never thinks about transport.
This works for Ethereum. But the moment you want Optimism, the trait is wrong.
Step 2 — Multiple chains: the Network abstraction
Optimism's TransactionRequest includes an l1_block_number hint. Optimism receipts have an l1_fee field. The same RPC method names exist (eth_sendTransaction, eth_getTransactionReceipt), but the types differ.
You don't want to write OptimismProvider as a parallel trait — it'd be 90% identical. Instead, abstract the chain primitives behind a Network trait:
pub trait Network: Send + Sync + 'static {
type TxEnvelope: ...; // signed transaction representation
type UnsignedTx: ...; // unsigned tx
type ReceiptEnvelope: ...; // signed receipt
type Header: ...; // block header
type TransactionRequest: ...; // RPC call shape
type TransactionResponse: ...; // RPC response
type ReceiptResponse: ...;
type HeaderResponse: ...;
type BlockResponse: ...;
}
pub struct Ethereum;
impl Network for Ethereum { /* ...standard types... */ }
pub struct Optimism;
impl Network for Optimism { /* ...op-specific types... */ }
Network is a type-level dictionary — picking N: Network selects the bundle of types the chain uses, all in one place.
Now Provider becomes generic over N:
pub trait Provider<N: Network> {
async fn get_balance(&self, address: Address) -> Result<U256>; // unchanged — common to all chains
async fn call(&self, tx: N::TransactionRequest) -> Result<Bytes>; // chain-specific
async fn send_transaction(&self, tx: N::TransactionRequest) -> Result<...>; // chain-specific
// ...
}
Because 99% of users want Ethereum. A default lets them write Provider instead of Provider<Ethereum> everywhere. Only Optimism / custom-L2 users override. Default type parameters keep the common case ergonomic and the rare case explicit.
The real alloy trait has exactly this default: pub trait Provider<N: Network = Ethereum>.
Step 3 — Multiple transports: the indirection
Now picture two implementations: HttpProvider and WsProvider. Both fulfill the trait. But the underlying client (a thing that knows how to send JSON-RPC payloads) is different — one is a reqwest::Client, the other is a WebSocket connection.
If transport is part of the type, you'd have:
struct HttpProvider<N: Network> { client: reqwest::Client, url: Url, _phantom: PhantomData<N> }
struct WsProvider<N: Network> { conn: WsConnection, _phantom: PhantomData<N> }
struct IpcProvider<N: Network> { /* ... */ }
What
PhantomData<N>is: a zero-sized field that holds no runtime value but tells the compiler "this struct is generic overN". Zero runtime overhead; only compile-time type accounting. Used when you wantNas a type parameter without actually storing a value of typeN.
Three structs with the same trait methods, copy-pasted bodies. Bad.
Better: introduce a transport trait. The provider holds something that can send JSON-RPC; the something is itself a trait object:
pub trait Transport {
async fn send(&self, request: RpcRequest) -> Result<RpcResponse>;
}
// One concrete provider, parameterized over transport
pub struct ProviderImpl<T: Transport, N: Network> {
transport: T,
_network: PhantomData<N>,
}
🔍 Find in repo. Search alloy for
alloy-transportandalloy-transport-http. Notice how transports are separate crates. That's so you can depend onalloy-transport-httporalloy-transport-wswithout pulling in the other.
(In current alloy, the transport trait has evolved into a more elaborate Transport + TransportConnect design with tower::Service underneath, but the structural decision — abstracting transport so one provider impl works for all — is the durable shape.)
Step 4 — RootProvider and the root() indirection
So far we have one struct: ProviderImpl<T, N>. But there's a problem the SDK people who designed alloy ran into: users want to wrap providers.
Imagine you want a provider that automatically signs outgoing transactions. You'd write SignerProvider that wraps an inner Provider and overrides send_transaction:
pub struct SignerProvider<P: Provider, S: Signer> {
inner: P,
signer: S,
}
If SignerProvider itself implements Provider (forwarding most methods to inner and overriding send_transaction), users can stack: SignerProvider<NonceFiller<HttpProvider>>.
But these wrappers don't have their own transport — they need to delegate to the innermost provider for transport access. Hence the root() method:
pub trait Provider<N: Network = Ethereum> {
fn root(&self) -> &RootProvider<N>;
// default impls of every RPC method delegate through self.root()
}
pub struct RootProvider<N: Network = Ethereum> {
client: ClientRef<'_>, // the actual transport
_network: PhantomData<N>,
}
RootProvider is the concrete struct that owns the transport. Every other Provider impl just holds an inner provider and forwards root() to that inner. The trait's default methods can fall back to self.root() for transport access — so wrapper authors only override what they specifically want to change.
You override send_transaction (sign the tx, then forward to inner). For everything else (get_balance, call, etc.), you let the trait's default impls fire — they use self.root() to get the transport. You write 1 method body instead of 30.
Step 5 — Layered providers: FillProvider and Filler
The wrapper pattern from Step 4 generalizes. Common needs:
- Sign outgoing transactions (
Signer) - Fill in nonces (query
get_transaction_countif the user didn't specify one) - Fill in gas estimates (run
estimate_gasif the user didn't specify gas limit) - Fill in chain ID (query
chain_idonce, attach to every tx)
Each is a Filler — a small piece of logic that mutates an outgoing TransactionRequest before sending. Composed via:
pub struct FillProvider<F: Filler<N>, P: Provider<N>, N: Network> {
filler: F,
inner: P,
_network: PhantomData<N>,
}
impl<F: Filler<N>, P: Provider<N>, N: Network> Provider<N> for FillProvider<F, P, N> {
fn root(&self) -> &RootProvider<N> { self.inner.root() }
async fn send_transaction(&self, mut tx: N::TransactionRequest) -> Result<...> {
self.filler.fill(&mut tx).await?;
self.inner.send_transaction(tx).await
}
}
Stack them: FillProvider<NonceFiller, FillProvider<GasFiller, FillProvider<SignerFiller, RootProvider>>>. Each layer fills one piece. The user assembles via the builder:
let provider = ProviderBuilder::new()
.filler(NonceFiller)
.filler(GasFiller)
.signer(my_signer)
.on_http(url);
Each .filler(...) call wraps the inner provider in another FillProvider layer. Composition over inheritance, applied to RPC client construction.
Step 6 — auto_impl for shared usage
Last piece. Real alloy has:
#[auto_impl(&, &mut, Box, Rc, Arc)]
pub trait Provider<N: Network = Ethereum>: Send + Sync { /* ... */ }
auto_impl derives Provider for &P, &mut P, Box<P>, Rc<P>, Arc<P> whenever P: Provider. Why?
Because MEV bots, indexers, and dapp servers all want to share one provider across many tasks. The natural shape is Arc<Provider> — clone the Arc cheaply, hand it to a thousand worker tasks, they all hit the same connection pool. auto_impl makes Arc<P> implement Provider for free, so callers can use Arc<dyn Provider> or Arc<P> interchangeably with P itself.
What you've built
#[auto_impl(&, &mut, Box, Rc, Arc)]
pub trait Provider<N: Network = Ethereum>: Send + Sync {
fn root(&self) -> &RootProvider<N>;
// default-impl'd RPC methods that delegate via self.root()
}
Every piece earned its keep:
N: Network = Ethereum(Step 2) — type-level dictionary, default keeps Ethereum users ergonomic- Transport trait abstraction (Step 3) — one provider impl works across HTTP / WS / IPC
RootProvider+root()(Step 4) — wrappers can delegate transport access without re-implementing 30 methodsFillProvider/Filler(Step 5) — composable layering for signing, nonces, gasauto_impl(Step 6) —Arc<P>works asProvider; cheap to share across tasks
The next lesson reads alloy's actual crates/provider/src/provider/trait.rs line by line, mapping every line back to the build-up step that motivated it.
Recall before moving on
Without scrolling:
- Why is
N: Network = Ethereum(with a default) better thanN: Network(no default)? - What problem does
root()solve that "every Provider impl owns its own transport" wouldn't? - Sketch what the
Providerimpl onSignerProviderlooks like — which method body do you write, and which use the default? - Why is
auto_impl(Arc, ...)important for production usage of alloy?
If any answer is shaky, scroll back. The next lesson is a guided walkthrough of the real alloy Provider source.
Summary (3 lines)
- Six steps from naive
get_balanceto realProvider: Transport + Network + RootProvider/root() + FillProvider + auto_impl. - Each step removes one hardcoded assumption. Final shape supports HTTP / WebSocket / IPC / Anvil-fork × Ethereum / Optimism × Arc-shared.
- Next: walk through the real trait source.