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

Lesson 5 — Building the Network trait step by step

Question

Network lets one Provider work across Ethereum / Optimism / custom L2s. 10 associated types capture each chain's tx envelope / receipt / header shape. Build it step by step.

Principle (minimum model)

  • Step 0 — Ethereum hardcoded. TxEnvelope is Ethereum-specific. Same code can't talk to Optimism (which adds L1Cost fields).
  • Step 1 — Split tx into 4 states. TxEnvelope (signed) / UnsignedTx / TransactionRequest / TransactionResponse. Each chain might differ in each state.
  • Step 2 — 10 associated types. TxEnvelope + UnsignedTx + TransactionRequest + TransactionResponse + ReceiptEnvelope + ReceiptResponse + Header + HeaderResponse + Block + BlockResponse. The full chain typing.
  • Step 3 — Send + Sync + 'static bounds. All types must be cross-thread + program-lifetime. Tokio + cross-task compatibility.
  • Step 4 — Generic vs associated types. Associated types lock per-Network; generic would multiply types. Chosen for clarity.
  • Step 5 — Default N = Ethereum. Removes typing noise.

Worked example + steps

Building the Network trait step by step

Optimism's transactions carry an L1 mint field. Their receipts carry l1_fee and l1_block_number. Polygon zkEVM's tx envelope has a sequencer signature. Each L2 has its own tx, receipt, and block shapes — but the same Provider API still works on all of them. How? Through Network: alloy's type-level dictionary (one trait whose associated types select the bundle of chain-specific shapes a given chain uses).

The Provider chain treated Network as a black box. This chain opens it up.

By the end of this lesson you'll have built every piece of:

pub trait Network: Send + Sync + 'static {
    type TxType: ...;
    type TxEnvelope: ...;
    type UnsignedTx: ...;
    type ReceiptEnvelope: ...;
    type Header: ...;
    type TransactionRequest: ...;
    type TransactionResponse: ...;
    type ReceiptResponse: ...;
    type HeaderResponse: ...;
    type BlockResponse: ...;
}

Ten associated types. The shape looks weird until you see the failure modes that drive each one.

📂 Open alloy-rs/alloy/crates/network in another tab. And keep crates/consensus ready — most of the concrete types (TxEnvelope, Header, etc.) live there.

Step 0 — The naive Provider, hardcoded to Ethereum

Earlier in the Provider chain, our send_transaction looked like:

fn send_transaction(&self, tx: EthereumTransactionRequest) -> SendTransaction;

Hardcoded EthereumTransactionRequest. Hardcoded receipts. Hardcoded block headers. Works on mainnet — and only on mainnet.

The three:

  1. Optimism. Tx envelopes include a mint field for L1-deposit-derived ETH. Receipts include l1_gas_used and l1_block_number. Hardcoded Ethereum types can't carry these.
  2. Anvil / Hardhat with custom hardforks. Anvil's impersonateAccount returns receipts with debug fields the standard Ethereum types don't have.
  3. Custom L2s with bespoke tx envelopes. Polygon zkEVM, Scroll, Linea — each has its own tx envelope variant for L1-data fees, sequencer signatures, etc.

The fix: abstract the chain-specific types behind a trait.

Step 1 — First sketch: one type per concept

Naive trait:

trait Network {
    type Transaction;
    type Receipt;
    type Block;
}

struct Ethereum;
impl Network for Ethereum {
    type Transaction = EthereumTx;
    type Receipt    = EthereumReceipt;
    type Block      = EthereumBlock;
}

struct Optimism;
impl Network for Optimism {
    type Transaction = OpTx;
    type Receipt    = OpReceipt;
    type Block      = OpBlock;
}

This is almost right. Three associated types. Ethereum and Optimism each pick their own bundle. Generic-over-Network code (like Provider<N: Network>) reads/writes through the associated types.

But "transaction" isn't one type — it's several, each playing a different role.

Step 2 — Transactions have multiple lives

A single transaction passes through several representations:

  • TransactionRequest — what the user constructs. Most fields optional. Built via a builder API: TransactionRequest::default().with_to(addr).with_value(...).
  • UnsignedTx — request fully filled: nonce resolved, gas estimated, chain_id set. Ready to hash for signing.
  • TxEnvelope — signed transaction. The UnsignedTx plus the signature. What gets broadcast over the wire.
  • TransactionResponse — transaction as returned by eth_getTransactionByHash. Has block_hash, block_number, transaction_index baked in.

Each role has different fields, validation, serialization. Hammering them into one Transaction type would force every method to take a wide union and validate at runtime. Splitting them lets the type system enforce "you can't broadcast a TransactionRequest" or "you can't sign a TransactionResponse."

Validation gets pushed to runtime. broadcast(&tx) would have to check "is the signature actually present? is block_hash absent (because broadcasting a tx that already has a block_hash is nonsense)?" — runtime errors that the compiler should reject. Splitting into TransactionRequest / UnsignedTx / TxEnvelope / TransactionResponse makes those impossible-by-construction. Each function's signature only accepts the right state.

So our trait grows:

trait Network {
    type TxEnvelope;
    type UnsignedTx;
    type TransactionRequest;
    type TransactionResponse;
    type Receipt;
    type Block;
}

Six associated types. Notice: the same reason that forced "one transaction type per lifecycle stage" applies to Receipts and Blocks too.

Write down your guess before scrolling. The answer follows.

Step 3 — Receipts and headers split, too

