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.
TxEnvelopeis Ethereum-specific. Same code can't talk to Optimism (which addsL1Costfields). - 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/networkin another tab. And keepcrates/consensusready — 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:
- Optimism. Tx envelopes include a
mintfield for L1-deposit-derived ETH. Receipts includel1_gas_usedandl1_block_number. Hardcoded Ethereum types can't carry these. - Anvil / Hardhat with custom hardforks. Anvil's
impersonateAccountreturns receipts with debug fields the standard Ethereum types don't have. - 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. TheUnsignedTxplus the signature. What gets broadcast over the wire.TransactionResponse— transaction as returned byeth_getTransactionByHash. Hasblock_hash,block_number,transaction_indexbaked 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 headerHeaderResponse— 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:
- No cohesion.
Provider<EthereumTxRequest, OptimismTxEnvelope, ...>would compile. There's nothing stopping you from mixing types from different chains. Associated types group them under oneNetworkimpl — you pickEthereumorOptimism, you get the whole coherent set. - Verbosity at every callsite. Every signature mentioning
Providerwould have to spell out 10 generic parameters.Provider<N: Network>is one parameter that pulls 10 types in. - No type-level identity.
Networkis a trait, so we can write functions likefn 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 reasonProviderrequires them: production users wrap providers inArc<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.'static—ProviderstoresPhantomData<N>. IfNhad a non-static lifetime parameter, everyProviderinstance 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:
TransactionRequestandTxEnvelopeare different associated types. What does the type system enforce by keeping them separate that a unifiedTransactiontype wouldn't?Networkuses associated types, not generic parameters. Name two concrete consequences of that choice for code that's generic over Network.'staticis one of the trait's bounds. What pattern would break if it wereNetwork: Send + Sync(no'static)?- Optimism deposits include an L1
mintfield. Which associated type would need to differ betweenEthereumandOptimismto 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.