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

Lesson 2 — Reading the real Provider trait

Question

Read the actual Provider trait in alloy-rs/core. Verify what we built matches.

Principle (minimum model)

  • Trait header. #[auto_impl(&, &mut, Box, Rc, Arc)] pub trait Provider<N: Network = Ethereum>: Send + Sync. Default N = Ethereum removes typing noise for the common case.
  • root() is the only required method. All other RPC methods have default impls that forward via self.root(). ~30 methods; only one to override.
  • Database-like asymmetry. Provider's auto_impl has 5 variants (& + &mut + Box + Rc + Arc); Revm's Database has 2 (&mut + Box). Asymmetric because Provider methods are &self (cache via internal mutability) and Database methods are &mut self (cache in place).
  • 3 return types. ProviderCall<R> (basic future), RpcWithBlock<R> (block-parameterised), EthCall<R> (call-specific). Different RPC methods return different builders.
  • Why 30+ methods. Each EVM RPC is a method. Generic users only override root(); specific producers override the methods they need.

Worked example + steps

Reading the real Provider trait

You built Provider from a naive RPC client up to the real trait shape. Now open the source — crates/provider/src/provider/trait.rs — and read the production version line by line. Each piece you read should snap back to the buildup step that motivated it.

Most importantly, this lesson covers what the build-up deliberately glossed over: the return-type machinery (ProviderCall, RpcWithBlock, EthCall, PendingTransactionBuilder — future-builder types that let you customize an RPC call before awaiting it). Those wrapper types are the part of alloy that newcomers find weirdest — and once you see why they exist, the trait surface stops looking arbitrary.

📂 Open alloy-rs/alloy/crates/provider/src/provider/trait.rs now. The exact line numbers and method bodies drift; the structural points are durable. When the lesson says "in current alloy main," verify yourself before quoting it elsewhere.

The trait header

#[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()
    }
    // ...many RPC methods, all default-impl'd
}

Three things to look hard at:

Send + Sync supertraits

Every Provider implementation must be safe to send across threads (Send — the value can move to another thread) and reference from multiple threads (Sync&P can be shared). This isn't decorative — production users wrap providers in Arc<P> (an atomically reference-counted shared pointer) and clone the Arc into many task handlers (workers, MEV searchers, indexer streams). Without Send + Sync, those usages don't compile.

#[auto_impl(&, &mut, Box, Rc, Arc)]

Five wrappers. Same shape as DatabaseRef from Inside Revm, and for the same reason: Provider only needs &self to read state — no &mut self mutation in the method signatures — so all five wrapper types work through the trait. Arc<P> and Rc<P> give you cheap shareable handles.

Database's methods take &mut self (so impls can cache reads in place). That excludes &/Rc/Arc because those don't give out &mut T. Provider's methods take &self — caching, if needed, has to use interior mutability inside the implementation (Mutex, OnceLock, atomic primitives). The wider auto_impl list is a direct consequence of the &self choice. Same trade-off shape, different decision per use case.

root() is the one required method

Everything else has a default impl. An impl just needs to point at the root. Wrapping providers (signer, gas filler, nonce filler) implement root() by forwarding to their inner: self.inner.root(). The trait's default methods then route through self.root() for transport access. Wrapper authors write one line.

🔍 Find in repo. Search for fn root(&self) -> &RootProvider across alloy. Count the impls — every wrapper provider, every adapter, every test fixture has one. This is the load-bearing method of the whole design.

The transport accessors: client() vs weak_client()

Two flavors:

fn client(&self) -> ClientRef<'_>     // strong reference, lifetime-bounded
fn weak_client(&self) -> WeakClient   // owned weak reference, no lifetime

Why two?

  • client() for synchronous calls: you borrow the client briefly, send a request, get a response. Lifetime-bounded — you can't outlive the borrow.
  • weak_client() for tasks that outlive the borrow: subscriptions, long-lived background tasks, anything spawned with tokio::spawn that needs a handle to the client without keeping the provider alive. Weak references don't prevent drop; if the provider is dropped, the task notices and shuts down.

The asymmetry maps to the two operational modes alloy supports: short-lived RPC calls and long-lived subscription streams.

The RPC method surface — three representative shapes

Don't try to read all 30+ methods. Read three that show the return-type pattern:

get_block_number — no params, simple result

fn get_block_number(&self) -> ProviderCall<NoParams, U64, BlockNumber> {
    self.client().request_noparams("eth_blockNumber").into()
}

ProviderCall<P, R, F> is a future-builder — it represents an in-flight RPC call you haven't awaited yet. The three type parameters: P = params type, R = raw response type, F = final user-facing type (e.g., BlockNumber is U64 re-typed for ergonomics).

