Lesson 9 — Building the Signer trait step by step
Question
Signer is alloy's abstraction over "something that can sign" — PrivateKeySigner (in-memory key) / AwsSigner (HSM) / Ledger / etc. Build it step by step.
Principle (minimum model)
- Naive sign function.
fn sign(msg: &[u8]) -> Signature. Four problems: async signing needed (AWS HSM is async) + multi-chain signing (Ethereum vs Optimism tx differ) + signing variants (transaction / message / typed_data) + signer holds a private key in-memory. - Step 1 — async. Sign needs to be async (AwsSigner uses async HTTP).
async fn sign(msg: &[u8]) -> Signature. - Step 2 — TxSigner<N> separate trait. Transaction-specific signing has chain-specific encoding. Separate from
Signer. - Step 3 — SignerSync wrapper. Tests don't want async;
SignerSyncwrapsSignerand provides sync API. - Step 4 — WalletFiller bridge. Connects the Signer to the FillProvider chain. Signer signs; WalletFiller injects into tx.
- Step 5 — Default
Sig = Signaturegeneric. Lets you use a custom signature type if needed (advanced).
Worked example + steps
Building the Signer trait step by step
A MEV searcher signs with an AWS KMS key (cloud key — the private key never leaves AWS). A treasury operator signs with a Ledger (hardware wallet — key on a USB device, requires a button press). A test suite signs with raw secp256k1 bytes in process. The same alloy application code has to drive all three. That's the constraint that shapes the Signer trait.
This chain builds the abstractions that make that possible: the Signer trait, the TxSigner<N> chain-specific variant, the async/sync split, and the WalletFiller that ties signing into the ProviderBuilder you used in the Provider drill.
By the end of this lesson you'll have built every piece of:
#[async_trait]
pub trait Signer<Sig = Signature> {
async fn sign_hash(&self, hash: &B256) -> Result<Sig>;
async fn sign_message(&self, message: &[u8]) -> Result<Sig> { /* default: hash, then sign_hash */ }
fn address(&self) -> Address;
fn chain_id(&self) -> Option<ChainId>;
fn set_chain_id(&mut self, chain_id: Option<ChainId>);
}
#[async_trait]
pub trait TxSigner<Sig> {
fn address(&self) -> Address;
async fn sign_transaction(&self, tx: &mut dyn SignableTransaction<Sig>) -> Result<Sig>;
}
pub trait SignerSync<Sig = Signature> { /* sync mirror of Signer */ }
Three traits. Each earns its keep against a specific failure mode you'll see if you try to short-circuit them.
📂 Open
alloy-rs/alloy/crates/signerin another tab.
Step 0 — The naive sign function
Without thinking, you'd write signing as one free function:
fn sign_tx(privkey: B256, mut tx: TypedTransaction) -> Result<TxEnvelope> {
let hash = tx.signature_hash();
let sig = secp256k1_sign(privkey, hash)?;
Ok(tx.with_signature(sig))
}
A function. Hardcoded B256 private key. Hardcoded TypedTransaction (an Ethereum type). Hardcoded secp256k1_sign (in-process, sync, no I/O).
The three:
- AWS KMS / Cloud HSM. The private key never leaves AWS. Signing is an async network call to a remote service. The function returns a signature; the caller never holds the key.
- Hardware wallets (Ledger, Trezor). Key on the device. Signing is an async USB / IPC call that waits for human button press.
- Multi-chain. Optimism's
UnsignedTxisOpTypedTransaction(with deposit variants), not Ethereum'sTypedTransaction. Hardcoding one type means the function doesn't work on the other chain.
Plus a fourth (cross-cutting): signing isn't always tx-signing. personal_sign (a.k.a. eth_sign with the EIP-191 prefix), signTypedData_v4 (EIP-712), raw hash signing for off-chain commitments — all need some signing capability but operate on different inputs.
The fix: abstract along three axes. Signer location (in-process / cloud / hardware), what's being signed (hash / message / tx), and async vs sync. Each axis gets its own trait or trait method.
Step 1 — First sketch: a single Signer trait
#[async_trait]
trait Signer {
async fn sign_hash(&self, hash: B256) -> Result<Signature>;
}
struct LocalSigner { privkey: B256 }
impl Signer for LocalSigner { /* in-process */ }
struct AwsSigner { client: AwsKmsClient, key_id: String }
impl Signer for AwsSigner { /* network call */ }
struct LedgerSigner { dev: LedgerDevice }
impl Signer for LedgerSigner { /* USB call */ }
One method: take a 32-byte hash, return a signature. Async (so cloud and hardware impls can do I/O). Each Signer impl picks where it stores the key.
This works for raw hash signing. But the user almost never calls sign_hash directly — they call something higher-level:
sign_message(b"hello")— apply EIP-191 prefix, hash, then signsign_transaction(tx)— encode tx, hash, then sign
These methods do extra work before the hash. So we add them.
Step 2 — Add sign_message with a default impl
#[async_trait]
trait Signer {
async fn sign_hash(&self, hash: &B256) -> Result<Signature>;
async fn sign_message(&self, message: &[u8]) -> Result<Signature> {
let prefixed = eip191_hash(message); // "\x19Ethereum Signed Message:\n" + len + msg
self.sign_hash(&prefixed).await
}
}
sign_message has a default impl that does the EIP-191 prefixing and calls sign_hash. Every Signer gets sign_message for free, but a remote signer can override it if the remote service has its own prefix logic.
Because default impls let signers override behavior. AWS KMS might want to forward the message bytes to a service that does its own prefixing — overriding sign_message on AwsSigner lets it do that without changing callers. A free function can't be overridden. Default impls are the right tool when you want "common behavior with optional per-impl customization."
This is the same shape as Provider (Step 4 of the Provider chain): default impls handle the common case, overrides handle the chain-specific case, all behind one trait.
Step 3 — Tx signing isn't just hash signing
Now consider sign_transaction:
async fn sign_transaction(&self, tx: TypedTransaction) -> Result<TxEnvelope> { ... }
It needs to:
- Encode the tx according to its type (Legacy / EIP-1559 / etc.)
- Hash that encoding (the signing hash, distinct from the transaction hash)
- Sign the hash
- Attach the signature to produce a
TxEnvelope
Here's the problem: TypedTransaction is an Ethereum-specific type. Optimism's deposit txs aren't TypedTransaction — they're OpTypedTransaction. If we put sign_transaction(&TypedTransaction) on Signer, OP signers can't implement it without dropping back to Ethereum-only.
Two options:
- Option A: Make
Signergeneric over network:Signer<N: Network>withsign_transaction(&N::UnsignedTx). - Option B: Split tx-signing into a separate trait that handles the chain-aware part, while
Signerstays chain-agnostic.
Alloy picks Option B: Signer stays simple (chain-agnostic, just hashes); TxSigner<N> is a separate trait for tx-signing. Reasons:
- Most signing operations aren't tx-signing. EIP-191 messages and EIP-712 typed data are far more common in dapps. Forcing every
Signerto be parameterized byNfor the rare tx case would bloat every signer signature. Signerimpls are reusable across chains.LocalSignerdoesn't care if you're on Ethereum or Optimism — it just signs hashes. Tagging it withNwould force one signer struct per chain.- Tx-signing is naturally polymorphic. A signer can implement
TxSigner<Ethereum>andTxSigner<Optimism>separately, with different code paths. Bundling them into one trait makes that harder.
So:
#[async_trait]
pub trait TxSigner<Sig> {
fn address(&self) -> Address;
async fn sign_transaction(&self, tx: &mut dyn SignableTransaction<Sig>) -> Result<Sig>;
}
(SignableTransaction is a trait implemented by every chain's UnsignedTx type. The Signer doesn't need to know which chain — it just needs to know how to sign whatever SignableTransaction is given.)
Step 4 — Async vs sync: the SignerSync split
Async is fine for AWS / Ledger. But for in-process key signing — the most common case — async is overhead. Every sign_hash call goes through a future even though there's no I/O.
Two ways to handle this:
- Accept the overhead:
async fneverywhere, even for in-process. - Provide a parallel sync trait, and let in-process signers implement both.
Alloy picks the second:
pub trait SignerSync<Sig = Signature> {
fn sign_hash_sync(&self, hash: &B256) -> Result<Sig>;
fn sign_message_sync(&self, message: &[u8]) -> Result<Sig> { /* default */ }
fn chain_id_sync(&self) -> Option<ChainId>;
}
LocalSigner implements both Signer (async) and SignerSync (sync). AwsSigner and LedgerSigner only implement Signer — they can't sign sync because they're network-bound.
Generic-over-S code can require either bound: fn foo<S: Signer> for code that must be async-tolerant, fn bar<S: SignerSync> for code that needs the cheaper path.
Because traits with async fn and traits without async fn are different kinds of things in Rust. An async fn foo() -> T is really fn foo() -> impl Future<Output = T> — it returns a Future rather than the result, forcing every caller to .await. Exposing the same method in sync and async forms means two completely different signatures.
An in-process signer can implement both — its sync method does the actual work, its async method just wraps in async for compatibility. A network-bound signer can only implement the async trait (I/O has to happen). Generic-over-S code chooses which contract to require. Fusing them would force every signer to commit to async, losing the cheaper-sync optimization (in-process signing skips both the .await and the Future allocation).
Step 5 — Tying it back to Provider: the WalletFiller
Recall from the Provider chain: FillProvider<F: Filler<N>, P, N> lets you stack chain-aware logic in front of the inner provider. Signing is one such Filler:
pub struct WalletFiller<W> {
wallet: W,
}
impl<W: TxSigner<...>, N: Network> Filler<N> for WalletFiller<W> {
async fn fill(&self, tx: &mut N::TransactionRequest) -> Result<()> {
// 1. resolve the unsigned tx from the request
// 2. self.wallet.sign_transaction(...)
// 3. attach signature
}
}
(Approximate shape — exact alloy has more nuance around how the unsigned tx is built and signed.)
This is the bridge: the Signer (or TxSigner<N>) trait abstracts how signing happens; the WalletFiller is the Filler-side machinery that invokes signing at the right point in the request flow.
User code:
let signer = PrivateKeySigner::random();
let provider = ProviderBuilder::new()
.wallet(signer) // installs WalletFiller(signer)
.with_recommended_fillers()
.on_http(url);
provider.send_transaction(tx).await?; // tx is signed by the WalletFiller
The .wallet(...) builder method is a Filler installer that wraps the signer in a WalletFiller and stacks it onto the FillProvider chain. The user never thinks about Signer directly — they think "hand the wallet to the builder."
What you've built
// Lowest-level: chain-agnostic hash/message signing
#[async_trait]
pub trait Signer<Sig = Signature> {
async fn sign_hash(&self, hash: &B256) -> Result<Sig>;
async fn sign_message(&self, message: &[u8]) -> Result<Sig> { /* default */ }
fn address(&self) -> Address;
fn chain_id(&self) -> Option<ChainId>;
fn set_chain_id(&mut self, chain_id: Option<ChainId>);
}
// Sync mirror for in-process keys
pub trait SignerSync<Sig = Signature> { /* parallel sync API */ }
// Tx-signing: separate trait, works against any SignableTransaction
#[async_trait]
pub trait TxSigner<Sig> {
fn address(&self) -> Address;
async fn sign_transaction(&self, tx: &mut dyn SignableTransaction<Sig>) -> Result<Sig>;
}
Every piece earned its keep:
Signerasync (Step 1) — handles cloud and hardware signers.sign_messagedefault impl (Step 2) — common EIP-191 path with override hook.TxSignerseparate trait (Step 3) — keepsSignerchain-agnostic; lets a signer implement multiple chains.SignerSyncparallel trait (Step 4) — in-process keys avoid async overhead; net-bound signers only do async.WalletFiller(Step 5) — bridgesSigner/TxSignerinto theProviderrequest flow via the Filler machinery from the Provider chain.
The next lesson reads alloy's real Signer trait, the PrivateKeySigner impl, the AwsSigner impl, and the WalletFiller source line by line.
Recall before moving on
Without scrolling:
- Why is
Signerasync, not sync? Name two production signers that need async. - Why is
TxSigner<Sig>a separate trait fromSigner? What would break if tx-signing were just a method onSigner? - Why does
SignerSyncexist? What does an in-process signer gain by implementing it alongsideSigner? - The user code
ProviderBuilder.wallet(signer)doesn't mentionWalletFillerorFillerdirectly. How do those connect under the hood?
If any answer is shaky, scroll back. The next lesson reads alloy's real Signer source + concrete impls.
🧭 Where you are now in the stack: you've built the authentication layer's signer abstraction —
Signerfor hashes,TxSigner<N>for tx envelopes, async/sync split for cloud vs local, andWalletFillerto graft signing into the FillProvider chain. The same user code now drives local keys, cloud KMS, and hardware wallets without any of them knowing about the others. Next lesson opens up the real implementations.
Summary (3 lines)
- 6-step buildup: async sign → TxSigner<N> separate trait → SignerSync wrapper → WalletFiller bridge → default Sig generic.
- Real signing tree: Signer (async / Sig generic) + TxSigner<N> (chain-specific tx) + SignerSync (sync wrapper).
- WalletFiller is how signer plugs into FillProvider chain. Next: walk real impls.