eth_getTransactionReceipt returns a receipt-like object with consensus-defined fields plus RPC-decoration fields (transaction_hash, block_hash, block_number, transaction_index, etc.). The pure consensus shape — what goes into the Merkle root — is different from the API response.

Same split:

  • ReceiptEnvelope — consensus shape. Whatever the consensus protocol cares about.
  • ReceiptResponse — RPC return type, with extra "where in the chain" metadata.

Same shape for headers:

  • Header — consensus header
  • HeaderResponse — RPC-formatted header (with hash already computed, gas_used as a string for JSON, etc.)
  • BlockResponse — full block payload as returned over RPC

Plus one more — Ethereum and Optimism need to identify which kind of transaction they're dealing with (Legacy / EIP-1559 / EIP-4844 / OP-Deposit). That's:

  • TxType — enum tag for transaction category

Now the trait has:

trait Network {
    type TxType;
    type TxEnvelope;
    type UnsignedTx;
    type TransactionRequest;
    type TransactionResponse;
    type ReceiptEnvelope;
    type ReceiptResponse;
    type Header;
    type HeaderResponse;
    type BlockResponse;
}

Ten associated types. Each one earns its keep against a specific failure mode.

Step 4 — Why associated types, not generic parameters

Here's the design choice that confused me when I first read alloy: why is this an associated-type trait, not a struct of generic parameters? Like:

struct Provider<TxRequest, TxEnvelope, Receipt, Block, ...> { ... }

Three problems:

  1. No cohesion. Provider<EthereumTxRequest, OptimismTxEnvelope, ...> would compile. There's nothing stopping you from mixing types from different chains. Associated types group them under one Network impl — you pick Ethereum or Optimism, you get the whole coherent set.
  2. Verbosity at every callsite. Every signature mentioning Provider would have to spell out 10 generic parameters. Provider<N: Network> is one parameter that pulls 10 types in.
  3. No type-level identity. Network is a trait, so we can write functions like fn for_network<N: Network>(...) -> N::TransactionRequest. The Network name carries identity. Loose generics don't.

Associated types express "these go together." Generic parameters express "any combination is valid." For chain primitives, the former is the correct semantics.

The real alloy uses associated types for exactly this reason. Look at any function in alloy that touches "the chain's tx envelope" — it's N::TxEnvelope (associated-type access), not E (generic parameter).

Step 5 — Trait bounds: Send + Sync + 'static

pub trait Network: Send + Sync + 'static { ... }

Three bounds.

  • Send + Sync — for the same reason Provider requires them: production users wrap providers in Arc<Provider<N>> and clone across tasks. The associated types and the network type itself need to be safe to send/share. Without these, Arc<Provider<MyNetwork>> wouldn't compile.
  • 'staticProvider stores PhantomData<N>. If N had a non-static lifetime parameter, every Provider instance would be lifetime-bounded — a sharp constraint that breaks the "stash a Provider in a global Arc" pattern.

If MyNetwork had a borrowed lifetime (e.g., MyNetwork<'a> where 'a was the lifetime of some external config), Provider<MyNetwork<'a>> would inherit that lifetime. Storing the provider in an Arc (Arc<Provider<MyNetwork<'a>>>) means the Arc is also bounded by 'a. The Arc can't outlive whatever the config was borrowed from. 'static rules out that pattern: MyNetwork impls own all their data, no borrows. Arcs become free-standing.

Step 6 — Putting it together

pub trait Network: Send + Sync + 'static {
    type TxType: ...;
    type TxEnvelope: ...;
    type UnsignedTx: ...;
    type ReceiptEnvelope: ...;
    type Header: ...;
    type TransactionRequest: ...;
    type TransactionResponse: ...;
    type ReceiptResponse: ...;
    type HeaderResponse: ...;
    type BlockResponse: ...;
}

Each associated type has its own trait bounds (e.g., Serialize + DeserializeOwned, Clone, Encodable) — those make the responses RPC-serializable and the envelopes RLP-encodable. We're skipping those bounds in this lesson; the next lesson reads them in detail.

The shape:

  • TxType — enum tag (Legacy / EIP1559 / EIP4844 / chain-specific variants)
  • TxEnvelope / UnsignedTx — pre/post-signature consensus shape
  • TransactionRequest / TransactionResponse — what the user builds vs what RPC returns
  • ReceiptEnvelope / ReceiptResponse — consensus shape vs RPC return
  • Header / HeaderResponse / BlockResponse — consensus header, RPC header, RPC block

Concrete impls in alloy: Ethereum (in alloy-network), Optimism (in alloy-op-network), AnyNetwork (a permissive impl with serde-flavored types for "I don't know the chain ahead of time" tooling).

Recall before moving on

Without scrolling:

  1. TransactionRequest and TxEnvelope are different associated types. What does the type system enforce by keeping them separate that a unified Transaction type wouldn't?
  2. Network uses associated types, not generic parameters. Name two concrete consequences of that choice for code that's generic over Network.
  3. 'static is one of the trait's bounds. What pattern would break if it were Network: Send + Sync (no 'static)?
  4. Optimism deposits include an L1 mint field. Which associated type would need to differ between Ethereum and Optimism to support that?

If any answer is shaky, scroll back. The next lesson reads alloy's real Network trait + the Ethereum and Optimism impls in detail.

Summary (3 lines)

  • 6-step buildup: Ethereum hardcoded → split tx into 4 states → 10 associated types → Send + Sync + 'static bounds → associated (not generic) → default Ethereum.
  • Associated types lock the chain typing; generic would multiply.
  • Next: walk the real trait + Ethereum / Optimism impls.