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

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; SignerSync wraps Signer and provides sync API.
  • Step 4 — WalletFiller bridge. Connects the Signer to the FillProvider chain. Signer signs; WalletFiller injects into tx.
  • Step 5 — Default Sig = Signature generic. 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/signer in 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:

  1. 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.
  2. Hardware wallets (Ledger, Trezor). Key on the device. Signing is an async USB / IPC call that waits for human button press.
  3. Multi-chain. Optimism's UnsignedTx is OpTypedTransaction (with deposit variants), not Ethereum's TypedTransaction. 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 sign
  • sign_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:

  1. Encode the tx according to its type (Legacy / EIP-1559 / etc.)
  2. Hash that encoding (the signing hash, distinct from the transaction hash)
  3. Sign the hash
  4. 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 Signer generic over network: Signer<N: Network> with sign_transaction(&N::UnsignedTx).
  • Option B: Split tx-signing into a separate trait that handles the chain-aware part, while Signer stays chain-agnostic.

Alloy picks Option B: Signer stays simple (chain-agnostic, just hashes); TxSigner<N> is a separate trait for tx-signing. Reasons:

  1. Most signing operations aren't tx-signing. EIP-191 messages and EIP-712 typed data are far more common in dapps. Forcing every Signer to be parameterized by N for the rare tx case would bloat every signer signature.
  2. Signer impls are reusable across chains. LocalSigner doesn't care if you're on Ethereum or Optimism — it just signs hashes. Tagging it with N would force one signer struct per chain.
  3. Tx-signing is naturally polymorphic. A signer can implement TxSigner<Ethereum> and TxSigner<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 fn everywhere, 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:

  • Signer async (Step 1) — handles cloud and hardware signers.
  • sign_message default impl (Step 2) — common EIP-191 path with override hook.
  • TxSigner separate trait (Step 3) — keeps Signer chain-agnostic; lets a signer implement multiple chains.
  • SignerSync parallel trait (Step 4) — in-process keys avoid async overhead; net-bound signers only do async.
  • WalletFiller (Step 5) — bridges Signer/TxSigner into the Provider request 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:

  1. Why is Signer async, not sync? Name two production signers that need async.
  2. Why is TxSigner<Sig> a separate trait from Signer? What would break if tx-signing were just a method on Signer?
  3. Why does SignerSync exist? What does an in-process signer gain by implementing it alongside Signer?
  4. The user code ProviderBuilder.wallet(signer) doesn't mention WalletFiller or Filler directly. 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 abstractionSigner for hashes, TxSigner<N> for tx envelopes, async/sync split for cloud vs local, and WalletFiller to 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.