FABRKNT
Inside Alloy — Reading the Rust Ethereum Library
Inside Alloy
Lesson 5 of 15·CONTENT12 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
5 / 15

Lesson 4 — Drill: build a logging Provider wrapper

Question

Build LoggingProvider that wraps any Provider and logs every RPC call. Same pattern as FillProvider; just doing nothing useful except logging. The wrapper pattern is the key idea.

Principle (minimum model)

  • Struct. LoggingProvider<P, N> { inner: P, _phantom: PhantomData<N> }. Wraps; doesn't hold extra state.
  • Implement Provider. Override root() -> &RootProvider<N> returning self.inner.root(). All other methods inherit via default impls but go through logging.
  • Logging. Override the methods you want to log: get_balance etc. Call tracing::info!(...) before delegating.
  • Anvil setup. Anvil::new().fork(MAINNET).fork_block_number(BLOCK).spawn() + ProviderBuilder::new().on_http(anvil.endpoint().parse()?).
  • Wrap. let logged = LoggingProvider { inner: provider, _phantom: PhantomData }.
  • Call. logged.get_balance(addr).await?. Logging fires; result is identical to unwrapped.
  • Stacking. LoggingProvider<FillProvider<...>> works — wrappers compose because each forwards via root().

Worked example + steps

Drill: build a logging Provider wrapper

Reading is rehearsal. Doing is memory. This drill takes you from "I've read about wrapper providers" to "I have written one, run it against a real RPC endpoint, and watched my code on the path of every call."

You'll write a LoggingProvider that wraps any other Provider and logs every selected RPC call before forwarding to the inner provider. This is exactly the kind of code production indexers and MEV pipelines ship: observability layered on top of an RPC client without forking alloy.

Setup

You'll need three things:

  1. Foundry / Anvil — the local Ethereum dev node alloy talks to:

    curl -L https://foundry.paradigm.xyz | bash
    foundryup
    
  2. A fresh cargo project — separate from the alloy clone, but depending on alloy:

    cargo new alloy-logging-drill --bin
    cd alloy-logging-drill
    
  3. Add alloy + tokio + tracing to Cargo.toml:

    [dependencies]
    alloy = { version = "0.x", features = ["full", "provider-http", "node-bindings"] }
    tokio = { version = "1", features = ["full"] }
    tracing = "0.1"
    tracing-subscriber = { version = "0.3", features = ["env-filter"] }
    

    (Pin to whichever current alloy version you cloned for the find-in-repo prompts. The structural shape of Provider is durable across versions; specific feature flags drift.)

Drill 1 — Read FillProvider first

Before writing your own wrapper, read alloy's existing one. Open the alloy-rs/alloy clone and find:

crates/provider/src/fillers/mod.rs

🔍 Find impl<F, P, N> Provider<N> for FillProvider<F, P, N> (or the equivalent shape in your version). Look at:

  1. The root() method — what does it return? (should forward to self.inner.root())
  2. Which RPC methods does FillProvider explicitly override? (should be a small handful — send_transaction, maybe call/estimate_gas for gas filling)
  3. The methods that AREN'T overridden — they fall through to the trait's default impls. What gives those defaults access to the inner provider's transport?

Because the trait's default impls call self.client(), which falls back to self.root().client(). FillProvider's root() returns self.inner.root() — so every default-impl method automatically routes through the inner provider's transport. No code per method. This is the payoff of the root() indirection from the buildup.

Drill 2 — Sketch your LoggingProvider

In your src/main.rs (or a separate src/logging_provider.rs if you prefer):

use alloy::network::{Ethereum, Network};
use alloy::primitives::Address;
use alloy::providers::{Provider, RootProvider};
use std::marker::PhantomData;

pub struct LoggingProvider<P, N: Network = Ethereum> {
    inner: P,
    _network: PhantomData<N>,
}

impl<P, N: Network> LoggingProvider<P, N> {
    pub fn new(inner: P) -> Self {
        Self { inner, _network: PhantomData }
    }
}

