Rust Web3 token transactions from blocks: how to

In this tutorial, we will learn about how to get token transactions info with Web3 and Rust. What we will learn is how to get the latest block’s transactions. Then loop through those transactions, find which of them are ERC20 smart contracts, get the token name, and also which method was used in the transaction. For example, approval, transfer, mintItem, etc. that kind of thing.

This is a follow-up to my article: Rust Web3 connect to Ethereum blockchain: how to.

Note: since we will not be performing any transactions in this project, it is safe to use a connection to the Ethereum main net.

The completed project can be found on my Github here: https://github.com/tmsdev82/rust-web3-block-transactions-tutorial.

Prerequisites

To be able to follow this tutorial we need:

Set up the Rust Web3 token transactions info gathering project

We start by creating the project and then configuring dependencies, and the keys for the Ethereum connection using infura.io.

Let’s create the project: cargo new rust-web3-block-token-transactions-tutorial.

Then, cd into the project directory, open up your favorite IDE, and let’s add the following dependencies to Cargo.toml:

[package]
name = "rust-web3-block-token-transactions-tutorial"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
web3 = "0.17.0"
tokio = { version= "1", features = ["full"] }
dotenv = "0.15.0"
serde = { version = "1.0", features = ["derive"]}
serde_json = "1.0"

The dependencies we are using:

  • web3: Ethereum JSON-RPC multi-transport client. Rust implementation of Web3.js library.
  • tokio: An event-driven, non-blocking I/O platform for writing asynchronous I/O backed applications.
  • dotenv: A `dotenv` implementation for Rust. We will use this to load sensitive information (keys, ids, etc) from a .env file.
  • serde: A generic serialization/deserialization framework.
  • serde_json: A JSON serialization file format.

Finally, let’s add a .env file to the root of our project. This .env file contains the following environment variable for connecting with the infura.io Ethereum node as mentioned we will be using a main net connection: INFURA_MAIN=wss://mainnet.infura.io/ws/v3/xxxxxx

Let’s not forget to add .env to our .gitignore file so we don’t accidentally push sensitive data to a public repository:

/target
.env

Getting the latest block’s information

In this section, we are going to get the latest block and extract information from it using Rust and Web3 after this section we will look at token transactions.

Let’s establish a connection to an Etherum node through Infura.io and get some information about the latest block:

use chrono::prelude::*;
use std::env;
use web3::types::{BlockId, BlockNumber};

#[tokio::main]
async fn main() {
    dotenv::dotenv().ok();

    let websocket = web3::transports::WebSocket::new(&env::var("INFURA_MAIN").unwrap())
        .await
        .unwrap();
    let web3s = web3::Web3::new(websocket);

    let latest_block = web3s
        .eth()
        .block(BlockId::Number(BlockNumber::Latest))
        .await
        .unwrap()
        .unwrap();

    println!(
        "block number {}, number of transactions: {}, difficulty {}",
        latest_block.number.unwrap(),
        &latest_block.transactions.len(),
        &latest_block.total_difficulty.unwrap()
    );
}

Let’s go through the code step by step.

Initial configuration set ups

use chrono::prelude::*;
use std::env;
use web3::types::{BlockId, BlockNumber};

