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

Lesson 12 — Drill: ship an end-to-end signed tx via the FillProvider chain

Question

Build a Provider + sign + send a transaction end-to-end. ProviderBuilder::new().wallet(signer).on_http(url)? is the user API; FillProvider chain handles fill order.

Principle (minimum model)

  • ProviderBuilder::new().wallet(signer).on_http(url)? = Provider with FillProvider chain (Nonce + Gas + ChainId + Signer).
  • Fill order. Nonce → Gas → ChainId → Signer. Sign last because earlier fields go into the signed payload.
  • Anvil setup. Local node with hardcoded test keys.
  • Send. provider.send_transaction(tx).await?.get_receipt().await?. Tx propagates through fill chain; signer signs at the end; broadcast to node.
  • Assert. Receipt has expected from/to/value; nonce incremented.
  • Why the order matters. If signer fills first, sign payload missing nonce/gas → wrong signature. Order forced by FillProvider design.
  • Production parallel. This is how every Alloy-based dapp signs txs.

Worked example + steps

Drill: ship an end-to-end signed tx via the FillProvider chain

Reading is rehearsal. Doing is memory. This drill takes you from "I've read about Signer and WalletFiller" to "I have wired a real signer into a real ProviderBuilder, sent a signed transaction against Anvil, and observed the FillProvider chain do nonce / gas / chain-id / signing in stack order."

This is the capstone for the Provider, Network, and Signer chains: all three trait families come together in one runnable program.

Setup

Two terminals:

Terminal 1 — Anvil:

anvil

