Real-Time Ethereum Block Monitor
Build an async Rust app that streams live Ethereum block data via WebSocket.
Introduction
30 Second Summary
Every 12 seconds, a new block is added to the Ethereum blockchain carrying thousands of transactions worth millions of dollars. Professional crypto infrastructure teams monitor this activity in real-time to detect patterns and respond to network conditions.
In this project, you will build a command-line Ethereum block monitor in Rust that connects to Ethereum mainnet over WebSocket and streams live blockchain activity directly to your terminal.
What You'll Build
A real-time block monitor that fetches live Ethereum blocks, classifies every transaction by type, and prints formatted summaries with color coding.
By the end of this project, you'll have:
- A working Rust binary that connects to Ethereum mainnet via WebSocket and streams live block data in real-time.
- Hands-on experience with Rust fundamentals (ownership, structs, enums, pattern matching, error handling) in the context of a real systems-level application.
- A terminal-based monitor that classifies and displays live Ethereum transactions as ETH transfers, contract calls, or contract deployments.
- Secret Mission: Add whale alert detection that flags transactions over a configurable ETH threshold.
Are there any prerequisites?
You should have basic programming knowledge in any language and an understanding of what Ethereum blocks and transactions are. No prior Rust experience is required.
Install Rust and Set Up the Project
Every Rust project is managed by Cargo, Rust's built-in package manager and build tool. Cargo handles downloading libraries, compiling code, and structuring your project files.
In this step, you'll create a fresh Cargo project and configure the five libraries your Ethereum monitor needs to connect to the blockchain and display live data.
In this step, get ready to:
- Confirm your Rust installation is ready.
- Create a new Cargo project and configure its dependencies.
- Compile all dependencies to verify everything builds cleanly.
Confirm your Rust installation
Your Rust toolchain is already installed via rustup. The Alloy library (which your project uses to talk to Ethereum) requires Rust 1.91 or higher.
- Confirm your Rust version by running this command:
rustc --version
- You should see a version number of 1.91 or higher (e.g. rustc 1.96.0).
- Also confirm Cargo is available by running:
cargo --version
- You should see a matching Cargo version number.
Seeing command not found?
If your terminal does not recognize rustc or cargo, your shell configuration may not include Cargo's bin directory. Try running:
source $HOME/.cargo/env
Then retry the version check. If it still fails, reinstall Rust by visiting rust-lang.org/tools/install.
help me fix my Rust installation
Create the project and configure dependencies
Cargo's cargo new command scaffolds a complete Rust project with the correct file structure. You'll get a Cargo.toml manifest (where dependencies live) and a src/main.rs file with a starter program.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Rust project by running:
cargo new eth-monitor
- Move into the new project directory by running:
cd eth-monitor
- Confirm the project was created by running:
ls
- You should see Cargo.toml and a src folder in the output.
Now you need to tell Cargo which libraries your project depends on. Cargo.toml is Rust's equivalent of package.json or requirements.txt.
- In VS Code, open the eth-monitor folder by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the eth-monitor folder, and click Open.
- In the Explorer sidebar on the left, click Cargo.toml to open it.
- Replace the entire contents of Cargo.toml with the following dependency configuration:
[package]
name = "eth-monitor"
version = "0.1.0"
edition = "2021"
[dependencies]
alloy = { version = "2.0", features = ["full"] }
tokio = { version = "1.52", features = ["full"] }
eyre = "0.6"
futures-util = "0.3"
colored = "3"
What are these dependencies?
- alloy is the Ethereum library for Rust. It provides WebSocket connections, block subscriptions, and transaction data types. The "full" feature flag enables all sub-crates so you don't hit confusing missing-feature errors.
- tokio is Rust's most popular async runtime. It lets your program wait for network data without blocking.
- eyre provides clean error handling with the ? operator, so you can propagate errors without boilerplate.
- futures-util adds stream processing traits (like .next().await) for iterating over incoming blocks.
- colored adds color to your terminal output, making it easy to distinguish transaction types at a glance.
- Save Cargo.toml by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Build the project and confirm your Alchemy URL
Running cargo build downloads every dependency and compiles them from source. This first build takes 1-2 minutes because Rust compiles everything ahead of time for maximum runtime performance.
- Build your project by running this command in your terminal:
cargo build
- Wait for compilation to finish. You'll see many Compiling lines scroll by as each dependency is built.
- When it finishes, you should see a Finished message indicating a successful build.
Why does the first build take so long?
Rust compiles every dependency from source code into optimized machine code. This is different from languages like Python or JavaScript where libraries are interpreted at runtime. The tradeoff is a slower first compile but much faster execution.
Future builds will be much faster because Cargo caches compiled dependencies. Only code you change gets recompiled.
Build failed with errors?
If you see a version resolution error, double-check that your Cargo.toml matches the code above exactly. A missing quote or bracket will cause a parse failure.
If you see a network error, check your internet connection. Cargo needs to download crates from crates.io on the first build.
help me fix my cargo build error
Finally, confirm you have your Alchemy WebSocket URL ready. This is the URL from your Alchemy dashboard that looks like:
wss://eth-mainnet.g.alchemy.com/v2/your-api-key
You'll paste this URL into your Rust code in the next step to connect to Ethereum mainnet.
What is a WebSocket URL?
A WebSocket URL (starting with wss://) opens a persistent two-way connection between your program and Alchemy's Ethereum node. Unlike regular HTTP requests where you ask and wait for a response each time, a WebSocket stays open so the node can push new block data to you the instant it arrives.
✔️ Awesome, I've got everything!
Great. Make sure you've saved Cargo.toml and that cargo build finished without errors.
ⓧ I'd like to double check the full code
Here's what your Cargo.toml should look like:
[package]
name = "eth-monitor"
version = "0.1.0"
edition = "2021"
[dependencies]
alloy = { version = "2.0", features = ["full"] }
tokio = { version = "1.52", features = ["full"] }
eyre = "0.6"
futures-util = "0.3"
colored = "3"
And your src/main.rs should still be the default generated code:
fn main() {
println!("Hello, world!");
}
Your project is compiled and ready. Next up, you'll write the code that connects to Ethereum mainnet and starts streaming live block data over your WebSocket connection.
Connect to Ethereum and Subscribe to New Blocks
Your Rust project is compiled and ready with all the libraries you need. Now it's time to connect your program to a live Ethereum node and start receiving real-time data.
In this step, you'll write an async Rust program that opens a persistent WebSocket connection to your Alchemy node and subscribes to new block headers as they're mined on Ethereum mainnet.
In this step, get ready to:
- Write an async main function that connects to Ethereum via WebSocket.
- Subscribe to new block headers and stream them in real-time.
- Run the monitor and see live Ethereum blocks printing to your terminal.
Write the async main function with WebSocket provider
Rust doesn't include a built-in async runtime. Tokio provides the async executor that lets you use async/await syntax. Alloy provides the WebSocket client that speaks JSON-RPC to your Alchemy Ethereum node.
- In VS Code, open src/main.rs from the eth-monitor project.
- Select all the existing code and replace it with the following:
use alloy::providers::{Provider, ProviderBuilder, WsConnect};
use colored::Colorize;
use eyre::Result;
use futures_util::StreamExt;
#[tokio::main]
async fn main() -> Result<()> {
// Print a welcome banner
println!("{}", "Ethereum Block Monitor".bold().green());
println!("Connecting to Ethereum mainnet...\n");
// Connect to Ethereum via WebSocket
let rpc_url = "wss://eth-mainnet.g.alchemy.com/v2/your-api-key-here";
let ws = WsConnect::new(rpc_url);
let provider = ProviderBuilder::new().connect_ws(ws).await?;
println!("{}", "Connected! Subscribing to new blocks...".green());
// Subscribe to new block headers
let sub = provider.subscribe_blocks().await?;
Ok(())
}
What does this code do?
The four use statements import the traits and types you need: Provider for blockchain methods, WsConnect for WebSocket connections, Colorize for terminal colors, Result for error handling, and StreamExt for async iteration.
The #[tokio::main] attribute transforms your main function into an async entry point powered by Tokio's runtime. WsConnect::new creates a WebSocket configuration, and ProviderBuilder opens the live connection to Alchemy.
The ? operator after each await propagates errors cleanly. If anything fails, the program exits with a descriptive error message instead of panicking.
- Replace your-api-key-here in the rpc_url string with your actual Alchemy API key from Step 1.
- Save src/main.rs.
Not sure where your Alchemy API key is?
Go back to your Alchemy dashboard, click on your app, and click View Key. Copy the WebSocket URL that starts with wss://. Paste the entire URL (including the key portion) as your rpc_url value.
help me find my Alchemy WebSocket URL
Stream block headers and print block metadata
The subscribe_blocks() call gives you a subscription handle, but you haven't started reading from it yet. You need to convert it into an async stream, then loop over each incoming block header.
- In src/main.rs, find the line that says Ok(()) near the bottom of your main function.
- Add the following code directly above Ok(()):
// Convert subscription to an async stream
let mut stream = sub.into_stream();
// Loop over each new block header as it arrives
while let Some(header) = stream.next().await {
let block_number = header.number;
println!(
"\n{} Block #{} | Gas used: {} | Timestamp: {}",
"NEW BLOCK".bold().yellow(),
block_number,
header.gas_used,
header.timestamp
);
}
What does this code do?
The .into_stream() method converts the subscription into something you can iterate over asynchronously. Think of it like turning a live feed into a queue you can pull from one item at a time.
The while let Some(header) = stream.next().await pattern waits for the next block header to arrive from the network. Each time Ethereum mines a new block (approximately every 12 seconds), your loop runs once and prints the block's number, gas used, and timestamp.
- Save src/main.rs.
Seeing a red underline or compiler error?
Make sure the while let block is placed above Ok(()) but still inside the curly braces of main. The indentation should match the let sub line above it.
help me fix a compiler error in my Rust block monitor code
✔️ Awesome, I've got everything!
Great. Double check you've saved src/main.rs with your Alchemy API key filled in.
ⓧ I'd like to double check the full code
use alloy::providers::{Provider, ProviderBuilder, WsConnect};
use colored::Colorize;
use eyre::Result;
use futures_util::StreamExt;
#[tokio::main]
async fn main() -> Result<()> {
println!("{}", "Ethereum Block Monitor".bold().green());
println!("Connecting to Ethereum mainnet...\n");
let rpc_url = "wss://eth-mainnet.g.alchemy.com/v2/your-api-key-here";
let ws = WsConnect::new(rpc_url);
let provider = ProviderBuilder::new().connect_ws(ws).await?;
println!("{}", "Connected! Subscribing to new blocks...".green());
let sub = provider.subscribe_blocks().await?;
let mut stream = sub.into_stream();
while let Some(header) = stream.next().await {
let block_number = header.number;
println!(
"\n{} Block #{} | Gas used: {} | Timestamp: {}",
"NEW BLOCK".bold().yellow(),
block_number,
header.gas_used,
header.timestamp
);
}
Ok(())
}
Run the monitor and observe live blocks
Your code is complete for this step. Time to run it and watch live Ethereum blocks stream into your terminal.
- Run your monitor by executing this command:
cargo run
You should see the green "Connected!" message followed by new block headers appearing approximately every 12 seconds. Each line shows the block number, gas used, and timestamp.
What should I see?
Your terminal will print something like this every 12 seconds:
Ethereum Block Monitor
Connecting to Ethereum mainnet...
Connected! Subscribing to new blocks...
NEW BLOCK Block #22451032 | Gas used: 15234567 | Timestamp: 1750012345
The exact numbers will be different since you're watching live data. Press Ctrl+C to stop the program when you're done watching.
Seeing a connection error?
Check that you replaced your-api-key-here with your actual Alchemy API key. The URL must start with wss:// (not https://).
Also make sure your Alchemy app is configured for Ethereum Mainnet, not a testnet.
help me debug my WebSocket connection to Alchemy
Your monitor is alive and streaming real-time data from Ethereum mainnet. Next up, you'll fetch full block data and classify each transaction by type.
Fetch Full Blocks and Classify Transactions
Your monitor is streaming block headers from Ethereum mainnet. Headers are lightweight though. They tell you a block exists but not what happened inside it.
In this step, you will fetch full block data (including every transaction), classify each transaction by type, and print a color-coded summary. This is where your monitor becomes genuinely useful for understanding on-chain activity.
In this step, get ready to:
- Fetch complete block data with all transaction objects.
- Classify transactions into ETH transfers, contract calls, and contract deployments.
- Print a formatted per-block summary with colored output.
Fetch full block data with transactions
Block headers from subscribe_blocks() are intentionally small. They contain metadata like block number and gas used, but not the actual transactions. To see what happened in a block, you need to make a separate RPC call to fetch the full block.
- In src/main.rs, find the while let Some(header) loop.
- Add the following code immediately after the existing println! that prints the block header:
let block = provider
.get_block_by_number(block_number.into(), true)
.await?;
let Some(block) = block else {
println!(" Block data not available yet, skipping...");
continue;
};
let transactions = block.transactions.as_transactions().unwrap_or(&[]);
let tx_count = transactions.len();
What does this code do?
The get_block_by_number method calls the eth_getBlockByNumber JSON-RPC endpoint. Passing true as the second argument tells the node to include full transaction objects instead of just transaction hashes.
The method returns an Option because a block might not be available yet (very rare on mainnet). The let Some(block) = block else { continue; } pattern skips gracefully if that happens.
You won't see a change yet. The next substep builds the classification logic that processes these transactions.
Classify transactions into types
Every Ethereum transaction falls into one of three categories based on its to and input fields. If to is empty, the transaction is deploying a new contract. If input is empty but to exists, it is a simple ETH transfer. Otherwise, it is calling a function on an existing contract.
- In src/main.rs, add the following import at the top of the file, below the existing use futures_util::StreamExt; line:
use std::fmt;
- Below the imports and above the #[tokio::main] line, add the TxType enum, its Display implementation, and the classification function:
#[derive(Debug, Clone)]
enum TxType {
EthTransfer,
ContractCall,
ContractDeployment,
}
impl fmt::Display for TxType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TxType::EthTransfer => write!(f, "ETH Transfer"),
TxType::ContractCall => write!(f, "Contract Call"),
TxType::ContractDeployment => write!(f, "Contract Deployment"),
}
}
}
fn classify_transaction(to: Option<&alloy::primitives::Address>, input: &[u8]) -> TxType {
if to.is_none() {
TxType::ContractDeployment
} else if input.is_empty() || input == [0] {
TxType::EthTransfer
} else {
TxType::ContractCall
}
}
What does this code do?
- The TxType enum defines three variants representing the three transaction categories on Ethereum.
- The Display implementation lets you print each variant as a human-readable string.
- The classify_transaction function uses Rust's pattern matching to check the to and input fields. A missing to address means the transaction creates a new contract on-chain.
Why enums over strings?
Rust enums are checked at compile time. If you later add a match statement and forget a variant, the compiler will refuse to build your code. Strings offer no such protection.
You won't see a change yet. The next substep wires these pieces together into the loop.
Print a formatted summary for each block
Now you will connect the classification logic to the fetched transactions and print a color-coded summary for each block.
- In src/main.rs, add the wei_to_eth helper function below classify_transaction and above #[tokio::main]:
fn wei_to_eth(wei: alloy::primitives::U256) -> f64 {
let wei_str = wei.to_string();
let wei_val: f64 = wei_str.parse().unwrap_or(0.0);
wei_val / 1_000_000_000_000_000_000.0
}
- Inside the while let Some(header) loop, add the following code after the let tx_count = transactions.len(); line you added earlier:
let mut eth_transfers = 0u64;
let mut contract_calls = 0u64;
let mut contract_deployments = 0u64;
let mut block_eth_volume = 0.0f64;
for tx in transactions {
let tx_type = classify_transaction(tx.to.as_ref(), tx.input.as_ref());
let eth_value = wei_to_eth(tx.value);
block_eth_volume += eth_value;
match &tx_type {
TxType::EthTransfer => eth_transfers += 1,
TxType::ContractCall => contract_calls += 1,
TxType::ContractDeployment => contract_deployments += 1,
}
}
println!(
" Transactions: {} | {} transfers, {} calls, {} deployments",
tx_count.to_string().bold(),
eth_transfers.to_string().green(),
contract_calls.to_string().blue(),
contract_deployments.to_string().magenta()
);
println!(" Block ETH volume: {:.4} ETH", block_eth_volume);
What does this code do?
- The wei_to_eth function converts Ethereum's smallest unit (wei) to ETH by dividing by 10^18. It converts the U256 value to a string first because U256 is a 256-bit integer that does not fit in a standard f64 directly.
- The for loop iterates every transaction in the block, classifies it, and adds to the running counters.
- The colored crate methods (.green(), .blue(), .magenta()) apply terminal color codes so each transaction type stands out visually.
- Save src/main.rs.
- Run the monitor by executing:
cargo run
You should see new blocks appearing every ~12 seconds, now with a transaction breakdown showing the count of transfers, contract calls, and deployments in each block.
Not seeing transaction counts?
- Make sure your get_block_by_number call passes true as the second argument. Without it, the node returns transaction hashes instead of full objects.
- Check that the new code is inside the while let Some(header) loop, not after the closing brace.
- If you see a compile error about classify_transaction, confirm the function is defined above #[tokio::main], not inside main.
help me debug my Ethereum block monitor
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs and your terminal shows color-coded transaction breakdowns.
ⓧ I'd like to double check the full code
use alloy::providers::{Provider, ProviderBuilder, WsConnect};
use colored::Colorize;
use eyre::Result;
use futures_util::StreamExt;
use std::fmt;
#[derive(Debug, Clone)]
enum TxType {
EthTransfer,
ContractCall,
ContractDeployment,
}
impl fmt::Display for TxType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TxType::EthTransfer => write!(f, "ETH Transfer"),
TxType::ContractCall => write!(f, "Contract Call"),
TxType::ContractDeployment => write!(f, "Contract Deployment"),
}
}
}
fn classify_transaction(to: Option<&alloy::primitives::Address>, input: &[u8]) -> TxType {
if to.is_none() {
TxType::ContractDeployment
} else if input.is_empty() || input == [0] {
TxType::EthTransfer
} else {
TxType::ContractCall
}
}
fn wei_to_eth(wei: alloy::primitives::U256) -> f64 {
let wei_str = wei.to_string();
let wei_val: f64 = wei_str.parse().unwrap_or(0.0);
wei_val / 1_000_000_000_000_000_000.0
}
#[tokio::main]
async fn main() -> Result<()> {
println!("{}", "Ethereum Block Monitor".bold().green());
println!("Connecting to Ethereum mainnet...\n");
let rpc_url = "wss://eth-mainnet.g.alchemy.com/v2/your-api-key-here";
let ws = WsConnect::new(rpc_url);
let provider = ProviderBuilder::new().connect_ws(ws).await?;
println!("{}", "Connected! Subscribing to new blocks...".green());
let sub = provider.subscribe_blocks().await?;
let mut stream = sub.into_stream();
while let Some(header) = stream.next().await {
let block_number = header.number;
println!(
"\n{} Block #{} | Gas used: {} | Timestamp: {}",
"NEW BLOCK".bold().yellow(),
block_number,
header.gas_used,
header.timestamp
);
let block = provider
.get_block_by_number(block_number.into(), true)
.await?;
let Some(block) = block else {
println!(" Block data not available yet, skipping...");
continue;
};
let transactions = block.transactions.as_transactions().unwrap_or(&[]);
let tx_count = transactions.len();
let mut eth_transfers = 0u64;
let mut contract_calls = 0u64;
let mut contract_deployments = 0u64;
let mut block_eth_volume = 0.0f64;
for tx in transactions {
let tx_type = classify_transaction(tx.to.as_ref(), tx.input.as_ref());
let eth_value = wei_to_eth(tx.value);
block_eth_volume += eth_value;
match &tx_type {
TxType::EthTransfer => eth_transfers += 1,
TxType::ContractCall => contract_calls += 1,
TxType::ContractDeployment => contract_deployments += 1,
}
}
println!(
" Transactions: {} | {} transfers, {} calls, {} deployments",
tx_count.to_string().bold(),
eth_transfers.to_string().green(),
contract_calls.to_string().blue(),
contract_deployments.to_string().magenta()
);
println!(" Block ETH volume: {:.4} ETH", block_eth_volume);
}
Ok(())
}
Your monitor now reveals what is actually happening on Ethereum. Next up, you will add transaction detail display and robust error handling to make the monitor production-ready.
Add Transaction Detail Display and Error Handling
Your monitor classifies transactions, but right now you only see aggregate counts per block. You have no visibility into which specific transactions moved the most ETH or who sent them.
In this step, you will surface the top transactions by value, format addresses for readability, and build a statistics tracker that accumulates data across blocks. This transforms your monitor from a per-block snapshot into a running dashboard.
In this step, get ready to:
- Display the top 3 transactions per block with truncated addresses and ETH values.
- Understand the error handling patterns already in your code.
- Add a running statistics tracker that prints cumulative data every 5 blocks.
Display notable transactions
Ethereum addresses are 42 characters long. Printing them in full clutters your terminal and makes it hard to scan output quickly. A truncation helper will format them as 0x1234...abcd so the output stays readable.
- In src/main.rs, find the wei_to_eth function. Add this new helper function directly below it:
fn truncate_address(addr: &alloy::primitives::Address) -> String {
let s = format!("{addr}");
if s.len() > 10 {
format!("{}...{}", &s[..6], &s[s.len() - 4..])
} else {
s
}
}
What does this code do?
This function takes an Ethereum address and returns a shortened string. It keeps the first 6 characters (including the 0x prefix) and the last 4 characters, joining them with ... in between.
The guard clause (if s.len() > 10) prevents slicing errors on unexpectedly short strings. Rust's string slicing panics if indices are out of bounds, so this check keeps the function safe.
Now you need to use this helper inside the transaction loop to collect address data for the top transactions display.
- Inside the while let Some(header) loop, find this line:
let mut block_eth_volume = 0.0f64;
- Add this new line directly below it:
let mut top_txs: Vec<(f64, String, String, TxType)> = Vec::new();
What is this Vec for?
This vector will store a tuple for each transaction: the ETH value, the truncated sender address, the truncated receiver address, and the transaction type. After processing all transactions in a block, you will sort this vector by ETH value to find the biggest transfers.
- Inside the for tx in transactions loop, find the closing brace of the match block. Add this code after it (still inside the for loop):
let from_str = truncate_address(&tx.from);
let to_str = tx
.to
.as_ref()
.map(|a| truncate_address(a))
.unwrap_or_else(|| "Contract Creation".to_string());
top_txs.push((eth_value, from_str, to_str, tx_type));
What does this code do?
For the sender, you call truncate_address(&tx.from) directly because every transaction has a sender. For the receiver, you use .map() and .unwrap_or_else() because contract deployments have no to address.
Now add the sorting and display logic that shows the top 3 transactions after each block is processed.
- Find the line that prints Block ETH volume. Add this code directly below it:
top_txs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
if !top_txs.is_empty() {
println!(" {} (by ETH value):", "Top transactions".underline());
for (i, (eth_val, from, to, tx_type)) in top_txs.iter().take(3).enumerate() {
if *eth_val > 0.0 {
println!(
" {}. {:.4} ETH | {} -> {} | {}",
i + 1,
eth_val,
from,
to,
tx_type
);
}
}
}
What does this code do?
- sort_by sorts the vector in descending order by the first tuple element (ETH value). The b.0.partial_cmp(&a.0) reverses the comparison so the largest values come first.
- .take(3) limits the output to the top 3 transactions. The if *eth_val > 0.0 guard skips zero-value transactions (common in contract calls).
- .underline() from the colored crate adds underline formatting to the section header so it stands out visually.
- Save src/main.rs.
Seeing a type mismatch or borrow error?
Make sure top_txs.push uses tx_type (not &tx_type). The push takes ownership of the value. Also confirm the top_txs declaration is above the for loop, not inside it.
Help me fix a borrow or type error in my transaction display code.
Add robust error handling
Your code already uses several Rust error handling patterns that production applications rely on. Before adding more features, take a moment to understand why these patterns matter.
How does error handling work in your code?
Rust does not have exceptions. Instead, functions that can fail return a Result type. The ? operator at the end of fallible calls (like .await?) propagates errors up to the caller automatically.
The eyre crate provides the Result type alias your main() function uses. If any WebSocket call fails or the connection drops, the error bubbles up and the program exits with a descriptive message instead of crashing silently.
The let Some(block) = block else { continue; } pattern handles the case where get_block_by_number returns None. This can happen when a block is announced but not yet fully available from the node. Your monitor skips it and continues to the next block.
Why eyre instead of anyhow?
Both crates simplify error handling, but eyre is the idiomatic choice for application code. It produces clean, human-readable error reports. For libraries where callers need to inspect errors programmatically, you would use thiserror instead.
Add a running statistics tracker
Individual block summaries are useful, but you also want cumulative data. A statistics struct will track totals across all blocks your monitor observes and print a summary every 5 blocks.
- In src/main.rs, find the closing brace of the impl fmt::Display for TxType block. Add this struct and implementation below it (before classify_transaction):
struct MonitorStats {
blocks_seen: u64,
total_transactions: u64,
eth_transfers: u64,
contract_calls: u64,
contract_deployments: u64,
total_eth_volume: f64,
}
impl MonitorStats {
fn new() -> Self {
Self {
blocks_seen: 0,
total_transactions: 0,
eth_transfers: 0,
contract_calls: 0,
contract_deployments: 0,
total_eth_volume: 0.0,
}
}
fn print_summary(&self) {
println!("\n{}", "=== Monitor Statistics ===".bold().cyan());
println!(" Blocks seen: {}", self.blocks_seen);
println!(" Total transactions: {}", self.total_transactions);
println!(" ETH transfers: {}", self.eth_transfers);
println!(" Contract calls: {}", self.contract_calls);
println!(" Contract deployments: {}", self.contract_deployments);
println!(" Total ETH volume: {:.4} ETH", self.total_eth_volume);
println!("{}", "==========================".bold().cyan());
}
}
What does this code do?
- MonitorStats is a struct that groups related counters together. Rust structs are similar to classes in other languages, but they hold only data. Behavior lives in impl blocks.
- new() is a constructor (by convention). It returns Self with all fields initialized to zero.
- print_summary() takes &self (an immutable reference), meaning it can read the fields but not change them. This is Rust's ownership system enforcing read-only access at compile time.
Now wire the struct into your main function so it accumulates data as blocks arrive.
- In your main() function, find this line:
let mut stream = sub.into_stream();
- Add this line directly below it to create a mutable stats instance:
let mut stats = MonitorStats::new();
- Now find the end of the top transactions display (the closing brace of if !top_txs.is_empty()). Add this stats update code below it, still inside the while let loop:
stats.blocks_seen += 1;
stats.total_transactions += tx_count as u64;
stats.eth_transfers += eth_transfers;
stats.contract_calls += contract_calls;
stats.contract_deployments += contract_deployments;
stats.total_eth_volume += block_eth_volume;
if stats.blocks_seen % 5 == 0 {
stats.print_summary();
}
What does this code do?
Each time a block is processed, the code adds that block's data to the running totals. The as u64 cast converts the usize transaction count to match the struct's field type.
The modulo check (% 5 == 0) prints a summary every 5 blocks. Since Ethereum produces a block roughly every 12 seconds, you will see a summary about once a minute.
- Save src/main.rs.
- Run your monitor by running:
cargo run
- Watch the output for 1-2 minutes. You should see top transactions listed per block and a cyan statistics summary after every 5th block.
Not seeing statistics after 5 blocks?
Confirm the stats update code is inside the while let loop, not after it. Also check that let mut stats = MonitorStats::new(); is before the while let loop. If it is inside the loop, it resets to zero every block.
Help me debug why my statistics summary is not printing.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs at this point:
use alloy::providers::{Provider, ProviderBuilder, WsConnect};
use colored::Colorize;
use eyre::Result;
use futures_util::StreamExt;
use std::fmt;
#[derive(Debug, Clone)]
enum TxType {
EthTransfer,
ContractCall,
ContractDeployment,
}
impl fmt::Display for TxType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TxType::EthTransfer => write!(f, "ETH Transfer"),
TxType::ContractCall => write!(f, "Contract Call"),
TxType::ContractDeployment => write!(f, "Contract Deployment"),
}
}
}
struct MonitorStats {
blocks_seen: u64,
total_transactions: u64,
eth_transfers: u64,
contract_calls: u64,
contract_deployments: u64,
total_eth_volume: f64,
}
impl MonitorStats {
fn new() -> Self {
Self {
blocks_seen: 0,
total_transactions: 0,
eth_transfers: 0,
contract_calls: 0,
contract_deployments: 0,
total_eth_volume: 0.0,
}
}
fn print_summary(&self) {
println!("\n{}", "=== Monitor Statistics ===".bold().cyan());
println!(" Blocks seen: {}", self.blocks_seen);
println!(" Total transactions: {}", self.total_transactions);
println!(" ETH transfers: {}", self.eth_transfers);
println!(" Contract calls: {}", self.contract_calls);
println!(" Contract deployments: {}", self.contract_deployments);
println!(" Total ETH volume: {:.4} ETH", self.total_eth_volume);
println!("{}", "==========================".bold().cyan());
}
}
fn classify_transaction(to: Option<&alloy::primitives::Address>, input: &[u8]) -> TxType {
if to.is_none() {
TxType::ContractDeployment
} else if input.is_empty() || input == [0] {
TxType::EthTransfer
} else {
TxType::ContractCall
}
}
fn wei_to_eth(wei: alloy::primitives::U256) -> f64 {
let wei_str = wei.to_string();
let wei_val: f64 = wei_str.parse().unwrap_or(0.0);
wei_val / 1_000_000_000_000_000_000.0
}
fn truncate_address(addr: &alloy::primitives::Address) -> String {
let s = format!("{addr}");
if s.len() > 10 {
format!("{}...{}", &s[..6], &s[s.len() - 4..])
} else {
s
}
}
#[tokio::main]
async fn main() -> Result<()> {
println!("{}", "Ethereum Block Monitor".bold().green());
println!("Connecting to Ethereum mainnet...\n");
let rpc_url = "wss://eth-mainnet.g.alchemy.com/v2/your-api-key-here";
let ws = WsConnect::new(rpc_url);
let provider = ProviderBuilder::new().connect_ws(ws).await?;
println!("{}", "Connected! Subscribing to new blocks...".green());
let sub = provider.subscribe_blocks().await?;
let mut stream = sub.into_stream();
let mut stats = MonitorStats::new();
while let Some(header) = stream.next().await {
let block_number = header.number;
println!(
"\n{} Block #{} | Gas used: {} | Timestamp: {}",
"NEW BLOCK".bold().yellow(),
block_number,
header.gas_used,
header.timestamp
);
let block = provider
.get_block_by_number(block_number.into(), true)
.await?;
let Some(block) = block else {
println!(" Block data not available yet, skipping...");
continue;
};
let transactions = block.transactions.as_transactions().unwrap_or(&[]);
let tx_count = transactions.len();
let mut eth_transfers = 0u64;
let mut contract_calls = 0u64;
let mut contract_deployments = 0u64;
let mut block_eth_volume = 0.0f64;
let mut top_txs: Vec<(f64, String, String, TxType)> = Vec::new();
for tx in transactions {
let tx_type = classify_transaction(tx.to.as_ref(), tx.input.as_ref());
let eth_value = wei_to_eth(tx.value);
block_eth_volume += eth_value;
match &tx_type {
TxType::EthTransfer => eth_transfers += 1,
TxType::ContractCall => contract_calls += 1,
TxType::ContractDeployment => contract_deployments += 1,
}
let from_str = truncate_address(&tx.from);
let to_str = tx
.to
.as_ref()
.map(|a| truncate_address(a))
.unwrap_or_else(|| "Contract Creation".to_string());
top_txs.push((eth_value, from_str, to_str, tx_type));
}
println!(
" Transactions: {} | {} transfers, {} calls, {} deployments",
tx_count.to_string().bold(),
eth_transfers.to_string().green(),
contract_calls.to_string().blue(),
contract_deployments.to_string().magenta()
);
println!(" Block ETH volume: {:.4} ETH", block_eth_volume);
top_txs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
if !top_txs.is_empty() {
println!(" {} (by ETH value):", "Top transactions".underline());
for (i, (eth_val, from, to, tx_type)) in top_txs.iter().take(3).enumerate() {
if *eth_val > 0.0 {
println!(
" {}. {:.4} ETH | {} -> {} | {}",
i + 1,
eth_val,
from,
to,
tx_type
);
}
}
}
stats.blocks_seen += 1;
stats.total_transactions += tx_count as u64;
stats.eth_transfers += eth_transfers;
stats.contract_calls += contract_calls;
stats.contract_deployments += contract_deployments;
stats.total_eth_volume += block_eth_volume;
if stats.blocks_seen % 5 == 0 {
stats.print_summary();
}
}
Ok(())
}
Your monitor is now a proper dashboard. It shows per-block breakdowns, highlights the biggest transactions, and accumulates running statistics. Next up, you will take on the Secret Mission and add whale alert detection for massive ETH transfers.
Secret mission
Add Whale Alert Detection
Your monitor already classifies transactions. But what about the big ones? In crypto, a "whale" is someone moving massive amounts of ETH in a single transaction. Add a detection system that flags any transfer above a configurable threshold and prints a bold alert to your terminal.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses Alchemy's free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Alchemy free account (no charges on free tier, 30M compute units/month included).
- Local Rust project in the eth-monitor/ directory on your Desktop.
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your Ethereum block monitor.
- The Alchemy free tier has no expiration and no credit card on file.
- Your Rust binary runs locally and consumes no resources when idle.
- Rust toolchain (rustc, cargo, rustup) remains installed and ready for future projects.
Pause - I'll come back to this later
Shut down running processes to free up terminal space. Your files and accounts stay intact.
- Stop the monitor if it is still running by pressing Ctrl+C in the terminal.
- No other action is needed. The Alchemy free tier remains active and your project files stay on disk.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete the project folder by running:
- Delete your Alchemy app by logging into your Alchemy Dashboard, clicking Apps in the left panel, selecting your app, clicking the Settings tab, scrolling down to Delete App, and confirming deletion.
- If you want to remove Rust entirely from your system, run:
cd ~/Desktop
rm -rf eth-monitor
rustup self uninstall
That's a Wrap!
That's a Wrap!
Nice work. You just built a production-style Rust binary that monitors Ethereum mainnet in real-time, using the same library that powers Foundry and Reth.
You've learned how to:
- Connected to Ethereum mainnet via WebSocket using Alloy and built a persistent streaming connection with Tokio that processes live blockchain data.
- Classified transactions into ETH transfers, contract calls, and contract deployments using Rust enums and pattern matching.
- Implemented error handling with the Result type and created structs with methods to track running statistics across blocks.
- Secret Mission: Added configurable whale alert detection that flags large ETH transactions using environment variables.
Ready to quiz yourself?