FABRKNT
Inside Alloy — Reading the Rust Ethereum Library
Inside Alloy
Lesson 2 of 15·CONTENT10 min25 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Inside Alloy — Reading the Rust Ethereum Library
Lesson role
CONTENT
Sequence
2 / 15

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 behind N::TxEnvelope etc.
  • 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/alloy in 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:

  1. URL hardcoded. No way to point at Anvil, mainnet via Alchemy, a private node, or a fork harness without rewriting the function.
  2. 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.
  3. Chain hardcoded. Optimism transactions have an L1Cost field. 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 over N". Zero runtime overhead; only compile-time type accounting. Used when you want N as a type parameter without actually storing a value of type N.

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-transport and alloy-transport-http. Notice how transports are separate crates. That's so you can depend on alloy-transport-http or alloy-transport-ws without 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_count if the user didn't specify one)
  • Fill in gas estimates (run estimate_gas if the user didn't specify gas limit)
  • Fill in chain ID (query chain_id once, 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 methods
  • FillProvider / Filler (Step 5) — composable layering for signing, nonces, gas
  • auto_impl (Step 6) — Arc<P> works as Provider; 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:

  1. Why is N: Network = Ethereum (with a default) better than N: Network (no default)?
  2. What problem does root() solve that "every Provider impl owns its own transport" wouldn't?
  3. Sketch what the Provider impl on SignerProvider looks like — which method body do you write, and which use the default?
  4. 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_balance to real Provider: 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.