#[tokio::main]
async fn main() {

First, we bring chrono into scope so we can convert timestamps into human-readable format. With the next line, we bring env into scope for reading environment variables. We also bring in types from the web3 crate BlockId which is an enum that allows us to use a block identifier as a number or a hash, and BlockNumber which is another enum representing a block number. It has useful shortcuts like Latest and Earliest.

Next, we enable the async runtime with #[tokio::main] on line 5.

We then set environment variables for the program with the .env file using dotenv::dotenv().ok(); on line 7.

On the next few lines we set up the WebSocket connection to the Ethereum node:

    let websocket = web3::transports::WebSocket::new(&env::var("INFURA_MAIN").unwrap())
        .await
        .unwrap();
    let web3s = web3::Web3::new(websocket);

Here get a WebSocket transport object passing in the value of the environment variable INFURA_MAIN. And then we create a new instance of the Web3 struct. This is a wrapper for all Web3 namespaces.

Get Block information using Web3

Now let’s get some information about the latest mined block from the Ethereum network:

    let latest_block = web3s
        .eth()
        .block(BlockId::Number(BlockNumber::Latest))
        .await
        .unwrap()
        .unwrap();

    let timestamp = latest_block.timestamp.as_u64() as i64;
    let naive = NaiveDateTime::from_timestamp(timestamp, 0);
    let utc_dt: DateTime<Utc> = DateTime::from_utc(naive, Utc);

    println!(
        "[{}] block num {}, parent {}, transactions: {}, gas used {}, gas limit {}, base fee {}, difficulty {}, total difficulty {}",
        utc_dt.format("%Y-%m-%d %H:%M:%S"),
        latest_block.number.unwrap(),
        latest_block.parent_hash,
        latest_block.transactions.len(),
        latest_block.gas_used,
        latest_block.gas_limit,
        latest_block.base_fee_per_gas.unwrap(),
        latest_block.difficulty,
        latest_block.total_difficulty.unwrap()
    );

On lines 14-19 we get the latest Block information using the block method from the eth namespace.

Then on lines 21-23 we take the block’s timestamp and convert it to a UTC-based DateTime object, which we can later use to print the date and time.

Then we simply print most of the fields on the Block object:

  • number: This block’s number.
  • parent_hash: the hash id for the previous block. This is how blocks are linked, they each refer to the the block before them.
  • transactions.len(): The number of transactions in the current block.
  • gas_used: Total gas that was used to execute the contracts in the block.
  • gas_limit: maximum amount of gas that can be expended by all transactions in the block. A limit is important, otherwise blocks could get very large.
  • base_fee_per_gas: minimum fee per gas required for a transaction to be included in the block. When creating a transaction the creator can set the fee per gas they are willing to pay.
  • difficulty: the effort required to mine the block.
  • total_difficulty: sum of all block difficulties until this block.

Let’s run the program and see the result:

 Finished dev [unoptimized + debuginfo] target(s) in 5.47s
     Running `target/debug/rust-web3-block-token-transactions-tutorial`
[2022-01-11 22:11:25] block num 13986871, parent 0xb6d8…7db6, transactions: 307, gas used 26798504, gas limit 29999944, base fee 160075796255, difficulty 12259361112919174, total difficulty 38734419068038865786966

Web3 Ethereum transactions

Now we will write some Rust code to print some basic information before we get to specific Web3 Token Transactions. In this section, we are going to create a loop to go through the latest block’s transactions.

Basic transaction information

First, let’s bring more Web3 items into scope:

use chrono::prelude::*;
use std::env;
use web3::helpers as w3h;
use web3::types::{BlockId, BlockNumber, TransactionId, U64};

This helpers module on line 3 has some useful functions that we will be using later. Then we have added some more types relevant to the transactions.

Next, let’s write the loop and print some basic information for now:

    for transaction_hash in latest_block.transactions {
        let tx = match web3s
            .eth()
            .transaction(TransactionId::Hash(transaction_hash))
            .await
        {
            Ok(Some(tx)) => tx,
            _ => {
                println!("An error occurred");
                continue;
            }
        };
        let from_addr = tx.from.unwrap_or(H160::zero());
        let to_addr = tx.to.unwrap_or(H160::zero());
        println!(
            "[{}] from {}, to {}, value {}, gas {}, gas price {}",
            tx.transaction_index.unwrap_or(U64::from(0)),
            w3h::to_string(&from_addr),
            w3h::to_string(&to_addr),
            tx.value,
            tx.gas,
            tx.gas_price,
        );
    }

We loop through the transaction hashes and we retrieve the associated transaction data on lines 40-50. We use a match statement to check if we can get the transaction data or else we simply print "an error occurred" and continue to the next transaction hash.

Finally, we print the transaction data. Most of these attributes are self-explanatory:

  • transaction_index: the index of the transaction within the block.
  • from: the “from” address.
  • to: the “to” address.
  • value: the value in terms of Wei that was sent. Wei is the smallest unit a value can be expressed in on Ethereum. We wil convert this to an Eth value later.
  • gas: How much gas was used for the transaction.
  • gas_price: Price per gas.

On lines 51 and 52 we try to get valid values for the addresses by unwrapping the Option<H160> or else returning a 0x0 address. Doing that handles possible None values.

On lines 56 and 57 we use the to_string function from helpers module to turn the H160 into a human-readable hex string. The all familiar address string.

Wei to Eth conversion

Before moving on to extracting other information from the transaction data, let’s convert the Wei to Eth.

We’ll add a function called wei_to_eth to main.rs:

fn wei_to_eth(wei_val: U256) -> f64 {
    let res = wei_val.as_u128() as f64;
    let res = res / 1_000_000_000_000_000_000.0;
    res
}

On line 66 we convert the U256 to a f64 by first turning it into a u128. Because there is no function to covert to f64 directly on the U256 struct. Then we divide that number by 1_000_000_000_000_000_000.0, because 1 Eth is 1^18 Wei.

We also need to bring the U256 type into scope:

use chrono::prelude::*;
use std::env;
use web3::helpers as w3h;
use web3::types::{BlockId, BlockNumber, TransactionId, U256, U64};

Now we can convert the Wei value to Eth to make more sense of the value presented in the transaction data:

        let eth_value = wei_to_eth(tx.value);
        println!(
            "[{}] from {}, to {}, value {}, gas {}, gas price {}",
            tx.transaction_index.unwrap_or(U64::from(0)),
            w3h::to_string(&from_addr),
            w3h::to_string(&to_addr),
            eth_value,
            tx.gas,
            tx.gas_price,
        );

The result now looks like this:

[2022-01-12 20:32:17] block num 13992910, parent 0xa2d1…4910, transactions: 286, gas used 22307212, gas limit 30078138, base fee 202268958667, difficulty 12482558674757734, total difficulty 38808868990449559406767
[0] from "0xd30b438df65f4f788563b2b3611bd6059bff4ad9", to "0xdac17f958d2ee523a2206206994597c13d831ec7", value 0, gas 420000, gas price 832000000000
[1] from "0x8baf2ec096b34dfe17262ffacdc92206a5719066", to "0x7be8076f4ea4a4ad08075c2508e481d6c946d12b", value 0.79, gas 294561, gas price 382402574370
[2] from "0x46340b20830761efd32832a74d7169b29feb9758", to "0xdac17f958d2ee523a2206206994597c13d831ec7", value 0, gas 350000, gas price 271247144334
[3] from "0x46340b20830761efd32832a74d7169b29feb9758", to "0x556e90c4bb06339bba855e2d0b44aa54c6406d3e", value 0.244610258279492, gas 350000, gas price 271247144334
[4] from "0x46340b20830761efd32832a74d7169b29feb9758", to "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", value 0, gas 350000, gas price 271247144334
[5] from "0x46340b20830761efd32832a74d7169b29feb9758", to "0xfac7fa94a49ac56384a51dc264cc70752446be7f", value 2.095, gas 350000, gas price 271247144334
[6] from "0x0031e147a79c45f24319dc02ca860cb6142fcba1", to "0xa4380320458a337ec6f8811cfe87844ae73101ba", value 0, gas 500000, gas price 263340000000
[7] from "0x65dcf7782260aa1d6642a85f36c0bc9545182f1e", to "0x7be8076f4ea4a4ad08075c2508e481d6c946d12b", value 0.69, gas 365605, gas price 252268958667
[8] from "0x8aa5d1e0a91111ecf31d4d99ed4a2c5624ed4f6e", to "0xeec5857897d534e0fe4bc9e576a9f8c497581ac0", value 0.015395405635386, gas 21000, gas price 244371518022
[9] from "0x2e46f444493a7eddfc5dec1383644e20de1d848d", to "0x410f1632af4bbfd31258aa82ea7fd4e2207b016c", value 0.02730548, gas 21000, gas price 241000000000
[10] from "0x8eb871bbb6f754a04bca23881a7d25a30aad3f23", to "0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce", value 0, gas 35155, gas price 237341251292
[11] from "0x3cf4b56702212ece872ea58811e930615baea709", to "0xd2c6c415c40ec254b43b9a8ecc63dcf1f6487533", value 0.995018367367702, gas 21000, gas price 237220601538
[12] from "0x3170c0ddcea0836c77f80bfdace7d50b4ead92da", to "0xdac17f958d2ee523a2206206994597c13d831ec7", value 0, gas 75000, gas price 237000000000
[13] from "0x5a6769208bff7f07f588a255623eb556ef26319a", to "0xb7d2feac78609111dae18ca1ffbe14b7dcc4c455", value 0.05922907, gas 21000, gas price 235000000000

Deciphering transaction data from a block

In this section, we will decipher some interesting information about the transactions in a block. We will try to find token or NFT related transactions and determine what function was used in the transaction. Because the function names tell us something about what kind of transaction took place. For example, it could be an NFT being minted, or a coin transfer taking place.

However, contract data and function names cannot be retrieved from the transaction directly. Because of that, we need some configuration files to help us.

The ERC20 Application Binary Interface

The Application Binary Interface (ABI) is an interface that describes the available functions, the information required by those functions, and the resulting outputs from the functions. The ERC20 ABI is the standard for Ethereum tokens. We will use this ABI to try and get the name of a token in the transaction. If the function call for the name fails then it is likely not an ERC20 token transaction.

Let’s add a file to the src directory called erc20_abi.json. The contents of this file can be found here on my GitHub: https://github.com/tmsdev82/rust-web3-block-transactions-tutorial/blob/main/src/erc20_abi.json.

Smart contract function names look up data

Next, we will add a file to look up function names based on hashes. From the transaction, we can get the input data. The first few bytes of that input data represent the function name. With the lookup file, we can match bytes value with a name function signature.

This data is based on an external GitHub repository: https://github.com/ethereum-lists/4bytes that I turned into a JSON file.

Let’s add the JSON file signatures.json to our project in the src directory. The contents of the file can be found on my GitHub: https://github.com/tmsdev82/rust-web3-block-transactions-tutorial/blob/main/src/signatures.json.

Getting Token names from transactions using Rust and Web3

In this section, we will get the name of a Token in the transaction. To keep things simple we will only look at the to_address. We can use the ERC20 ABI to call the name function to get the name of the Token. This only works if the address belongs to a Token’s smart contract of course.

Because of that, our first step is to find if there is code (for a smart contract) at the address. If there isn’t, then it is a wallet address. To keep the output smallish we will skip processing the transactions that don’t have a to_address that is a Token.

Determining if an address is a valid Smart Contract address

Let’s put the following code above let from_addr = tx.from.unwrap_or(H160::zero());:

       let smart_contract_addr = match tx.to {
            Some(addr) => match web3s.eth().code(addr, None).await {
                Ok(code) => {
                    if code == web3::types::Bytes::from([]) {
                        println!("Empty code, skipping");
                        continue;
                    } else {
                        println!("Non empty code, returning address.");
                        addr
                    }
                }
                _ => {
                    println!("Unable to retrieve code, skipping.");
                    continue;
                }
            },
            _ => {
                println!("To address is not a valid address, skipping.");
                continue;
            }
        };

Here on we put the tx.to in a match statement to check for some value with Some(addr). Then if it is a valid address value we continue and try to get code at the address using web3s.eth().code(addr, None) on line 53.

We check if the call to code returns an Ok() result, which contains the code in bytes. However, getting an Ok() result still doesn’t mean there is actual code there. Our final check is to see if the code return is empty bytes on line 55.

If the code bytes are not empty we return the addr value.

Invoking the name function to get the token name

Now we can instance a Smart Contract interface and call functions on the address. First, we have to add more things into scope:

use chrono::prelude::*;
use std::env;
use web3::contract::{Contract, Options};
use web3::helpers as w3h;
use web3::types::{BlockId, BlockNumber, TransactionId, H160, U256, U64};

Then we will attempt to instance the interface using web3 and the ABI JSON file we created earlier:

       let smart_contract = match Contract::from_json(
            web3s.eth(),
            smart_contract_addr,
            include_bytes!("erc20_abi.json"),
        ) {
            Ok(contract) => contract,
            _ => {
                println!("Failed to init contract, skipping.");
                continue;
            }
        };

Here we call the Contract::from_json function to get the contract instance. We pass in the address we determined to be a valid smart contract address smart_contract_address and the definition of the ERC20 ABI (line 78). If we get an Ok() response we return the instance, otherwise, we skip processing this transaction further.

Finally, we can move to invoke a function on the contract. In this case the name function. We do this by calling query on the contract instance and specifying the name of the function as the first parameter:

        let token_name: String = match smart_contract
            .query("name", (), None, Options::default(), None)
            .await
        {
            Ok(result) => result,
            Err(error) => {
                println!("Error: {:?}", error);
                continue;
            }
        };

Because the name function requires no other parameters, the rest of the query function’s parameters are empty, none, or default.

Let’s print the name of the token with the rest of the information about the transaction:

        println!(
            "[{}] ({}) from {}, to {}, value {}, gas {}, gas price {}",
            tx.transaction_index.unwrap_or(U64::from(0)),
            &token_name,
            w3h::to_string(&from_addr),
            w3h::to_string(&to_addr),
            eth_value,
            tx.gas,
            tx.gas_price,
        );

When we run the program the output looks something like this:

[46] (Apollo Inu) from "0x91ef92c83a9fc1a13ff69e4397f03a3acdb8106b", to "0xadf86e75d8f0f57e0288d0970e7407eaa49b3cab", value 0, gas 55900, gas price 253661888519
Empty code, skipping.
Empty code, skipping.
Non empty code, returning address.
[49] (Matic Token) from "0xddfabcdc4d8ffc6d5beaf154f18b778f892a0740", to "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0", value 0, gas 250000, gas price 253661888519
Non empty code, returning address.
[50] (USD Coin) from "0xd86c6ae32199d1c14e573f3bd9987dcc1b4fec49", to "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", value 0, gas 84000, gas price 253661888519
Empty code, skipping.
Empty code, skipping.
Non empty code, returning address.
[53] (Wrapped UST Token) from "0x09a77782f6b12bb02275c8c29404c9a56ed06dc9", to "0xa47c8bf37f92abed4a126bda807a7b7498661acd", value 0, gas 36411, gas price 253661888519
Empty code, skipping.
Empty code, skipping.
Non empty code, returning address.
[56] (Project Wyvern Exchange) from "0x089ad55045fe72f1f77eaac7e166de9e277600a5", to "0x7be8076f4ea4a4ad08075c2508e481d6c946d12b", value 0.275, gas 233945, gas price 253311888519
Non empty code, returning address.
[57] (Project Wyvern Exchange) from "0xec7de90f3f8cdb7e78eaa241a9bff9d1c79d9346", to "0x7be8076f4ea4a4ad08075c2508e481d6c946d12b", value 0.3, gas 270124, gas price 253161888519
Non empty code, returning address.
Could not get name, skipping.
Non empty code, returning address.
[59] (Undead Pastel Club) from "0x5ace364de66dafdb8e879f8743a859d27c6966fb", to "0x0811f26c17284b6e331beaa2328471107576e601", value 0, gas 46726, gas price 253161888519
Empty code, skipping.

Getting transaction function names from transaction input

In this final section, we will get the name of the function that was invoked during the Smart Contract transaction. The input field of the transaction contains data used as input for the contract. The first 4 bytes contain the name of the function.

If we want to convert the byte information to a function name we have to read in our lookup data first.

Let’s do that somewhere at the top of the main() function. Before we do that we have to bring some more items into scope again.

Prepare the functions lookup

Here is the full use code block:

use chrono::prelude::*;
use std::collections::BTreeMap;
use std::env;
use std::fs::File;
use std::io::BufReader;
use web3::contract::{Contract, Options};
use web3::helpers as w3h;
use web3::types::{BlockId, BlockNumber, TransactionId, H160, U256, U64};

We will deserialize the JSON to a BTreeMap instance, so that is why we include it here.

Now we can write the code for reading in the file:

    dotenv::dotenv().ok();    

    let file = File::open("src/signatures.json").unwrap();
    let reader = BufReader::new(file);
    let code_sig_lookup: BTreeMap<String, Vec<String>> = serde_json::from_reader(reader).unwrap();

Looking up token transaction function signatures

We can finally write the Rust code for looking up Token transactions’ functions.

        let input_str: String = w3h::to_string(&tx.input);
        if input_str.len() < 12 {
            continue;
        }
        let func_code = input_str[3..11].to_string();
        let func_signature: String = match code_sig_lookup.get(&func_code) {
            Some(func_sig) => format!("{:?}", func_sig),
            _ => {
                println!("Function not found.");
                "[unknown]".to_string()
            }
        };

First, we convert the input data to a string. Then we take the part from the string that is needed to do the signature look up. 4 bytes is 8 characters.

Then we use a match statement to see if we can get a value using the function lookup code. If so we return the associated function signature(s). Otherwise, we return "[unknown]".

The final result

Now let’s look at what kind of functions we can decipher from these transactions. Let’s add the func_signature value to the printed information and then run the program:

        println!(
            "[{}] ({} -> {}) from {}, to {}, value {}, gas {}, gas price {}",
            tx.transaction_index.unwrap_or(U64::from(0)),
            &token_name,
            &func_signature,
            w3h::to_string(&from_addr),
            w3h::to_string(&to_addr),
            eth_value,
            tx.gas,
            tx.gas_price,
        );

And the result:

Non empty code, returning address.
Could not get name, skipping.
Non empty code, returning address.
[5] (USD Coin -> ["transfer(address,uint256)"]) from "0xfac87e892800f73fea9bd4a81b4e0269f4363fe3", to "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", value 0, gas 79396, gas price 268474812811
Empty code, skipping.
Empty code, skipping.
Empty code, skipping.
Non empty code, returning address.
[9] (Graph Token -> ["approve(address,uint256)"]) from "0xbd9cf606a81da379d1ad3874363746f73295915a", to "0xc944e90c64b2c07662a292be6244bdf05cda44a7", value 0, gas 46506, gas price 235331289572
Non empty code, returning address.
Could not get name, skipping.
Empty code, skipping.
Empty code, skipping.
Empty code, skipping.
Empty code, skipping.
Empty code, skipping.
Non empty code, returning address.
Could not get name, skipping.
Non empty code, returning address.
Could not get name, skipping.
Non empty code, returning address.
[18] (Wrapped Ether -> ["withdraw(uint256)"]) from "0xb2066c050350d4072bb40344bc8d423a38fdb7b5", to "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", value 0, gas 54040, gas price 220000000000
Empty code, skipping.
Non empty code, returning address.
[20] (ChainLink Token -> ["transfer(address,uint256)"]) from "0x7bf225c53ac6c3680b6510f4bed26639b94bfbbf", to "0x514910771af9ca656af840dff83e8264ecf986ca", value 0, gas 52101, gas price 214000000000
...
[131] (STAPLEVERSE - FEED CLAN -> ["mint(uint256,uint256,bytes)"]) from "0xa283dfd91be8d638ea7e8d4800c20980ee2dda68", to "0x8ee9a60cb5c0e7db414031856cb9e0f1f05988d1", value 0.1, gas 125136, gas price 156354772202

Here we can see a transfer, approve, withdraw, and a mint being called.

Conclusion

We have learned how to extract various kinds of information from token transactions using Rust and Web3. With the end result of this project, we can now get the name of the Ethereum blockchain token, and the function name that was used during a transaction. This gives us an interesting look at the activity that happens on the blockchain.

The completed project can be found on my Github here: https://github.com/tmsdev82/rust-web3-block-transactions-tutorial.

Please follow me on Twitter to get updated on tutorials I am working on:

Comments (2)
  • This is an absolute life saver. Thank you for putting it together. Would you know how to convert a String contract address into H160 format so that the Contract section will work based on setting this address manually?

    • Hi, thank you for your comment.
      Sorry for the late reply, I have been away from the blog for a long time.

      You have probably already found the answer but for people who would like to know. You can use the from_str function on the Address/H160 object. Address is an type alias for H160.

      use web3::types::{Address};
      ...
      let aave_addr = Address::from_str("0x42447d5f59d5bf78a82c34663474922bdf278162").unwrap();

      There’s an example in an earlier tutorial:
      https://tms-dev-blog.com/rust-web3-connect-to-ethereum/

Leave a Reply

Your email address will not be published.