Lab 4 — Build a Wallet Backend in Rust
Question
A wallet backend has three jobs: track balances, sign transactions, monitor incoming activity. Build it as a long-running Rust service — Tokio actors + Alloy provider + a key-management module behind a trait.
Principle (minimum model)
- Three actors. Balance tracker (poll/subscribe → in-memory cache) + transaction signer (key access via trait) + event monitor (subscribe to incoming transfers / approvals / etc).
- Tokio actor pattern. Each actor is a struct with an
mpsc::Senderfor requests + atokio::spawned event loop. Communicate via message passing; no shared mutex. - Key management trait.
trait KeyStore { fn sign(&self, addr: Address, hash: B256) -> Signature; }. Implementations:LocalKeyStore(file),KmsKeyStore(AWS KMS),HsmKeyStore(hardware). Same trait; different blast radius. - Balance tracking. Subscribe to new blocks; for each block, fetch balances for tracked addresses. Batch via
eth_callmulticall to reduce round trips. - Event monitor. Subscribe to log filters (Transfer / Approval / etc) matching tracked addresses. Push notifications to downstream services.
- Test gate. Forked anvil + a mock keystore; assert signing produces a valid signature + balance updates after a state change.
Worked example + steps
Build a Wallet Backend in Rust
A user clicks "Send" fifty times in a minute. Your wallet has to: pick the next nonce (the per-account counter Ethereum uses to order transactions) without colliding, sign with the right key, broadcast, watch the mempool, and — when gas spikes from 5 gwei to 80 gwei mid-flight — bump the fee on stuck txs and replace them so the user's session doesn't deadlock behind a single dust-priced transaction. Wallet UIs are the famous part. The send service behind them is the part teams actually wrestle with. ~250 lines of Rust below — signer pool, nonce manager, send queue, replace-on-stuck, confirm watcher.
📌 Scope honesty. We build the service — signer pool + nonce manager + send queue + replace-on-stuck + confirm watcher — exposed over a tiny HTTP API. We don't cover key custody (HSM, MPC, KMS), fiat onramps, or the JS SDK. Those layers all sit on top of a send service that works; this lesson builds the part that has to work.
Acceptance criteria
The lesson is complete when these tests pass (full code at the end in §Test gate):
signed_tx_roundtrips— every signed transaction your service produces decodes back to the exactTransactionRequestit originated from (sender, to, value, nonce, gas params, data).no_nonce_gaps_under_concurrent_send— fire 50 concurrent/sendrequests for the samefrom; resulting nonces arebase..base+50with no gaps and no duplicates.
Test-first reading. The walkthrough below shows the signer pool, nonce manager, and TransactionRequest flow these tests exercise.
What you'll build
A backend service that exposes:
$ curl -X POST http://localhost:7000/send \
-H "Content-Type: application/json" \
-d '{
"from": "0xAlice...",
"to": "0xBob...",
"value": "0x16345785d8a0000",
"data": "0x"
}'
{ "tx_hash": "0xabc...", "queued_at": "2026-05-04T12:34:56Z" }
Behind that one POST: signer lookup by from-address, nonce reservation, gas-price computation, tx signing, broadcast via Alloy, and a background watcher that bumps the fee if the tx hasn't landed within 30 seconds.
flowchart TB
Client["HTTP client"] -->|POST /send| API["axum handler"]
API -->|reserve nonce| NM["NonceManager<br/>(per-address)"]
API -->|build & sign| Signer["PrivateKeySigner<br/>(loaded from env)"]
API -->|broadcast| Provider["Alloy Provider"]
API -->|track| Q["pending queue<br/>(tx_hash, deadline, fee)"]
Q -->|every 5s| Watcher["confirm watcher"]
Watcher -->|landed?| Provider
Watcher -->|stuck > 30s| Bump["bump fee 1.25x<br/>+ resubmit"]
Bump --> Q
Why this is hard
| Problem | Naive approach | What goes wrong |
|---|---|---|
| Nonce racing | provider.get_transaction_count(from).await per send | Two concurrent sends both read nonce N, both sign with N, only one lands. The other is rejected by the mempool. |
| Stuck txs | Hope the gas price was high enough | Mainnet gas spikes from 5 → 80 gwei in seconds. Your tx sits in mempool for hours. |
| Replace logic | "Just resubmit with the same nonce" | Most nodes reject replacements that don't bump fee by ≥10%. Naive resubmit silently fails. |
| Confirmation loss | Trust eth_sendRawTransaction's return | The tx hash is "I accepted it"; not "it landed". Network reorgs and dropped peers happen. |
A real wallet backend solves each. We'll do all four.
Cargo.toml
[package]
name = "wallet-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
# Alloy
alloy-primitives = "1.5"
alloy-provider = "1.0"
alloy-rpc-types = "1.0"
alloy-network = "1.0"
alloy-signer = "1.0"
alloy-signer-local = "1.0"
alloy-consensus = "2.0"
alloy-eips = "1.0"
# HTTP server
axum = "0.7"
# Plumbing
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
eyre = "0.6"
tracing = "0.1"
tracing-subscriber = "0.3"
Step 1: Signer pool + nonce manager
The core invariant: every address has exactly one source of truth for its next nonce, and that source is in-process state, not a fresh RPC call:
use alloy_primitives::Address;
use alloy_signer_local::PrivateKeySigner;
use std::{collections::HashMap, sync::Arc};
use tokio::sync::Mutex;
pub struct SignerPool {
inner: HashMap<Address, PrivateKeySigner>,
}
impl SignerPool {
pub fn from_env() -> eyre::Result<Self> {
// SIGNERS env var: comma-separated 0x-prefixed private keys
let mut inner = HashMap::new();
for hex in std::env::var("SIGNERS")?.split(',') {
let signer: PrivateKeySigner = hex.trim().parse()?;
inner.insert(signer.address(), signer);
}
Ok(Self { inner })
}
pub fn get(&self, addr: &Address) -> Option<&PrivateKeySigner> {
self.inner.get(addr)
}
}
#[derive(Clone)]
pub struct NonceManager {
state: Arc<Mutex<HashMap<Address, u64>>>,
}
impl NonceManager {
pub fn new() -> Self {
Self { state: Arc::new(Mutex::new(HashMap::new())) }
}
/// Reserve the next nonce for `addr`, initializing from RPC on first use.
pub async fn reserve<P: alloy_provider::Provider>(
&self,
addr: Address,
provider: &P,
) -> eyre::Result<u64> {
let mut state = self.state.lock().await;
let nonce = match state.get(&addr) {
Some(&n) => n,
None => provider.get_transaction_count(addr).pending().await?,
};
state.insert(addr, nonce + 1);
Ok(nonce)
}
/// Reset cached nonce for `addr` (call after a non-recoverable submission failure).
pub async fn forget(&self, addr: Address) {
self.state.lock().await.remove(&addr);
}
}
Walk:
Arc<Mutex<HashMap>>for the nonce state — yes, a single global mutex. Send throughput is bounded by signing speed, not by lock contention; the critical section here is microseconds. Don't pre-optimize with sharded locks until you've measured.provider.get_transaction_count(addr).pending()—pendingis the key word. The defaultget_transaction_countreturns confirmed-only count;pendingincludes txs sitting in the mempool. You want pending — confirmed-only would re-use a nonce that's already in flight.reserveisasyncbecause the first call hits RPC. Subsequent calls are pure local state. This means the slow path (cold start) is one RPC; the hot path (sustained sends) is zero.forgetis the safety valve. If a submission fails for "nonce too low" or similar, the in-memory state has drifted from the chain — drop it and let the next call re-initialize from RPC.
🔍 Find in repo. Open
alloy-signer-local. ThePrivateKeySigneryou parsed from a hex string is the same type you'd get from a keystore file (PrivateKeySigner::decrypt_keystore) or a mnemonic. The send service doesn't care which.
Step 2: Gas estimation (EIP-1559)
EIP-1559 gas: max_priority_fee_per_gas (your tip to the validator) + base_fee (burned, set by the protocol per block). Your max_fee_per_gas is the cap on the sum.
use alloy_eips::eip1559::Eip1559Estimation;
#[derive(Clone, Copy, Debug)]
pub struct GasParams {
pub max_fee_per_gas: u128,
pub max_priority_fee_per_gas: u128,
}
pub async fn estimate_gas<P: alloy_provider::Provider>(provider: &P) -> eyre::Result<GasParams> {
let est: Eip1559Estimation = provider.estimate_eip1559_fees().await?;
Ok(GasParams {
max_fee_per_gas: est.max_fee_per_gas,
max_priority_fee_per_gas: est.max_priority_fee_per_gas,
})
}
pub fn bump(params: GasParams) -> GasParams {
// 25% bump — comfortably above the 10% mempool minimum on most clients
GasParams {
max_fee_per_gas: params.max_fee_per_gas * 125 / 100,
max_priority_fee_per_gas: params.max_priority_fee_per_gas * 125 / 100,
}
}
Walk:
provider.estimate_eip1559_fees()— Alloy's helper that callseth_feeHistoryunder the hood and gives you a sane(max_fee, priority_fee)based on the last few blocks. Don't hand-roll fee math for the simple case; use the helper.bumpis 25%, not 10%. The mempool's minimum replacement bump is 10% on most clients (geth, Reth, Erigon). Submit at exactly 10% and you're betting the float won't trip a node's>vs>=check. 25% is the cheap safety margin that always works.- No retry on the estimate. If
estimate_eip1559_feesfails, your provider is having a bad time and any send right now is unsafe.
Step 3: Send + queue for confirmation
use alloy_consensus::{TxEip1559, SignableTransaction};
use alloy_network::{TxSignerSync, TransactionBuilder};
use alloy_primitives::{Bytes, U256};
use alloy_rpc_types::TransactionRequest;
use std::time::{Duration, Instant};
#[derive(Clone)]
pub struct PendingTx {
pub from: Address,
pub nonce: u64,
pub current_hash: alloy_primitives::B256,
pub gas_params: GasParams,
pub deadline: Instant,
pub original_request: TransactionRequest,
}
pub async fn send_one<P: alloy_provider::Provider>(
provider: &P,
pool: &SignerPool,
nm: &NonceManager,
req: TransactionRequest,
) -> eyre::Result<PendingTx> {
let from = req.from.ok_or_else(|| eyre::eyre!("from required"))?;
let signer = pool.get(&from).ok_or_else(|| eyre::eyre!("unknown signer: {from}"))?;
let nonce = nm.reserve(from, provider).await?;
let gas = estimate_gas(provider).await?;
let chain_id = provider.get_chain_id().await?;
let req = req
.with_nonce(nonce)
.with_chain_id(chain_id)
.with_gas_limit(req.gas.unwrap_or(100_000))
.with_max_fee_per_gas(gas.max_fee_per_gas)
.with_max_priority_fee_per_gas(gas.max_priority_fee_per_gas);
let tx = req.clone().build(&signer.clone().into()).await?;
let raw = tx.encoded_2718();
let pending = provider.send_raw_transaction(&raw).await?;
Ok(PendingTx {
from,
nonce,
current_hash: *pending.tx_hash(),
gas_params: gas,
deadline: Instant::now() + Duration::from_secs(30),
original_request: req,
})
}
Walk:
- Reserve nonce before signing, not after — ordering matters under concurrency. If you signed first and then reserved, two parallel sends could sign with the same nonce and only one would win.
build(&signer.clone().into())— Alloy'sTransactionBuildercallssign_transaction_syncunder the hood.PrivateKeySigneris sync; if you swap to a remote signer (KMS, HSM), this becomes async and the signature changes — but the rest of the function stays.send_raw_transactionreturns aPendingTransactionBuilder. We don't.await.confirmations(N)on it here; the watcher in Step 4 owns confirmation. The send path returns immediately so the API can respond fast.
Step 4: Confirm watcher with replace-on-stuck
The background loop. One tokio task that watches every queued tx; bumps the fee on anything older than its deadline:
use std::collections::HashMap;
use tokio::sync::RwLock;
use tokio::time::sleep;
#[derive(Clone)]
pub struct PendingQueue {
inner: Arc<RwLock<HashMap<alloy_primitives::B256, PendingTx>>>,
}
impl PendingQueue {
pub fn new() -> Self { Self { inner: Arc::new(RwLock::new(HashMap::new())) } }
pub async fn insert(&self, ptx: PendingTx) {
self.inner.write().await.insert(ptx.current_hash, ptx);
}
}
pub async fn watcher<P: alloy_provider::Provider + Clone>(
provider: P,
pool: SignerPool,
queue: PendingQueue,
) {
loop {
sleep(Duration::from_secs(5)).await;
// Snapshot to avoid holding the read lock during RPC.
let snapshot: Vec<PendingTx> = queue.inner.read().await.values().cloned().collect();
for mut ptx in snapshot {
// Check inclusion
if let Ok(Some(_receipt)) = provider.get_transaction_receipt(ptx.current_hash).await {
queue.inner.write().await.remove(&ptx.current_hash);
tracing::info!(hash = ?ptx.current_hash, "landed");
continue;
}
// Stuck? Bump and resubmit
if Instant::now() >= ptx.deadline {
let bumped = bump(ptx.gas_params);
let signer = pool.get(&ptx.from).expect("signer missing");
let req = ptx.original_request
.clone()
.with_max_fee_per_gas(bumped.max_fee_per_gas)
.with_max_priority_fee_per_gas(bumped.max_priority_fee_per_gas);
match req.build(&signer.clone().into()).await {
Ok(tx) => {
let raw = tx.encoded_2718();
if let Ok(p) = provider.send_raw_transaction(&raw).await {
let new_hash = *p.tx_hash();
let mut w = queue.inner.write().await;
w.remove(&ptx.current_hash);
ptx.current_hash = new_hash;
ptx.gas_params = bumped;
ptx.deadline = Instant::now() + Duration::from_secs(30);
w.insert(new_hash, ptx);
tracing::warn!("bumped + resubmitted");
}
}
Err(e) => tracing::error!(?e, "rebuild failed"),
}
}
}
}
}
Walk:
- Snapshot, then iterate. Holding the lock during
get_transaction_receiptwould serialize the whole queue behind a single RPC. Snapshotting trades a Vec allocation for parallelism. get_transaction_receiptreturningSomeis the inclusion signal. Note this is eventual consistency — a tx can be included in block N and the receipt visible at block N+1 due to RPC caching. The 5-second poll absorbs that lag.- Bump strategy is 25%, repeated. Each cycle that misses the deadline triples down. After 3 bumps a tx that started at 5 gwei is at
5 × 1.25³ ≈ 9.77gwei. Real production caps at a configurable max to avoid blowing budget on a network-wide spike. - The
expect("signer missing")— by construction, anything in the queue was signed by a key in the pool. Panicking here means our invariant is broken; better than silently dropping.
Step 5: HTTP API skeleton (axum)
use axum::{extract::State, routing::post, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct AppState<P: alloy_provider::Provider + Clone + 'static> {
pub provider: P,
pub signers: Arc<SignerPool>,
pub nonces: NonceManager,
pub queue: PendingQueue,
}
#[derive(Deserialize)]
pub struct SendRequest {
from: Address,
to: Address,
value: U256,
data: Option<Bytes>,
gas: Option<u64>,
}
#[derive(Serialize)]
pub struct SendResponse {
tx_hash: alloy_primitives::B256,
}
async fn handle_send<P: alloy_provider::Provider + Clone + 'static>(
State(state): State<AppState<P>>,
Json(req): Json<SendRequest>,
) -> Result<Json<SendResponse>, (axum::http::StatusCode, String)> {
let tx_req = TransactionRequest::default()
.with_from(req.from)
.with_to(req.to)
.with_value(req.value)
.with_input(req.data.unwrap_or_default())
.with_gas_limit(req.gas.unwrap_or(100_000));
let pending = send_one(&state.provider, &state.signers, &state.nonces, tx_req)
.await
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let hash = pending.current_hash;
state.queue.insert(pending).await;
Ok(Json(SendResponse { tx_hash: hash }))
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
tracing_subscriber::fmt::init();
let provider = alloy_provider::ProviderBuilder::new()
// Provider examples: QuickNode, Alchemy, Infura, or your own Reth node.
.connect(&std::env::var("RPC_URL")?)
.await?;
let state = AppState {
provider: provider.clone(),
signers: Arc::new(SignerPool::from_env()?),
nonces: NonceManager::new(),
queue: PendingQueue::new(),
};
// Spawn the watcher
{
let p = provider.clone();
let s = (*state.signers).clone(); // Note: SignerPool needs Clone
let q = state.queue.clone();
tokio::spawn(async move { watcher(p, s, q).await });
}
let app = Router::new()
.route("/send", post(handle_send))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:7000").await?;
axum::serve(listener, app).await?;
Ok(())
}
That's the whole service. ~250 lines including imports.
🔍 Find in repo. Open
alloy-rpc-types. FindTransactionRequest. Note that it's the same type that all thewith_*builder methods extend. Your wallet backend, an arb bot, a deployment script — they all build txs through this same type. That's the leverage Alloy gives you.
What's missing for production
| Gap | What real wallet backends do |
|---|---|
| Key custody | KMS / HSM / MPC (e.g., Fireblocks, ZenGo, Privy). PrivateKeySigner is fine for self-custody / hot ops; not for user funds. |
| Idempotency | The /send endpoint should accept a request_id and dedupe. Naive POST/retry can double-spend a user. |
| Per-key rate limiting | One bad actor with API access can drain a key's nonce range. Cap concurrent sends per from-address. |
| Persistent queue | A process restart drops everything in memory. Production persists PendingTx to Postgres / Redis and rehydrates on boot. |
| Multi-RPC fanout | Submit to 2–3 providers simultaneously so a single dead provider doesn't strand txs. The same signed bytes work everywhere. |
| Nonce gap detection | If you reserved nonces 5, 6, 7 and only 5 + 7 land, nonce 6 is missing — chain stalls until you fill it. Detect this and inject a no-op tx. |
| Observability | Per-from pending_count, oldest_pending_age, bumps_per_hour. Page when oldest_pending exceeds a threshold. |
The architecture you wrote — signer pool, nonce manager, send path, background watcher with replace-on-stuck — is the spine of every production wallet backend. Pick any commercial wallet's docs and you'll see this same shape under the marketing.
Drill
- Idempotency. Add a
request_id: Stringfield toSendRequest; cacherequest_id → tx_hashfor 1 hour. Returning the cached hash on duplicate POST. (30 min) - Per-key rate limit. Wrap
/sendin a per-from semaphore (max 4 concurrent). Reject with 429 if exceeded. (30 min) - Persistent queue. Write
PendingTxto Redis on insert, delete on land. Rehydrate on startup. (1.5 hours) - Multi-RPC fanout. Build a
MultiProviderthat wraps two providers and broadcastssend_raw_transactionto both, returning the firstOk. (1 hour) - Cancel endpoint. Add
POST /cancel { from, nonce }that submits a 0-value self-transfer at the same nonce with a 50% bump — a confirmed cancel of a stuck tx. (1 hour)
Finish drill 5 and you have a wallet backend that real users could rely on, modulo key custody. Wire in HSM-backed signing and you're at parity with what wallet teams ship.
Test gate
Per Test gate — every app in this tier ships with passing tests, this lesson's minimum gate is two invariants that must hold in any production wallet backend:
- Tx encoding roundtrip — every signed transaction your service produces must decode back to the exact
TransactionRequestit originated from (sender, to, value, nonce, gas params, data). A wallet that signs txs that "look right" but encode incorrectly silently ruins users when their txs revert at the EVM layer. - Nonce monotonicity under concurrency — fire N concurrent
/sendrequests for the samefrom; assert the resulting tx hashes have noncesbase,base+1, ...,base+N-1with no gaps and no duplicates. The whole reason for local nonce state (per the Final check above) is to prevent this; the test makes the prevention real.
// tests/wallet_invariants.rs
use alloy::consensus::TxEnvelope;
use alloy::eips::Decodable2718;
#[tokio::test]
async fn signed_tx_roundtrips() {
let svc = test_service().await;
let req = SendRequest { from: ALICE, to: BOB, value: U256::from(1), data: vec![] };
let signed_bytes = svc.sign_only(&req).await.unwrap();
let decoded = TxEnvelope::decode_2718(&mut signed_bytes.as_slice()).unwrap();
assert_eq!(decoded.recover_signer().unwrap(), ALICE);
assert_eq!(decoded.tx().to(), Some(BOB));
assert_eq!(decoded.tx().value(), U256::from(1));
}
#[tokio::test]
async fn no_nonce_gaps_under_concurrent_send() {
let svc = test_service().await;
let base = svc.next_nonce(ALICE).await;
let handles: Vec<_> = (0..50)
.map(|_| {
let svc = svc.clone();
tokio::spawn(async move { svc.send(stub_request(ALICE)).await.unwrap() })
})
.collect();
let mut nonces: Vec<u64> = futures::future::try_join_all(handles).await.unwrap()
.into_iter().map(|r| r.nonce).collect();
nonces.sort();
let expected: Vec<u64> = (base..base + 50).collect();
assert_eq!(nonces, expected, "nonces must be contiguous and unique");
}
The lesson is not complete until both pass. The wallet that fails (1) loses funds; the wallet that fails (2) deadlocks users behind a single stuck nonce.
📺 Further watching
wJnywGB33O4 | Georgios Konstantopoulos — Foundry, a portable, fast and modular toolkit (the same Alloy + Rust signer machinery, used inside Foundry's tx pipeline)
🧭 Where you are now in the stack: you've shipped the concurrency + state-management layer's wallet backend — signer pool, monotonic nonce manager, send queue, replace-on-stuck, watcher with reorg awareness. Same shape as Stripe payment intents and Kafka producer idempotence, applied to EVM transactions. Next lesson moves to the authentication layer: delegated authorization via EIP-7702.
Summary (3 lines)
- Wallet backend = three Tokio actors (balance tracker + signer + event monitor). Message passing; no shared mutex.
trait KeyStoreabstracts local / KMS / HSM behind one interface; same code, different blast radius.- Multicall for batched balance reads; event-log subscription for incoming activity. Test gate via forked anvil + mock keystore. Next: 7702 sponsor.