Ethereum

Ethereum

Rust

rust-web3 is the long-standing crate for talking to Ethereum from Rust, and it's what the examples here use. (The newer ethers-rs and alloy crates have taken over much new development, so check them if you're starting fresh — but rust-web3 still works and the patterns carry over.) It's built on Tokio, so the async runtime isn't optional: add tokio = { version = "1", features = ["full"] } alongside web3 = "0.19" in your Cargo.toml, and every call to chain 1 becomes an .await inside a Tokio task.

rust-web3 leans on Tokio for its async machinery, so the runtime is a hard dependency. Add tokio = { version = "1", features = ["full"] } and web3 = "0.19" to your Cargo.toml before connecting to Ethereum.

Web3-rs

Connecting is two steps: build an HTTP transport with web3::transports::Http::new(url) pointing at the Ethereum endpoint, then wrap it in Web3::new(transport). The transport constructor itself returns a Result, and so does every network call — eth().balance(...) gives you Result<U256, web3::Error>. Lean on the ? operator inside your async fn to propagate those errors up rather than unwrapping; a dropped connection or a bad address becomes a typed error your caller handles, not a panic mid-request.

use web3::Web3;
use web3::types::{Address, H256, TransactionParameters, U256};
use std::str::FromStr;
struct EthereumClient {
web3: Web3<web3::transports::Http>,
}
impl EthereumClient {
pub async fn new(url: &str) -> Result<Self, web3::Error> {
let transport = web3::transports::Http::new(url)?;
let web3 = Web3::new(transport);
Ok(Self { web3 })
}
pub async fn get_balance(&self, address: &str) -> Result<U256, web3::Error> {
let address = Address::from_str(address)
.map_err(|_| web3::Error::InvalidAddress)?;
self.web3.eth().balance(address, None).await
}
pub async fn send_transaction(
&self,
from: Address,
to: Address,
value: U256,
) -> Result<H256, web3::Error> {
let tx = TransactionParameters {
to: Some(to),
value,
from,
..Default::default()
};
self.web3.eth().send_transaction(tx).await
}
}
  • GitHub: https://github.com/tomusdrw/rust-web3
  • Docs: https://docs.rs/web3
  • Fully async, built on Tokio
  • Type-safe interfaces with U256, Address, and H256 primitives
  • Contract interaction loaded from a Solidity ABI
  • Transaction construction, signing, and submission
  • ENS resolution for .eth names
  • WebSocket transport for streaming Ethereum subscriptions

Contract Integration

Contract::from_json takes the eth() handle, an address, and the ABI bytes, and returns a Contract bound to that deployment on Ethereum. The two verbs to keep straight: contract.query(...) runs a read as an eth_call and decodes the return into the Rust type you ask for — perfect for an ERC-20 balanceOf — while contract.call(...) submits a state-changing transaction and returns its hash. Mixing them up is the most common beginner slip; query never touches the chain's state, call always does.

use web3::contract::{Contract, Options};
use web3::types::{Address, U256};
struct SmartContract {
contract: Contract<web3::transports::Http>,
}
impl SmartContract {
pub async fn new(
web3: Web3<web3::transports::Http>,
address: Address,
abi: &[u8],
) -> Result<Self, web3::Error> {
let contract = Contract::from_json(
web3.eth(),
address,
abi,
)?;
Ok(Self { contract })
}
pub async fn call_method<T: serde::de::DeserializeOwned>(
&self,
method: &str,
params: &[web3::contract::tokens::Tokenize],
) -> Result<T, web3::Error> {
let result = self.contract.query(
method,
params,
None,
Options::default(),
None,
).await?;
Ok(result)
}
}

Error Handling

Rather than pass web3::Error around everywhere, derive a domain error enum with thiserror and fold the library error in through #[from], as the EthereumError below does. That gives you one error type to match on, with variants for invalid addresses and failed transactions that read clearly in a Result. Pair it with safe_ wrappers that do the wei-to-ether conversion at the boundary: a balance comes back as U256 wei, and dividing by 1e18 into an f64 is fine for display — just remember that f64 loses precision, so never feed that float back into anything that signs or settles on Ethereum.

use thiserror::Error;
#[derive(Error, Debug)]
pub enum EthereumError {
#[error("Web3 error: {0}")]
Web3Error(#[from] web3::Error),
#[error("Invalid address: {0}")]
InvalidAddress(String),
#[error("Contract error: {0}")]
ContractError(String),
#[error("Transaction failed: {0}")]
TransactionError(String),
}
impl EthereumClient {
pub async fn safe_get_balance(&self, address: &str) -> Result<f64, EthereumError> {
let wei = self.get_balance(address).await?;
let eth = wei.as_u128() as f64 / 1e18;
Ok(eth)
}
}

Async Event Handling

subscribe_new_heads hands back a Stream of block headers, which means you need the WebSocket transport — eth_subscribe doesn't ride HTTP, so connect over wss://ethereum.therpc.io/YOUR_API_KEY rather than the HTTPS URL. Bring futures::StreamExt into scope and drain the stream with while let Some(block) = stream.next().await, matching each item as Ok(header) or an error. Since Ethereum produces a header roughly every 12 seconds, the loop spends most of its time parked on .await rather than spinning.

use futures::StreamExt;
impl EthereumClient {
pub async fn monitor_blocks(&self) -> Result<(), web3::Error> {
let mut stream = self.web3.eth_subscribe().subscribe_new_heads().await?;
while let Some(block) = stream.next().await {
match block {
Ok(header) => {
println!("New block: {}", header.number.unwrap_or_default());
}
Err(e) => {
eprintln!("Error: {}", e);
}
}
}
Ok(())
}
}

Testing

Mark async tests with #[tokio::test] so each one gets its own runtime — a plain #[test] can't .await. Point those tests at a local node on http://localhost:8545 (anvil or a geth dev chain) or a mainnet fork, not the production endpoint. Hammering the live Ethereum connection from a test suite burns CUs on every CI run and makes assertions flaky, since real chain 1 state moves with each block; a forked or local chain stays deterministic.

#[cfg(test)]
mod tests {
use super::*;
use tokio;
#[tokio::test]
async fn test_balance_query() {
let client = EthereumClient::new("http://localhost:8545")
.await
.expect("Failed to create client");
let balance = client
.get_balance("0x742d35Cc6634C0532925a3b844Bc454e4438f44e")
.await
.expect("Failed to get balance");
assert!(balance > U256::zero());
}
}

Ready to call this in production?

Free tier covers personal projects. Pay-as-you-go scales without a card.