(Anvil prints 10 prefunded accounts at start; we'll use the first one.)

Terminal 2 — your project:

cargo new alloy-signer-drill --bin
cd alloy-signer-drill

Cargo.toml:

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

Drill 1 — Sign a hash directly with PrivateKeySigner

Before wiring up the full Provider chain, exercise the lowest-level Signer::sign_hash interface. Verify by recovering the signer's address from the resulting signature.

src/main.rs:

use alloy::primitives::{B256, keccak256};
use alloy::signers::{Signer, local::PrivateKeySigner};

#[tokio::main]
async fn main() -> eyre::Result<()> {
    tracing_subscriber::fmt().with_env_filter("info").init();

    // Drill 1: sign a hash directly
    let signer = PrivateKeySigner::random();
    let signer_addr = signer.address();
    println!("signer address: {signer_addr}");

    let message = b"hello, alloy";
    let hash = keccak256(message);
    let sig = signer.sign_hash(&hash).await?;

    let recovered = sig.recover_address_from_prehash(&hash)?;
    assert_eq!(recovered, signer_addr);
    println!("recovered: {recovered}  (matches: {})", recovered == signer_addr);

    Ok(())
}

cargo run. You should see the signer's address, then a recovered address that matches.

For PrivateKeySigner, v falls out of the k256 ECDSA-recoverable signing primitive directly — the secp256k1 sign-recoverable function returns it as part of the signature. No extra work. This is why PrivateKeySigner::sign_hash is one line of crypto: (r, s, v) = k256::sign_recoverable(privkey, hash).

For AwsSigner, v would have to be recovered manually (try v=0 and v=1, see which one produces the cached address). That's the cost of cloud signing the walkthrough flagged.

Drill 2 — Wire the signer into ProviderBuilder and send a real tx

Now exercise the full FillProvider chain. Use ProviderBuilder.wallet(signer) to install the signer, plus with_recommended_fillers() to add nonce / gas / chain-id fillers.

Add to main.rs (after the Drill 1 code):

use alloy::providers::{Provider, ProviderBuilder};
use alloy::primitives::{Address, U256, address};

// Drill 2: real signed tx via FillProvider
// Use one of Anvil's prefunded accounts (private key from Anvil's startup output)
let funded_pk = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
let funded_signer: PrivateKeySigner = funded_pk.parse()?;
let funded_addr = funded_signer.address();
println!("funded sender: {funded_addr}");

let provider = ProviderBuilder::new()
    .with_recommended_fillers()  // nonce + gas + chain_id
    .wallet(funded_signer)        // signing
    .on_http("http://localhost:8545".parse()?);

// Send 1 ETH to a fresh address
let recipient: Address = address!("000000000000000000000000000000000000beef");
let value = U256::from(1_000_000_000_000_000_000u128);  // 1 ETH

let pending = provider
    .send_transaction(
        alloy::rpc::types::TransactionRequest::default()
            .with_to(recipient)
            .with_value(value)
    )
    .await?;
let receipt = pending.get_receipt().await?;
println!("tx hash: {:?}", receipt.transaction_hash);
println!("status: {:?}", receipt.status());

let recipient_balance = provider.get_balance(recipient).await?;
println!("recipient balance: {recipient_balance}");

(The .with_to(...), .with_value(...) calls are the TransactionBuilder<N> trait methods from the Network chain. Notice how everything composes: Provider chain's ProviderBuilder, Network chain's TransactionBuilder, Signer chain's .wallet() — three chains, one runnable program.)

cargo run should produce something like:

signer address: 0x... (random)
recovered: 0x... (matches: true)
funded sender: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
tx hash: 0x...
status: true
recipient balance: 1000000000000000000

The recipient balance is exactly 1 ETH (1_000_000_000_000_000_000 wei). The signed transaction landed; the FillProvider chain did its job.

Drill 3 — Trace the fillers that ran

The with_recommended_fillers() + .wallet(signer) calls layered four TxFillers into the chain. Before send_transaction could submit anything, each filler ran in stack order on the outgoing TransactionRequest.

The four fillers and their actions:

FillerField(s) populatedAction
NonceFillernonceCalls eth_getTransactionCount(from, "pending")
GasFillergas, gasPrice (legacy) or maxFeePerGas + maxPriorityFeePerGas (EIP-1559)Calls eth_estimateGas and eth_gasPrice (or eth_feeHistory for EIP-1559)
ChainIdFillerchainIdCalls eth_chainId once and caches
WalletFillersignature (which becomes the TxEnvelope)Builds SignableTransaction from the request, calls signer.sign_transaction(), attaches the signature to produce a signed envelope

Order matters: WalletFiller runs after the others because it needs nonce/gas/chain_id to be present before computing the signature hash. Sign-after-fill is non-negotiable.

🔍 Find in repo. Open crates/provider/src/fillers/ (or wherever fillers live). Confirm: there's a TxFiller<N> trait, and NonceFiller, GasFiller, ChainIdFiller, WalletFiller are all impl TxFiller<N>. They are interchangeable; the order is determined by how with_recommended_fillers and .wallet() insert them.

Drill 4 — Anti-fluency: skip the fillers, observe the failure

Modify your code to skip with_recommended_fillers():

let provider_no_fillers = ProviderBuilder::new()
    .wallet(funded_signer.clone())  // only the wallet, no nonce/gas/chain_id
    .on_http("http://localhost:8545".parse()?);

let result = provider_no_fillers
    .send_transaction(
        TransactionRequest::default()
            .with_to(recipient)
            .with_value(U256::from(1_000_000_000_000_000_000u128))
    )
    .await;
println!("no-filler result: {:?}", result);

You'll see something like "Error: missing nonce" or "Error: missing gas/maxFeePerGas"`, depending on which validation fires first. The error proves the fillers do real work — without them, the request is incomplete. Adding them back makes the program work.

🔍 Try one more variation. Add only the nonce filler, observe what's missing next. Add gas, observe what's missing next. Each filler closes one gap.

Drill 5 — Inspect the final Provider type (optional)

If you want to see the layered FillProvider type, add this and let cargo complain:

let _: () = provider;  // type mismatch error will print the real type

The compiler will print something like:

expected (), found
  FillProvider<JoinFill<JoinFill<JoinFill<JoinFill<Identity, GasFiller>, NonceFiller>, ChainIdFiller>, WalletFiller<EthereumWallet>>, RootProvider, Ethereum>

(The exact type depends on alloy's internal naming.) Read left-to-right: FillProvider wraps the inner provider; the JoinFill<...> chain is the four fillers stacked. The wallet sits at the outermost layer, applied last during send_transaction.

This is the type-level realization of the Provider chain's "build the right tower" lesson — the type itself encodes the filler stack order.

End-of-lesson recall

Without scrolling, in your own words:

  1. PrivateKeySigner doesn't have to do recovery-id work; AwsSigner does. What's the architectural cause? Where does v come from for each?
  2. Trace through Drill 2: between provider.send_transaction(req) being called and the network seeing a signed envelope, which fillers ran in what order, and what RPC calls did they make?
  3. Why is WalletFiller last in the filler chain? What would break if it ran before NonceFiller?
  4. The compiler-printed type for your provider is a deep FillProvider<JoinFill<JoinFill<...>>>. What does the depth of nesting represent at runtime?

If any answer is shaky, the lesson isn't done with you. Re-run the drills or re-read the buildup.

After this drill, you've shipped a signed transaction through the full Provider/Network/Signer trio — the same shape every dapp, MEV bot, and indexer uses in production. Provider, Network, and Signer chains are complete. The course's final quiz is the next stop.

Summary (3 lines)

  • End-to-end: ProviderBuilder::new().wallet(signer).on_http(url)? → FillProvider chain (Nonce → Gas → ChainId → Signer).
  • Sign last because earlier fields go into signed payload. Anvil for test; send_transaction → get_receipt; assert receipt.
  • Production parallel: every Alloy dapp signs this way. Next: testing alloy consumers.