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:
| Filler | Field(s) populated | Action |
|---|---|---|
NonceFiller | nonce | Calls eth_getTransactionCount(from, "pending") |
GasFiller | gas, gasPrice (legacy) or maxFeePerGas + maxPriorityFeePerGas (EIP-1559) | Calls eth_estimateGas and eth_gasPrice (or eth_feeHistory for EIP-1559) |
ChainIdFiller | chainId | Calls eth_chainId once and caches |
WalletFiller | signature (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 aTxFiller<N>trait, andNonceFiller,GasFiller,ChainIdFiller,WalletFillerare allimpl TxFiller<N>. They are interchangeable; the order is determined by howwith_recommended_fillersand.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:
PrivateKeySignerdoesn't have to do recovery-id work;AwsSignerdoes. What's the architectural cause? Where doesvcome from for each?- 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? - Why is
WalletFillerlast in the filler chain? What would break if it ran beforeNonceFiller? - 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.