impl<P, N> Provider<N> for LoggingProvider<P, N>
where
    P: Provider<N>,
    N: Network,
{
    fn root(&self) -> &RootProvider<N> {
        self.inner.root()
    }

    fn get_balance(&self, address: Address) -> alloy::providers::RpcWithBlock<Address, alloy::primitives::U256> {
        tracing::info!(?address, "LoggingProvider: get_balance called");
        self.inner.get_balance(address)
    }
}

(The exact return type of get_balance may have a slightly different alias in your version of alloy. Adjust to match what your IDE shows for the trait's signature; the structure is the same.)

It won't — you only intercepted get_balance. get_block_number falls through to the trait's default impl, which uses self.client() to route directly to the underlying transport via self.root(). Logging is opt-in per method: the wrapper only sees what you explicitly intercept.

For a production observability layer, you'd intercept the methods that matter (send_transaction, call, get_logs, get_balance) — not all 30+. Same payoff: write the bodies that earn their keep, default the rest.

Drill 3 — Wire it up against Anvil

Start Anvil in a second terminal:

anvil

(Default: http://localhost:8545, chain ID 31337, with 10 prefunded accounts.)

In your main.rs:

use alloy::providers::ProviderBuilder;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt()
        .with_env_filter("info")
        .init();

    let inner = ProviderBuilder::new()
        .on_http("http://localhost:8545".parse()?);

    let provider = LoggingProvider::new(inner);

    // First prefunded Anvil account
    let addr: Address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".parse()?;
    let balance = provider.get_balance(addr).await?;
    println!("balance: {balance}");

    // This should NOT log (we didn't intercept get_block_number)
    let block_number = provider.get_block_number().await?;
    println!("block: {block_number}");

    Ok(())
}

cargo run. You should see:

INFO LoggingProvider: get_balance called address=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
balance: 10000000000000000000000
block: 0

Drill 4 — Stack it with ProviderBuilder's Fillers

Real production providers are usually already wrapped in FillProviders for nonce/gas/chain-id management. Your LoggingProvider should compose with those, not replace them.

Modify your wiring to include alloy's recommended fillers:

let inner = ProviderBuilder::new()
    .with_recommended_fillers()  // adds nonce + gas + chain-id fillers
    .on_http("http://localhost:8545".parse()?);

let provider = LoggingProvider::new(inner);

let bal = provider.get_balance(addr).await?;

🔍 Question: Run it. Does the log still fire? Why does the layering work — LoggingProvider<FillProvider<NonceFiller, FillProvider<GasFiller, FillProvider<ChainIdFiller, RootProvider>>>> is a tower of wrappers. What property of Provider makes this composition automatic?

Because every wrapper's root() forwards to the next inner level, and the trait's default impls only need access to the root via self.root(). The whole tower flattens at the trait level — N levels of wrappers, 1 indirection chain at runtime.

This is the architectural unlock: arbitrary composition of wrappers, no wrapper is aware of the others, and adding a new wrapper layer is purely additive code.

End-of-lesson recall

Without scrolling, in your own words:

  1. FillProvider overrides ~3-5 methods out of 30+. What gives the un-overridden methods access to the inner provider's transport?
  2. Your LoggingProvider only logs get_balance. Why does get_block_number not log even though it's called on the same wrapper?
  3. LoggingProvider<FillProvider<...>> is a stack of wrappers. What single trait-level property makes this composition automatic, without each wrapper knowing about the others?

If any answer is shaky, the lesson isn't done with you. Re-run the drill or re-read the buildup's Step 4 (root()).

After this drill, you've shipped the same kind of code MEV pipelines and indexers use in production — observability layered on top of alloy without forking it. Next chain: the Network abstraction.

🧭 Where you are now in the stack: you've built the networking layer's client abstraction from scratch — N: Network generic, root() indirection, Filler composition, auto_impl. Five pieces that turn one trait into an arbitrarily composable observability + filling tower. Next chain re-uses these same pieces to open up the chain dimension (Optimism, Polygon, future L2s).

Summary (3 lines)

  • LoggingProvider<P, N> wraps any Provider. root() forwards via self.inner.root(); log + delegate per method.
  • Anvil fork harness; same Provider trait everywhere; wrapping is type-clean.
  • Stacking works — LoggingProvider<FillProvider<...>> because of root() indirection. Next module: Network.