Why isn't it just impl Future<Output = u64>? Because ProviderCall lets you customize the call before awaiting it. For get_block_number there's not much to customize, but the trait uses the same shape for all RPC methods, so the customization machinery is always available.

get_balance — block selector via builder

fn get_balance(&self, address: Address) -> RpcWithBlock<Address, U256> {
    RpcWithBlock::new_provider(move |block| {
        self.client().request("eth_getBalance", (address, block)).into()
    })
}

RpcWithBlock is a builder that lets the user pick which block to query at the call site:

provider.get_balance(addr).await                            // latest (default)
provider.get_balance(addr).block_id(1_000_000.into()).await // historical
provider.get_balance(addr).hash(some_hash).await            // by block hash
provider.get_balance(addr).pending().await                  // pending block

Hardcoding "latest" inside get_balance would force users who want historical or pending queries to construct different method calls. The builder pattern keeps one method, many ways to query.

Because most calls want latest and bloating every method's signature with a block_id parameter would be noisy at every call site. The builder pattern keeps the common case (get_balance(addr).await) terse and the rare case (...block_id(N).await) explicit. Same trade-off as default type parameters — bias the API toward the 95% case.

call — the customization-heavy one

fn call(&self, tx: N::TransactionRequest) -> EthCall<N> {
    EthCall::new(self.client(), tx)
}

EthCall is the most elaborate builder. Look at what you can chain on it:

  • .block(BlockId) — eth_call against a specific block
  • .overrides(state_overrides) — eth_call with state overrides (impersonate accounts, override balances, override code)
  • .gas(...), .value(...), .from(...) — modify the tx before the call

The eth_call JSON-RPC method has 4-5 optional parameters. Encoding all of them in one method signature would be an unreadable mess. The builder pattern factors them out into chainable methods. Each is its own fn on EthCall, independent of Provider.

🔍 Find in repo. Open crates/provider/src/provider/eth_call.rs (or wherever EthCall lives in the version you have). Count the chainable methods. That count IS the API surface of eth_call — they map 1:1 with the optional fields of the JSON-RPC method.

The pattern: builder return types

Three return shapes (ProviderCall, RpcWithBlock, EthCall) might look like API bloat. They're not. Each one is a generic builder pattern matched to the optionality structure of the underlying RPC method.

RPC methodOptional paramsReturn type
eth_blockNumbernoneProviderCall (just await)
eth_getBalanceblockRpcWithBlock (one optional dimension)
eth_callblock, from, gas, value, stateEthCall (many optional dimensions)

Each return type exposes exactly the customizations that match the JSON-RPC spec for that method. Type-driven discoverability — the IDE shows you the legal optionals via the builder methods on the return type.

How default impls work

The trait body is mostly default impls. Almost every method follows this shape:

fn get_X(&self, args...) -> SomeReturnType {
    self.client().request("rpc_methodName", args).into()
}

Build the request via the client (self.client() ultimately reaches RootProvider's transport). Convert into the appropriate builder/future type via .into().

This is why wrapper providers don't have to override anything except what they actually change. SignerProvider overrides send_transaction; everything else falls through to the default, which uses self.root() (and from there, the transport) automatically.

PendingTransactionBuilder — sending transactions

One method worth a separate look:

fn send_transaction(&self, tx: N::TransactionRequest) -> SendTransaction<N>

SendTransaction is similar to the builders above, but it handles a multi-step interaction:

  1. Submit the transaction (returns a PendingTransactionBuilder)
  2. Optionally configure: how many confirmations to wait for, timeout, etc.
  3. Await — get back the receipt once the transaction is mined

The builder lets the user choose the level of "waiting":

provider.send_transaction(tx).await?                       // tx hash only, no waiting
provider.send_transaction(tx).with_required_confirmations(3).get_receipt().await? // wait 3 blocks

Same builder pattern, but stretched across the state machine of "tx submitted → mined → confirmed."

Recall before the quiz

Without scrolling:

  1. Provider has auto_impl(&, &mut, Box, Rc, Arc) while Database has auto_impl(&mut, Box). What's the load-bearing structural difference between the two traits that drives that?
  2. Why does get_balance return RpcWithBlock instead of impl Future<Output = U256>?
  3. SignerProvider (a wrapper provider) needs to forward 30+ methods to its inner provider. How many method bodies does the author actually write, and why?
  4. weak_client() exists alongside client(). When would you use the weak version?

The next lesson is a quiz that gates progression. You can't nod past a quiz — engage with these recalls now if any answer is shaky.

Summary (3 lines)

  • Real Provider trait: <N: Network = Ethereum> default, auto_impl(&, &mut, Box, Rc, Arc), root() as the only required method.
  • 5-wrapper asymmetry vs Revm Database (&mut, Box only) because Provider methods are &self (internal mutability).
  • 3 return-type builders (ProviderCall / RpcWithBlock / EthCall). Next: quiz.