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. Overrideroot() -> &RootProvider<N>returningself.inner.root(). All other methods inherit via default impls but go through logging. - Logging. Override the methods you want to log:
get_balanceetc. Calltracing::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 viaroot().
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:
-
Foundry / Anvil — the local Ethereum dev node alloy talks to:
curl -L https://foundry.paradigm.xyz | bash foundryup -
A fresh cargo project — separate from the alloy clone, but depending on alloy:
cargo new alloy-logging-drill --bin cd alloy-logging-drill -
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
Provideris 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:
- The
root()method — what does it return? (should forward toself.inner.root())- Which RPC methods does
FillProviderexplicitly override? (should be a small handful —send_transaction, maybecall/estimate_gasfor gas filling)- 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 ofProvidermakes 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:
FillProvideroverrides ~3-5 methods out of 30+. What gives the un-overridden methods access to the inner provider's transport?- Your
LoggingProvideronly logsget_balance. Why doesget_block_numbernot log even though it's called on the same wrapper? 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: Networkgeneric,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 viaself.inner.root(); log + delegate per method.- Anvil fork harness; same Provider trait everywhere; wrapping is type-clean.
- Stacking works —
LoggingProvider<FillProvider<...>>because ofroot()indirection. Next module: Network.