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

Lesson 10 — Reading the real Signer trait + PrivateKeySigner / AwsSigner / WalletFiller

Question

Walk the real Signer trait + PrivateKeySigner / AwsSigner / WalletFiller.

Principle (minimum model)

  • pub trait Signer<Sig = Signature> with default-Signature generic; ~5 methods (sign_hash, sign_message, address, chain_id, set_chain_id).
  • PrivateKeySigner impl. Holds SecretKey; 5 constructors (random, from_bytes, from_str, from_slice, random_with_rng); sync.
  • AwsSigner impl. Async — calls AWS KMS for signing. Needs special handling: DER decoding + v=0/v=1 recovery to figure out the right v.
  • SignerSync wrapper. Wraps an async Signer and exposes sync API via Tokio runtime-block.
  • WalletFiller = TxFiller<N>. Inserts the signer into the FillProvider chain. Calls signer.sign_transaction at fill time.
  • auto_impl(&mut, Box, Arc). 3 wrappers; not 5 like Provider because Signer methods are &mut self (key state can mutate).

Worked example + steps

Reading the real Signer trait + PrivateKeySigner / AwsSigner / WalletFiller

You motivated the three-trait split (Signer / TxSigner / SignerSync) and the WalletFiller bridge. Now read the real source — the trait header with all its bounds, the in-process PrivateKeySigner, the cloud AwsSigner (where the recovery byte has to be brute-forced because AWS won't return it), the SignableTransaction glue, and WalletFiller's integration into the FillProvider chain.

📂 Open four files in tabs:

  • crates/signer/src/signer.rs — the Signer and SignerSync traits
  • crates/signer-local/src/private_key.rs — the PrivateKeySigner impl
  • crates/signer-aws/src/signer.rs — the AwsSigner impl
  • crates/provider/src/fillers/wallet.rs — the WalletFiller

The exact paths drift; structural shape is durable.

The trait header (real source)

#[async_trait]
#[auto_impl(&mut, Box, Arc)]
pub trait Signer<Sig = Signature>: Send + Sync {
    async fn sign_hash(&self, hash: &B256) -> Result<Sig>;

    async fn sign_message(&self, message: &[u8]) -> Result<Sig> {
        self.sign_hash(&eip191_hash_message(message)).await
    }

    fn address(&self) -> Address;
    fn chain_id(&self) -> Option<ChainId>;
    fn set_chain_id(&mut self, chain_id: Option<ChainId>);
}

Three things to look hard at:

Sig = Signature — default associated type-style parameter

The buildup wrote Signer<Sig = Signature>. The Sig parameter exists because not every chain uses ECDSA secp256k1 signatures (Ethereum's curve — 65-byte (r, s, v) tuple). Some L2s use BLS (aggregate-friendly), some use ed25519 (Solana's curve), some use post-quantum schemes. Defaulting to Signature (alloy's secp256k1 type) keeps the common case ergonomic — impl Signer is implicitly impl Signer<Signature> — while letting alternative schemes plug in.

Because the same signer might produce different signature types for different operations. A single PrivateKeySigner that holds an ECDSA key could implement both Signer<Signature> (for hash signing) and TxSigner<Signature> (for tx signing) — or, if you wanted, Signer<RawBytes> for a raw-bytes-out variant. Generic parameter = "I can implement this trait multiple times with different Sig." Associated type = "I commit to exactly one Sig per impl." For signers, generic is the right shape.

auto_impl(&mut, Box, Arc)

Three wrappers — narrower than Provider's five (&, &mut, Box, Rc, Arc).

The omissions are deliberate:

  • No &set_chain_id(&mut self, ...) is a mutating method. &self wouldn't compile through the trait. (Provider doesn't have any &mut self methods, hence its wider list.)
  • No RcSigner requires Send + Sync, but Rc<T> is !Send and !Sync because Rc's ref count isn't atomic. Arc<T> is the thread-safe version and stays.

Two omissions, two different reasons. The auto_impl list is a precise statement about the trait's contract.

Send + Sync supertraits

For the same reason Provider requires them: production code wraps signers in Arc<S> and shares them across tasks. The trait bounds make Arc<dyn Signer> and Arc<S: Signer> work as the natural sharing primitive.

PrivateKeySigner — the in-process impl

pub struct PrivateKeySigner {
    /// The `SigningKey` from k256 (Rust BIP-340 secp256k1).
    signer: SigningKey,
    /// The Ethereum address derived from the public key.
    address: Address,
    /// Chain ID for EIP-155 replay protection.
    chain_id: Option<ChainId>,
}

Three fields. The SigningKey is the actual private key. address is cached at construction (deriving it from the public key is non-trivial — keccak of the uncompressed pubkey, take last 20 bytes — so it's computed once). chain_id is per-signer, not per-call, because most users want one signer pinned to one chain.

Constructors

impl PrivateKeySigner {
    pub fn random() -> Self { /* OsRng → SigningKey */ }
    pub fn random_with(rng: &mut impl CryptoRng) -> Self { /* tests */ }
    pub fn from_bytes(bytes: &B256) -> Result<Self> { /* parse k256 key */ }
    pub fn from_str(s: &str) -> Result<Self> { /* hex → from_bytes */ }
    pub fn from_signing_key(signer: SigningKey) -> Self { /* direct */ }
}

Five constructors covering the realistic key sources: random for tests, from-bytes for stored keys, from-string for hex, direct for callers that already have a SigningKey from elsewhere. Construction is the one place PrivateKeySigner differs from network-bound signers — they have their own connect/configure flows.

Both Signer and SignerSync

PrivateKeySigner implements both the async Signer trait and the sync SignerSync trait. Generic-over-S code can use either:

fn high_throughput_path<S: SignerSync>(signer: &S) { /* sync, no future overhead */ }
fn cloud_compatible_path<S: Signer>(signer: &S) { /* async, works for AWS too */ }

PrivateKeySigner's sync path does the actual signing work; its async path is a thin wrapper that calls the sync path inside an async block. Async impls compose cheaply over sync ones; the reverse doesn't work, which is why SignerSync exists as a separate trait that fewer impls satisfy.

🔍 Find in repo. Open alloy-signer-local/src/private_key.rs. Confirm both impl Signer for PrivateKeySigner and impl SignerSync for PrivateKeySigner are present. Read the async sign_hash body — is it just async { self.sign_hash_sync(...) }?

AwsSigner — the cloud impl

pub struct AwsSigner {
    client: Client,         // the `aws-sdk-kms` Client
    key_id: String,
    address: Address,       // cached
    chain_id: Option<ChainId>,
}

#[async_trait]
impl Signer for AwsSigner {
    async fn sign_hash(&self, hash: &B256) -> Result<Signature> {
        let resp = self.client
            .sign()
            .key_id(&self.key_id)
            .message(Blob::new(hash.to_vec()))
            .message_type(MessageType::Digest)
            .send()
            .await?;
        // resp.signature is DER-encoded; convert to alloy Signature
        let sig = der_to_alloy(&resp.signature.as_ref())?;
        // recover the recovery id by trying both possibilities and matching address
        let recid = recover_recid(hash, &sig, &self.address)?;
        Ok(Signature { /* ... */ })
    }
    // ...
}

// AwsSigner does NOT impl SignerSync — there's no sync path through AWS KMS.

(Approximate — exact alloy is more elaborate around DER decoding and recovery-id resolution.)

Three things worth noting:

  1. AWS returns DER-encoded signatures, not the (r, s, v) tuple alloy uses. The signer's responsibility is to decode and adapt.
  2. Recovery ID isn't returned by AWS. AWS only returns r and s. The v (recovery byte) has to be recovered by trying both possibilities and checking which one produces the cached address. One AWS call → two address-derivation attempts → one matched signature.
  3. No SignerSync impl. AWS calls cross the network, so there's no sync path. Generic-over-S code that needs sync must accept that AWS signers are excluded.

Because address() is called for every transaction, often multiple times per tx (for tracking, logging, signing-eligibility checks, building call frames). If it were a network call, every transaction would incur AWS round-trip latency just to learn whose key signed it. Caching at construction trades a one-time setup for amortized zero per-call cost.

SignableTransaction — the chain-aware glue

pub trait SignableTransaction<Sig> {
    fn set_chain_id(&mut self, chain_id: ChainId);
    fn set_chain_id_checked(&mut self, chain_id: ChainId) -> bool;
    fn encode_for_signing(&self, out: &mut dyn BufMut);
    fn signature_hash(&self) -> B256;
    fn into_signed(self, signature: Sig) -> Signed<Self, Sig>
    where
        Self: Sized;
}

Every chain's UnsignedTx type (alloy_consensus::TypedTransaction for Ethereum, OpTypedTransaction for Optimism) implements SignableTransaction<Signature>. That's what gives TxSigner::sign_transaction something to operate on without knowing the chain:

async fn sign_transaction(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<Signature> {
    let hash = tx.signature_hash();
    self.sign_hash(&hash).await
}

The TxSigner doesn't know what kind of tx it is. It just calls signature_hash() on the trait object, gets back a 32-byte hash, signs that. Chain-agnostic signer + chain-specific UnsignedTx via the SignableTransaction bridge.

🔍 Find in repo. Open alloy_consensus::TypedTransaction. Find the impl SignableTransaction<Signature> for TypedTransaction. Note that signature_hash dispatches by tx type (Legacy uses RLP-encoded tx, EIP-1559 uses keccak of 0x02 || rlp(tx), etc.) — the encoding rules are inside the impl. The signer never sees them.

WalletFiller — the bridge into Provider machinery

pub struct WalletFiller<W> {
    pub wallet: W,
}

impl<W, N: Network> TxFiller<N> for WalletFiller<W>
where
    W: NetworkWallet<N>,
{
    type Fillable = Sendable<N::TxEnvelope>;

    // ...build the unsigned tx, sign it, attach the signature to the request
    async fn fill(&self, fillable: Self::Fillable, tx: &mut SendableTx<N>) -> TransportResult<...> {
        let envelope = self.wallet.sign_request(/* the unsigned tx from fillable */).await?;
        tx.envelope = Some(envelope);
        Ok(...)
    }
    // ...
}

(The exact alloy code involves several traits — NetworkWallet<N>, TxFiller<N>, Sendable — that we're skipping. The structural point: WalletFiller is generic over the wallet type W, parameterized by network N, and implements the same TxFiller<N> trait as nonce/gas/chain-id fillers from the Provider chain.)

The user-facing builder method .wallet(signer):

impl<P, N> ProviderBuilder<P, N>
where
    P: ProviderLayer<...>,
    N: Network,
{
    pub fn wallet<W: NetworkWallet<N>>(self, wallet: W) -> ProviderBuilder<...> {
        self.layer(WalletFiller::new(wallet))
    }
}

.wallet(signer) is a .layer(WalletFiller::new(signer)) in disguise. Same composition mechanism as nonce/gas/chain-id fillers. The signer integrates into the FillProvider chain by being wrapped in a WalletFiller and stacked alongside the others.

That's why the user never sees WalletFiller directly: ProviderBuilder.wallet(...) is the documented surface. Underneath, it's the same Filler machinery from the Provider chain.

sign_dynamic_typed_data — EIP-712 (briefly)

For completeness: alloy has a separate SignerSync::sign_dynamic_typed_data(typed_data: &TypedData) -> Result<Signature> for EIP-712 signing (typed data with domain separator, used heavily by dapps for off-chain signed orders). The shape is:

  1. Compute the EIP-712 hash (domain hash + struct hash, prefixed with 0x1901)
  2. Call sign_hash with the result

Same default-impl pattern as sign_message: lower-level sign_hash does the work; the higher-level method handles the EIP-712 prefixing and calls down. One trait, multiple input types, all routing through the same low-level signing op.

Recall before the quiz

Without scrolling:

  1. Signer's auto_impl list is (&mut, Box, Arc). Why no &, why no Rc?
  2. PrivateKeySigner caches the address at construction. Trace through: how would AwsSigner perform if address() were uncached?
  3. AwsSigner only returns (r, s) from KMS. What recovery-id work does AwsSigner::sign_hash do that PrivateKeySigner::sign_hash doesn't?
  4. The user calls ProviderBuilder.wallet(signer). Trace through the WalletFiller machinery: what's the layered structure of the resulting provider, and where does the signer's sign_transaction actually get invoked?

The next lesson is a quiz. Engage with these recalls now if any answer is shaky.

Summary (3 lines)

  • Real Signer trait: <Sig = Signature> default + 5 methods. PrivateKeySigner sync; AwsSigner async (KMS + recovery).
  • SignerSync wraps async via Tokio runtime-block. WalletFiller = TxFiller<N> plugs signer into FillProvider chain.
  • auto_impl(&mut, Box, Arc) 3 wrappers — &mut self methods mean no shared & impl. Next: quiz.