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).PrivateKeySignerimpl. HoldsSecretKey; 5 constructors (random,from_bytes,from_str,from_slice,random_with_rng); sync.AwsSignerimpl. Async — calls AWS KMS for signing. Needs special handling: DER decoding + v=0/v=1 recovery to figure out the right v.SignerSyncwrapper. 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— theSignerandSignerSynctraitscrates/signer-local/src/private_key.rs— thePrivateKeySignerimplcrates/signer-aws/src/signer.rs— theAwsSignerimplcrates/provider/src/fillers/wallet.rs— theWalletFillerThe 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.&selfwouldn't compile through the trait. (Providerdoesn't have any&mut selfmethods, hence its wider list.) - No
Rc—SignerrequiresSend + Sync, butRc<T>is!Sendand!SyncbecauseRc'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 bothimpl Signer for PrivateKeySignerandimpl SignerSync for PrivateKeySignerare present. Read the asyncsign_hashbody — is it justasync { 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:
- AWS returns DER-encoded signatures, not the (r, s, v) tuple alloy uses. The signer's responsibility is to decode and adapt.
- 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 cachedaddress. One AWS call → two address-derivation attempts → one matched signature. - No
SignerSyncimpl. 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 theimpl SignableTransaction<Signature> for TypedTransaction. Note thatsignature_hashdispatches by tx type (Legacy uses RLP-encoded tx, EIP-1559 uses keccak of0x02 || 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:
- Compute the EIP-712 hash (domain hash + struct hash, prefixed with
0x1901) - Call
sign_hashwith 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:
Signer'sauto_impllist is(&mut, Box, Arc). Why no&, why noRc?PrivateKeySignercaches theaddressat construction. Trace through: how wouldAwsSignerperform ifaddress()were uncached?AwsSigneronly returns(r, s)from KMS. What recovery-id work doesAwsSigner::sign_hashdo thatPrivateKeySigner::sign_hashdoesn't?- The user calls
ProviderBuilder.wallet(signer). Trace through theWalletFillermachinery: what's the layered structure of the resulting provider, and where does the signer'ssign_transactionactually 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 selfmethods mean no shared&impl. Next: quiz.