Ethereum transactions viewer app: Rust how to

In this tutorial, we will build an Ethereum transactions viewer app with Rust. This will be a full-stack Rust application. A benefit of this is that we can share the data model between frontend and backend. For the backend API we will use warp and for the frontend we will use the Rust framework Yew. Our application will make use of the Etherscan API to get data about transactions for a specified wallet address. This data will then be displayed in our Rust/Webassembly frontend.

For this tutorial, we will keep the data we use and the frontend relatively simple. There is a lot of information we can get about transactions. However, we will only display where the transaction came from, the value, the gas price, and the contract address and function name used if there is one.

The completed project’s code can be found on my GitHub here.

Prerequisites

To follow this tutorial the following is required:

What does the Ethereum transactions viewer look like?

Here is a quick preview of what we are going to build. As mentioned in the introduction we are going to build a full stack Rust app: an Ethereum transactions viewer. There are three main components a common library, backend, and frontend.

The frontend will allow users to input a Ethereum wallet address the backend will use this address to make calls to the Etherscan API to retrieve information. Then the backend will use that data to send a response back to our frontend.

Common component

This component will contain code that is common between the backend and the frontend. Namely the data models. We will have data models representing data coming from the Etherscan API and for what we send back to our frontend.

Backend component

Next, the backend is a REST API that we construct using the warp crate. Our backend can receive requests from the frontend. Which in turn will trigger it to retrieve data from the Etherscan API using reqwest, we then transform that data and send it up to the frontend.

Frontend component

This will be a simple UI for the user to put in an Ethereum wallet address and then a simple display of the address’ balance and recent transactions with additional information about the transaction.

Ethereum transactions viewer app workspace setup

In this section, we will setup the initial workspace for this project.

Let’s create the main project directory using the cargo CLI: cargo new fullstack-etherscan-app. We’ll turn this into a workspace. Using cargo workspaces we can more easily manage multiple packages in one project. See more information on workspaces here.

Then let’s change the Cargo.toml to configure the workspace for the full-stack application:

[workspace]

members = [
    "backend",
    "frontend",

    #Internal
    "common"
]

Let’s get rid of the src directory as this workspace project itself will not contain source code.

Next, we are going to add the component projects. Let’s execute the following commands within the fullstack-etherscan-app directory:

  • Common: cargo new --lib common
  • Backend: cargo new backend
  • Frontend: cargo new frontend

The resulting directory structure should look like this:

fullstack-etherscan-app
+Cargo.toml
+--backend
   +Cargo.toml
   +--src
       +main.rs
+--common
   +Cargo.toml
   +--src
      +lib.rs
+--frontend
   +Cargo.toml
   +--src
      +main.rs

Ethereum transactions viewer common component

In this common component we will define our data models. As in the data structures of the data we receive and send.

As mentioned we are going to make use of the Etherscan API to retrieve transaction data from an address on the blockchain. While we are at it let’s also get balance data from the address. We can find the definitions of the response data in the API documentation.

To get the data we want we have to make requests to the following API endpoints:

On thing to note right away is that there is poor consistency in naming convention for the data structures. For instance, some are camelCase others are PascalCase.

Common component dependencies

Before we begin writing our data models, let’s first add the required dependencies to our common library.

In the Cargo.toml:

[package]
name = "common"
version = "0.1.0"
edition = "2021"

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

[dependencies]
serde = { version = "1.0", features = ["derive"]}

Generic response data model

Etherscan’s response data structures all share some of the same fields:

  • status: status code for the response.
  • message: message explaining the result.
  • result: the actual data corresponding to the request. This field’s type can differ from endpoint to endpoint (string, list, etc.)

Therefore we can create a generic struct that can be used for all these response data structures. In other words, Rust’s generics can be used to create definitions of structs, functions, etc. that can be used with many concrete data types.

Let’s add a file called models.rs to our common library:

use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct EtherScanResult<T> {
    pub status: String,
    pub message: String,
    pub result: T
}

We bring some traits from serde into scope Deserialize and Serialize. When we annotate our struct with serde can easily deserialize and serialize the data represented by this struct.

Balance response data model

Let’s now write the struct for the response for getting Ether balance for a single address. The example looks like this:

{
   "status":"1",
   "message":"OK",
   "result":"40891626854930000000000" 
}

In our Rust code we can represent this using our generic struct and an alias like this:

use serde::{Deserialize, Serialize};

pub type AccountBalanceResponse = EtherscanResult<String>;

#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct EtherscanResult<T> {
    pub status: String,
    pub message: String,
    pub result: T,
}

Since the result in this case is the address’ balance represented as a string we can simple create an alias for EtherscanResult<String> for this endpoint’s response.

Normal transactions response model

Next, let’s create the struct for transactions result which we can use to create an alias using EtherscanResult<T> again:

use serde::{Deserialize, Serialize};

pub type AccountBalanceResponse = EtherscanResult<String>;
pub type AccountNormalTransactionsResponse = EtherscanResult<Vec<NormalTransactionResult>>;

#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct EtherscanResult<T> {
    pub status: String,
    pub message: String,
    pub result: T,
}

#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct NormalTransactionResult {
    pub block_number: String,
    pub time_stamp: String,
    pub hash: String,
    pub nonce: String,
    pub block_hash: String,
    pub transaction_index: String,
    pub from: String,
    pub to: String,
    pub value: String,
    pub gas_price: String,
    pub is_error: String,
    #[serde(default)]
    pub txreceipt_status: String,
    pub input: String,
    pub contract_address: String,
    pub cumulative_gas_used: String,
    pub confirmations: String,
    pub method_id: String,
    pub function_name: String,
}

Here we have our struct annotated with #[serde(rename_all = "camelCase")] to ensure fields deserialized by serde are mapped to our struct’s fields with the Rust naming conventions.

However, there is one field that does not conform to a naming convention that can be converted from camelCase and that is txreceipt_status. So we have to annotate that with #[serde(default)] to prevent mapping to/from camelCase.

On line 4 we define our alias. Since the result field in the response is a list of objects we use a Vec<NormalTransactionResult> as the concrete type for the generic field.

Models between backend and frontend

Finally, let’s add some models that will be sent between the backend and frontend of our application to communicate. We’ll add a struct (QueryParams) that represents query parameters for the backend endpoint. For example, to indicate for what address we want to see transactions. Also, we’ll need a struct representing the response to that query: AccountData.

The listing below shows the structs:

#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct QueryParams {
    pub address: String,
    pub page: u32,
    pub offset: u32,
    pub sort: String,
}

#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct AccountData {
    pub address: String,
    pub balance: String,
    pub page: u32,
    pub offset: u32,
    pub sort: String,
    pub normal_transactions: Vec<NormalTransactionResult>,
}

First, QueryParams has the following fields:

  • address: the address of the wallet we want to see the transactions of.
  • page: what page of the data we want to see, starts at 1.
  • offset: how many results we want to see per page, max 1000.
  • sort: this is the sort direction: asc for ascending and desc for descending.

Second, we have the AccountData struct, which represents what our backend will return to the frontend after a request. It had the following fields:

  • address: the address that was used to get the balance and transaction data.
  • balance: balance of the account in gwei.
  • page: what page of the transactions the records are from.
  • offset: maximum number of records per page.
  • sort: which direction the data was sorted: ascending or descending.
  • normal_transactions: a vec of NormalTransactionResult data.

Now that we have the models implemented for our Ethereum transactions viewer app, we can work on the backend component.

Ethereum transactions viewer basic backend and frontend setup

Before we work on the details of the application, let’s create the bare minimum foundation. In this section, we will write a backend with an API endpoint that returns a text and a frontend that sends a request to that endpoint after clicking a button.

Basic Warp backend

Our framework of choice for this tutorial’s backend is warp. We won’t go into a lot of detail on how to set up the REST API endpoints in this tutorial. To see a more detailed tutorial on the subject of REST APIs with Rust please see: How to build a REST API server with Rust and warp.

Backend Dependencies setup

Let’s add the dependencies to the Cargo.toml file in the backend directory. Here we can refer to our own common library using the regular syntax but specifying a relative path as well: common = { version = "0.1.0", path = "../common"}.

[package]
name = "backend"
version = "0.1.0"
edition = "2021"

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

[dependencies]
tokio = { version="1.19.2", features= ["macros", "rt-multi-thread"]}
common = { version = "0.1.0", path = "../common"}
warp = "0.3.2"
serde = { version = "1.0.138", features = ["derive"]}
serde_json = "1"
log = "0.4"
log4rs = "1.1.1"
dotenv = "0.15.0"
reqwest = { version = "0.11", features = ["json"]}

We have tokio for the async runtime, warp for the backend framework, serde and serde_json for handling the JSON data, and reqwest for retrieving data from the Etherscan API.

Some optional crates are: we’re adding log4rs for nicer logging and dotenv to easily use a .env file to configure things in our backend, such as the Etherscan API key.

Logging configuration

We are using log4rs to enable configurable logging and color coding of log messages. For more information on what we can do with log4rs, please see my tutorial here: Basic how to log to a file in Rust with log4rs.

Let’s set up our logging configuration using a YAML file logging_config.yml in the root of our backend directory:

appenders:
  my_stdout:
    kind: console
    encoder:
      pattern: "{h({d(%Y-%m-%d %H:%M:%S)(utc)} - {l}: {m}{n})}"
root:
  level: debug
  appenders:
    - my_stdout

This will log a message to stdout with the date and time, the log level, and a message.

Let’s add the following code to main.rs to load the logging configuration and test it:

fn main() {
    log4rs::init_file("logging_config.yml", Default::default()).unwrap();
    log::debug!("Backend running");
}

To run the backend project navigate into the backend directory: cd backend and run the cargo run command: cargo run. The result in the terminal/console will look something like this:

ethereum transaction viewer app: logging example.

Basic Warp backend and endpoint

In this section, we will write the code to set up a warp server and endpoint. The server will return a text “Ethereum transactions viewer app” when any sends a HTTP GET request to the root URL. In this case the root URL would be http://localhost:5000:

use warp::Filter;

#[tokio::main]
async fn main() {
    log4rs::init_file("logging_config.yml", Default::default()).unwrap();

    let root = warp::path::end().map(|| "Ethereum transactions viewer app");
    let routes = root.with(warp::cors().allow_any_origin());

    warp::serve(routes).run(([127, 0, 0, 1], 5000)).await;
}

After running the backend code again with cargo run, opening the URL http://localhost:5000 in a browser should present us with the previously mentioned text:

The console/terminal output looks something like that:

Thanks to our logging configuration we also get information from other crates in our log output. In this case, logging from the warp server code.

Basic Yew frontend

In this next section, we will write code for a very basic frontend using the Yew framework. This frontend will send a request to our backend server upon pressing a button and then present the response on a web page.

Frontend Dependencies setup

Similarly to the backend, let’s add the dependencies to the Cargo.toml file in the frontend directory. Here we should also refer to our own common library using the regular syntax but specifying a relative path as well: common = { version = "0.1.0", path = "../common"}.

[package]
name = "frontend"
version = "0.1.0"
edition = "2021"

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

[dependencies]
yew = "0.19"
serde_json = "1"
serde = { version = "1.0.138", features = ["derive"]}
gloo-net = "0.2.3"
wasm-bindgen-futures = "0.4.30"
log = "0.4.17"
wasm-logger = "0.2.0"
web-sys = "0.3.57"
common = { version = "0.1.0", path = "../common"}

Here’s a list of the crates we are including with a short description:

  • yew: A framework for making client-side single-page apps.
  • serde_json: A JSON serialization file format.
  • serde: A generic serialization/deserialization framework.
  • gloo-net: HTTP requests library for WASM Apps.
  • wasm-bindgen-futures: Bridging the gap between Rust Futures and JavaScript Promises.
  • log: A lightweight logging facade for Rust.
  • wasm-logger: A logger that sends a message with its Rust source’s line and filename to the browser console.
  • web-sys: Bindings for all Web APIs, a procedurally generated crate from WebIDL.
  • common: our own common library holding data structure definitions.

Minimal Yew frontend

Because we’re using Yew and Trunk to create the frontend we also have to add an index.html file to the root of the frontend project:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Ethereum transactions</title>
</head>
<body>
    
</body>
</html>

Now let’s start by writing the most basic frontend using Yew we can to try it out. Let’s add the following code to main.rs in the frontend/src directory:

use yew::prelude::*;

struct App {}

impl Component for App {
    type Message = ();
    type Properties = ();

    fn create(ctx: &Context<Self>) -> Self {
        Self {}
    }

    fn view(&self, ctx: &Context<Self>) -> Html {
        html! {
            <div>
            <h1>{"Ethereum transactions viewer frontend"}</h1>
            </div>
        }
    }
}

fn main() {
    wasm_logger::init(wasm_logger::Config::default());
    yew::start_app::<App>();
}

Here on line 1 we use the yew prelude to bring all the classes and things into scope that we’re going to use to build the frontend.

Yew is designed around the use of components. And on line 3 we define an empty struct App to represent the main component for our application. Then on lines 5-20 we implement the Component trait. This trait requires implementations for create and view but there are other functions too. We will look at those other functions later.

The view function starting on line 13 determines what should be displayed on the web page. We use the html! macro to type HTML into Rust. There should always be one HTML element at the root. We have a div element here but our HTML can also be surrounded by empty tags like so: <><h1>{"Ethereum transactions viewer frontend"}</h1></>

Then in fn main() on lines 22-25 we initialize a couple of thins:

  • the logger: so any logging we do in the frontend code shows up in the dev tools console.
  • our yew app: yew::start_app::<App>();

We can run the frontend using the Trunk command: trunk serve, this will compile the project and run a server for the frontend:

When we navigate to http://localhost:8080 we should see our message.

Communicate with backend using Gloo-net

In this next section, we will use the gloo-net crate to make an HTTP request to our backend.

Let’s add a service.rs file and module to our frontend code:

use gloo_net::http::Request;

const API_URL: &str = "http://localhost:5000";

pub async fn get_data() -> Option<String> {
    let request_url = format!("{}", API_URL);

    log::debug!("request: {}", &request_url);
    let response = Request::get(&request_url).send().await.unwrap();

    let result = if response.ok() {
        let body = response.text().await.unwrap();
        log::debug!("Response: {}", &body);
        Some(body)
    } else {
        None
    };

    result
}

Here we define our backend URL as a const string slice on line 3. This gives us easy access to the backend URL if we were to write multiple functions talking to various endpoints. However, in this tutorial, we will only really have one endpoint.

Next on line 9, we use Request from gloo_net::http to send a HTTP GET request to the backend. The response is then checked for a success code using if response.ok() on line 11.

If the response is ok the text can be retrieved by calling .text() on the response object.

Handle calling an async function and updating the frontend with Yew

Now let’s update our frontend in parts to make use of this service function in main.rs and display the data. First, we need to add mod service; and a message enum for managing the component’s processing, second: a field to store the state of the data:

use yew::prelude::*;

mod service;

pub enum Msg {
    GetData,
    ProcessData(Option<String>),
}

struct App {
    received_data: Option<String>,
}

We have to indicate we use this Msg enum in our impl Component for App code block and also update the create function to set the received_data field to None:

impl Component for App {
    type Message = Msg;
    type Properties = ();

    fn create(ctx: &Context<Self>) -> Self {
        Self {
            received_data: None,
        }
    }
App component update

Then we need to add an update function that can handle these messages:

    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
        match msg {
            Msg::GetData => {
                log::debug!("Get data");
                ctx.link().send_future(async move {
                    let result = service::get_data().await;

                    Msg::ProcessData(result)
                })
            }
            Msg::ProcessData(data) => {
                log::debug!("Received data: {:?}", data);
                self.received_data = data;
            }
        }
        true
    }

On lines 26-33, we handle the GetData message. Because we have to make a call to our async function service::get_data() and our update function is not async we have to use send_future here on line 28. This function process a future. At the end of the function/closure called by send_future we have to return a Message type. In this case we return Msg::ProcessData and pass in the result from service::get_data().

We pass the result through the message system because we can’t modify our component’s state in the send_future context.

Finally, in the Msg::ProcessData(data) arm of the match we set the component’s received_data field to the incoming value.

The true at the end of the update function means that the component should be redrawn after every update.

App component view

Now let’s update the view function to add a button and display of the received_data field. All the button has to do is call a function that returns the Msg::GetData message:

    fn view(&self, ctx: &Context<Self>) -> Html {
        let link = ctx.link();
        let on_click = link.callback(|_e: MouseEvent| {
            log::debug!("Clicked");
            Msg::GetData
        });

        html! {
            <div>
            <h1>{"Ethereum transactions viewer frontend"}</h1>
            <div>
            <button onclick={on_click}>{"Get Data"}</button>
            </div>
            <div>
                { match self.received_data.clone() {
                    Some(data) => data,
                    _ => "No data received".to_string()
                }}
            </div>
            </div>
        }
    }

We define a callback function for the button click on lines 44-47, as mentioned it simply returns a message: Msg::GetData this be handled by our update function we wrote earlier.

Then we change the HTML portion where we added the button on line 53 and a match to determine what to display taking into account that the value can be None.

Basic web app result

Now that we have connected all the pieces we can run the backend and frontend and have them communicate with each other. Let’s make sure we have both the backend and the frontend running:

  • backend: run the cargo run command from the backend directory.
  • frontend: trunk serve command from the frontend directory.

When we navigate to http://localhost:8080 in the browser we should see:

After pressing the button:

ethereum transactions viewer app basic web app example.

The console in the browsers dev tool shows the log message we wrote:

Now that we have the basics we can look at retrieving and displaying some more complex data structures.

Ethereum transactions viewer backend component

In this section, expand the backend to request data from the Etherscan API. We will also add an endpoint so that the frontend can request the backend to retrieve this data. In this case, the wallet balance and “normal” transactions data.

Let’s first write the code necessary to talk to the Etherscan API.

Etherscan API requests

We will use reqwest to send requests to the Etherscan API. To make use of connection pooling we will instance a Client object and pass it around to our functions that set up the requests.

HTTP client using reqwest

So, let’s add a service.rs module with a function that configures the client object:

use reqwest::{header, Client};

static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));

pub fn get_client() -> Client {
    log::debug!("Construct client.");

    let headers = header::HeaderMap::new();
    let client = Client::builder()
        .default_headers(headers)
        .user_agent(APP_USER_AGENT)
        .build()
        .unwrap();

    client
}

This uses the builder pattern to configure the client. We simply set the headers to empty/default and configure an agent name based on our project’s name and version. Then we return the instance of a Client object at the end of the function.

Etherscan API data retrieval functions

Next, let’s add a new file etherscan.rs to our backend project in backend/src. In this file we will write the functions for retrieving the balance and normal transactions from an address:

use reqwest::Client;

use common as c;

pub async fn get_balance_for_address(
    client: &Client,
    api_url: &str,
    api_key: &str,
    address: &str,
) -> c::models::AccountBalanceResponse {
    let module = "account";
    let action = "balance";
    let tag = "latest";

    let request_url = format!(
        "{}?module={}&action={}&address={}&tag={}&apikey={}",
        api_url, module, action, address, tag, api_key
    );

    let response_text = client
        .get(request_url)
        .send()
        .await
        .unwrap()
        .text()
        .await
        .unwrap();

    let result = serde_json::from_str::<c::models::AccountBalanceResponse>(&response_text).unwrap();

    result
}

pub async fn get_normal_transactions_for_address(
    client: &Client,
    api_url: &str,
    api_key: &str,
    address: &str,
    start_block: u32,
    end_block: u32,
    page: u32,
    offset: u32,
    sort: &str,
) -> c::models::AccountNormalTransactionsResponse {
    let module = "account";
    let action = "txlist";

    let request_url = format!("{}?module={}&action={}&address={}&startblock={}&endblock={}&page={}&offset={}&sort={}&apikey={}",
        api_url, module, action, address, start_block, end_block, page, offset, sort, api_key);

    let result_text = client
        .get(request_url)
        .send()
        .await
        .unwrap()
        .text()
        .await
        .unwrap();

    let result =
        serde_json::from_str::<c::models::AccountNormalTransactionsResponse>(&result_text).unwrap();

    result
}

These functions are very straightforward, for example the get_balance_for_address function:

pub async fn get_balance_for_address(
    client: &Client,
    api_url: &str,
    api_key: &str,
    address: &str,
) -> c::models::AccountBalanceResponse {
    let module = "account";
    let action = "balance";
    let tag = "latest";

    let request_url = format!(
        "{}?module={}&action={}&address={}&tag={}&apikey={}",
        api_url, module, action, address, tag, api_key
    );

    let response_text = client
        .get(request_url)
        .send()
        .await
        .unwrap()
        .text()
        .await
        .unwrap();

    let result = serde_json::from_str::<c::models::AccountBalanceResponse>(&response_text).unwrap();

    result
}

This function’s parameters are pretty much self explanatory:

  • client: a reference to a Client object which is the HTTP client that will send our request to the Etherscan API.
  • api_url: the root URL to the API. For example: https://api.etherscan.io/api. We will define this in main.rs as a const later.
  • api_key: security key to be able to use the API.
  • address: The wallet or address we want information from.

On lines 11-13 we have some variables and values that determine what kind of information we get back from the API. In this case we want to do something related to "account" namely getting the "balance". And we want the balance from the "latest" block.

Then we construct the request URL using these pieces of information and send a get request using the client object.

Finally, we deserialize the data from the response body text to a AccountBalanceResponse data struct and return it.

Since this is a simple example tutorial we are not doing any error handling and just unwrap everything.

Our second function get_normal_transactions_for_address is constructed in a similar way, but naturally retrieves other data and returns a AccountNormalTransactionsResponse type which is a Vec of objects.

Warp backend endpoint configuration

In this section, we configure the endpoint that our frontend will send requests to. This endpoint will return the AccountData object, which is both balance and transaction data in one.

Let’s update main.rs to add:

  • the client, etherscan, and handlers (that we still have to write) modules.
  • URL const for the Etherscan API address.
  • a configuration for the account endpoint path.
  • a filter function for passing in the Client object to the handler function for our account endpoint.

Below is the full code listing for main.rs:

use common::models as c_models;
use reqwest::Client;
use std::convert::Infallible;
use warp::{Filter, Rejection};

mod client;
mod etherscan;
mod handlers;

const API_URL: &str = "https://api.etherscan.io/api";

type WarpResult<T> = std::result::Result<T, Rejection>;

#[tokio::main]
async fn main() {
    dotenv::dotenv().ok();
    log4rs::init_file("logging_config.yml", Default::default()).unwrap();

    let http_client = client::get_client();

    let root = warp::path::end().map(|| "Welcome to my api");
    let account_route = warp::path("account")
        .and(warp::get())
        .and(with_http_client(http_client.clone()))
        .and(warp::query::<c_models::QueryParams>())
        .and_then(handlers::get_account_data);

    let routes = root.or(account_route).with(warp::cors().allow_any_origin());

    warp::serve(routes).run(([127, 0, 0, 1], 5000)).await;
}

fn with_http_client(
    client: Client,
) -> impl Filter<Extract = (Client,), Error = Infallible> + Clone {
    warp::any().map(move || client.clone())
}

Let’s look at some of the noteworthy pieces.

On line 16 we call dotenv::dotenv().ok() to set envirnonment variables from a .env file. A .env file is a good place to put our API key. Let’s add a file called .env and add a line with our API Key in there, for example: ETHER_SCAN_KEY=XXXXXXXXXXX. An actual API key can be requested from here.

On line 19 we create an instance of the HTTP Client which we can pass to handler functions. The Client object uses Arc internally which means that if we clone it we will get a reference counted point instead of a copy of the object. Meaning we can use connection pooling if we pass this object to various parts of our code by “cloning” it.

Our account endpoint is defined on lines 22-26:

    let account_route = warp::path("account")
        .and(warp::get())
        .and(with_http_client(http_client.clone()))
        .and(warp::query::<c_models::QueryParams>())
        .and_then(handlers::get_account_data);

Here we define a path: "account", we accept HTTP GET requests on it by calling .and(warp::get()). Next we use the filter with_http_client to pass a “clone” (in this case a reference counted pointer) of the http_client object.

The function with_http_client is defined on lines 33-37:

fn with_http_client(
    client: Client,
) -> impl Filter<Extract = (Client,), Error = Infallible> + Clone {
    warp::any().map(move || client.clone())
}

This function returns something that implements a Filter that extracts a Client object. For more information see the crate documentation here.

Going back to the endpoint configuration, on line 25 we configure that the endpoint accepts parameters mapped to the QueryParams struct we defined in our common library.

Finally, on line 26, we configure what function should handle the incoming request. This function should accept a Client object and QueryParams object as parameters. Let’s write that function next.

Endpoint handler function: get_account_data

In this section, we will write the handler function for our endpoint: get_account_data. This function will call our two functions from the etherscan module and combine the response data into one result namely AccountData.

Let’s crate a file called handlers.rs in the src directory with the following code:

use crate::{c_models, etherscan, WarpResult, API_URL};
use reqwest::Client;
use std::env;
use warp::{http::StatusCode, reply, Reply};

pub async fn get_account_data(
    client: Client,
    params: c_models::QueryParams,
) -> WarpResult<impl Reply> {
    let api_key = env::var("ETHER_SCAN_KEY").unwrap();
    let address = params.address;
    let result = etherscan::get_balance_for_address(&client, API_URL, &api_key, &address).await;

    log::info!("Balance for {}: {}", &address, &result.result);

    let response = etherscan::get_balance_for_address(&client, API_URL, &api_key, &address).await;

    let balance = response.result;

    let start_block = 0;
    let end_block = 99999999;
    let page = params.page;
    let offset = params.offset;
    let sort = params.sort;

    let response = etherscan::get_normal_transactions_for_address(
        &client,
        API_URL,
        &api_key,
        &address,
        start_block,
        end_block,
        page,
        offset,
        &sort,
    )
    .await;

    let normal_transactions = response.result;

    let account_data = c_models::AccountData {
        address: address.into(),
        balance: balance.into(),
        page,
        offset,
        sort: sort.into(),
        normal_transactions,
    };

    Ok(reply::with_status(
        reply::json(&account_data),
        StatusCode::OK,
    ))
}

Let’s look at the code in a few parts:

use crate::{c_models, etherscan, WarpResult, API_URL};
use reqwest::Client;
use std::env;
use warp::{http::StatusCode, reply, Reply};

pub async fn get_account_data(
    client: Client,
    params: c_models::QueryParams,
) -> WarpResult<impl Reply> {
    let api_key = env::var("ETHER_SCAN_KEY").unwrap();
    let address = params.address;
    let result = etherscan::get_balance_for_address(&client, API_URL, &api_key, &address).await;

    log::info!("Balance for {}: {}", &address, &result.result);

    let response = etherscan::get_balance_for_address(&client, API_URL, &api_key, &address).await;

    let balance = response.result;

Our function takes the following parameters:

  • client: a Client object instance for making requests to the Etherscan API endpoints.
  • params: a QueryParams object instance, representing query string parameters that influence how data ought to be retrieved from the Etherscan API.

The in the next few lines we retrieve the balance data by calling our get_balance_for_address function from our etherscan module.

Next, we call the get_normal_transactions_for_address function to retrieve data on transactions:

    let start_block = 0;
    let end_block = 99999999;
    let page = params.page;
    let offset = params.offset;
    let sort = params.sort;

    let response = etherscan::get_normal_transactions_for_address(
        &client,
        API_URL,
        &api_key,
        &address,
        start_block,
        end_block,
        page,
        offset,
        &sort,
    )
    .await;

    let normal_transactions = response.result;

On lines 22-24, we use values from the params object. We assign the values to new variables to be able to use shorthand notation later.

    let account_data = c_models::AccountData {
        address: address.into(),
        balance: balance.into(),
        page,
        offset,
        sort: sort.into(),
        normal_transactions,
    };

    Ok(reply::with_status(
        reply::json(&account_data),
        StatusCode::OK,
    ))
}

Finally, we take the retrieved data balance and normal_transactions and values from the search parameters and create a new AccountData instance. Which we then wrap in a Ok and reply::with_status as a return value.

This concludes our work for the backend.

Testing the backend

We can now run our backend code and do a little test, assuming we have our Etherscan API key set up. Let’s open a web browser, and navigate to the backend URL making sure to go to the account endpoint and using query parameters. Because all the parameters are required we will not get any results unless we use all of them. For example: http://localhost:5000/account?address=0xd5268a476aadd1a6729df5b3e5e8f2c1004139af&page=1&offset=1000&sort=asc

result from ethereum transactions app backend

Ethereum transactions viewer app frontend

In this section, we will update our simple frontend project. There are only a few things we should add to complete our simple Ethereum transactions viewer app:

  • Input field for an Ethereum address.
  • Table for viewing the data in a nice way.
  • Add a get_account_data function to send a request to our backend’s new account endpoint.

Adding the get_account_data function

Let’s first add this function to make use of the new account endpoint. Even though we won’t be using it anymore let’s leave the get_data function as it was and add a new function to service.rs:

use common::models as c_models;
use gloo_net::http::Request;

const API_URL: &str = "http://localhost:5000";

pub async fn get_data() -> Option<String> {
    let request_url = format!("{}", API_URL);

    log::debug!("request: {}", &request_url);
    let response = Request::get(&request_url).send().await.unwrap();

    let result = if response.ok() {
        let body = response.text().await.unwrap();
        log::debug!("Response: {}", &body);
        Some(body)
    } else {
        None
    };

    result
}

pub async fn get_account_data(
    params: &common::models::QueryParams,
) -> Option<c_models::AccountData> {
    let request_url = format!(
        "{}/account?address={}&page={}&offset={}&sort={}",
        API_URL, &params.address, params.page, params.offset, &params.sort
    );

    log::debug!("request: {}", &request_url);
    let response = Request::get(&request_url).send().await.unwrap();

    let result = if response.ok() {
        let body = response.text().await.unwrap();

        log::debug!("Response: {}", &body);

        let obj = serde_json::from_str::<c_models::AccountData>(&body).unwrap();
        Some(obj)
    } else {
        None
    };

    result
}

This get_account_data function takes a single parameter: the QueryParams object from our common library. This demonstrates the advantage of being able to write our backend and frontend both in Rust. Doing this allows us to make use of the same data structs in backend and frontend.

We return Option<c_models::AccountData>. Meaning, it can be a None return value as well.

On lines 26-27 we construct the request URL using the API_URL const and the value of the query parameters.

Then we use Request from the gloo_net crate to send a HTTP GET request.

Upon receiving the response and checking to see if it is ok()we finally deserialize the data to a AccountData struct and return it on line 40.

Updating the Ethereum transactions viewer app UI

Now that we can get this account data from the backend we should update our UI to let a user enter an address and then view the data in a table. We should also call our new service function get_account_data at the appropriate time. A lot of pieces need to be updated, first let’s look at the whole of the updated main.rs and then go through it in parts. Here is the full code listing for main.rs:

use common::models as c_models;
use web_sys::HtmlInputElement;
use yew::prelude::*;

mod service;

pub enum Msg {
    GetData,
    ProcessData(Option<c_models::AccountData>),
}

struct App {
    input_ref: NodeRef,
    received_data: Option<c_models::AccountData>,
}

impl Component for App {
    type Message = Msg;
    type Properties = ();

    fn create(_ctx: &Context<Self>) -> Self {
        Self {
            input_ref: NodeRef::default(),
            received_data: None,
        }
    }

    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
        match msg {
            Msg::GetData => {
                log::debug!("Get data");
                let input = self.input_ref.cast::<HtmlInputElement>().unwrap();
                let address = if input.value().trim().is_empty() {
                    "".to_string()
                } else {
                    input.value().trim().to_string()
                };

                input.set_value("");
                let params = c_models::QueryParams {
                    address: address,
                    page: 1,
                    offset: 1000,
                    sort: "asc".to_string(),
                };

                ctx.link().send_future(async move {
                    let result = service::get_account_data(&params).await;

                    Msg::ProcessData(result)
                });
            }
            Msg::ProcessData(data) => {
                log::debug!("Received data: {:?}", data);
                self.received_data = data;
            }
        }
        true
    }

    fn view(&self, ctx: &Context<Self>) -> Html {
        let link = ctx.link();
        let on_click = link.callback(|_e: MouseEvent| {
            log::debug!("Clicked");
            Msg::GetData
        });

        html! {
            <div>
            <h1>{"Ethereum transactions viewer frontend"}</h1>
            <div>
                    <input style="width:350px;" type="text" ref={self.input_ref.clone()} />
                    <button onclick={on_click}>{"Get Account Data"}</button>
                </div>
                <div>
                { match self.received_data.clone() {
                    Some(val) => {
                        show_account(&val)
                    },
                    _ => html! {
                        <div>
                            {"Please enter an address"}
                        </div>
                    }
                }}
                </div>
            </div>
        }
    }
}

fn show_account(account: &c_models::AccountData) -> Html {
    let normal_tx = account.normal_transactions.clone();
    let tx_html = normal_tx.into_iter().map(|tx| {
        html! {
            <tr key={tx.hash}>
                <td>{tx.from}</td>
                <td>{tx.value}</td>
                <td>{tx.gas_price}</td>
                <td>{tx.contract_address}</td>
                <td>{tx.function_name}</td>
            </tr>
        }
    });

    html! {
        <div>
            <div>
            { format!("Address: {}, Balance: {}, Normal tx: {}",
            account.address, account.balance, account.normal_transactions.len()
            ) }
            </div>
            <div>
                <table>
                    <thead>
                        <tr>
                        <th>{"From"}</th>
                        <th>{"Value"}</th>
                        <th>{"Gas Price"}</th>
                        <th>{"Contract"}</th>
                        <th>{"Function"}</th>
                        </tr>
                    </thead>
                    <tbody>
                        {for tx_html}
                    </tbody>
                </table>
            </div>
        </div>

    }
}

fn main() {
    wasm_logger::init(wasm_logger::Config::default());
    yew::start_app::<App>();
}

Enum and App component fields update

First, we update some the Msg enum and the fields that are on our App struct:

use common::models as c_models;
use web_sys::HtmlInputElement;
use yew::prelude::*;

mod service;

pub enum Msg {
    GetData,
    ProcessData(Option<c_models::AccountData>),
}

struct App {
    input_ref: NodeRef,
    received_data: Option<c_models::AccountData>,
}

impl Component for App {
    type Message = Msg;
    type Properties = ();

    fn create(_ctx: &Context<Self>) -> Self {
        Self {
            input_ref: NodeRef::default(),
            received_data: None,
        }
    }

On line 9 we change the enum value ProcessData to take an AccountData object instead of a String.

Then on lines 12-15, we update the App struct to change the received_data field’s type to AccountData. Also we add a field input_ref which represents a reference to a UI element. This field will refer to the input element we will put in our HTML later. Doing this allows us to access this element in our code to get the value, and also change it if we want to.

The input_ref field needs to be initialized in the create function in the impl Component for App block. We do this on line 23.

Update the update function

Next, we’ll look at the update function. This is where we call our function to retrieve data and also handle the response data:

    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
        match msg {
            Msg::GetData => {
                log::debug!("Get data");
                let input = self.input_ref.cast::<HtmlInputElement>().unwrap();
                let address = if input.value().trim().is_empty() {
                    "".to_string()
                } else {
                    input.value().trim().to_string()
                };

                input.set_value("");
                let params = c_models::QueryParams {
                    address: address,
                    page: 1,
                    offset: 1000,
                    sort: "asc".to_string(),
                };

                ctx.link().send_future(async move {
                    let result = service::get_account_data(&params).await;

                    Msg::ProcessData(result)
                });
            }
            Msg::ProcessData(data) => {
                log::debug!("Received data: {:?}", data);
                self.received_data = data;
            }
        }
        true
    }

First we are retrieving the value from the HTML input element on lines 32-37. We cast the value of self.input_ref to a HtmlInputElement. We don’t do any error checking here. But we do check if the value is empty or not.

Then we clear the value of the input element on line 39 so the user can easily enter a new value without having to clear the box themselves first.

On lines 40-45 we create an instance of a QueryParams object and set some default values and the address value from the input element.

Finally, we have updated the function called in send_future from get_data to get_account_data passing in the QueryParams object as a reference on line 48.

The ProcessData arm of the match does not need to be updated.

View function update

In the view function, we update our HTML to include an input element. Also we want to have a table that can display our results dynamically (grows/shrinks depending on rows that come back). We construct the table in a separate function that we will look at after looking at the view function:

    fn view(&self, ctx: &Context<Self>) -> Html {
        let link = ctx.link();
        let on_click = link.callback(|_e: MouseEvent| {
            log::debug!("Clicked");
            Msg::GetData
        });

        html! {
            <div>
            <h1>{"Ethereum transactions viewer frontend"}</h1>
            <div>
                    <input style="width:350px;" type="text" ref={self.input_ref.clone()} />
                    <button onclick={on_click}>{"Get Account Data"}</button>
                </div>
                <div>
                { match self.received_data.clone() {
                    Some(val) => {
                        show_account(&val)
                    },
                    _ => html! {
                        <div>
                            {"Please enter an address"}
                        </div>
                    }
                }}
                </div>
            </div>
        }
    }

On line 72, we added an input element and set a ref value to the clone of self.input_ref. This allows us to access and manipulate this element in our Rust code.

Next on lines 76-85 we check to see if there is any data in self.received_data and call show_account (which we have not coded yet) if there is data to display a table. Otherwise we display "Please enter an address".

Constructing an HTML table dynamically in Rust

Let’s look at the final piece of code where we construct a table for viewing the transaction data and wallet balance. We’ll write a function that returns a Html type. Similarly, we could have written a new component for this, but to keep it simple for now let’s go with just a function:

fn show_account(account: &c_models::AccountData) -> Html {
    let normal_tx = account.normal_transactions.clone();
    let tx_html = normal_tx.into_iter().map(|tx| {
        html! {
            <tr key={tx.hash}>
                <td>{tx.from}</td>
                <td>{tx.value}</td>
                <td>{tx.gas_price}</td>
                <td>{tx.contract_address}</td>
                <td>{tx.function_name}</td>
            </tr>
        }
    });

    html! {
        <div>
            <div>
            { format!("Address: {}, Balance: {}, Normal tx: {}",
            account.address, account.balance, account.normal_transactions.len()
            ) }
            </div>
            <div>
                <table>
                    <thead>
                        <tr>
                        <th>{"From"}</th>
                        <th>{"Value"}</th>
                        <th>{"Gas Price"}</th>
                        <th>{"Contract"}</th>
                        <th>{"Function"}</th>
                        </tr>
                    </thead>
                    <tbody>
                        {for tx_html}
                    </tbody>
                </table>
            </div>
        </div>

    }
}

On lines 94-104 we iterate through the normal transactions data using into_iter and map. With map, we can build a new iterator and new values. In this case, we generate new HTML using html! and build the rows for our table.

The key on line 96 is where we should put a unique value associated with the data. This is used for tracking the data/elements through scrolling and updating the table.

Then on lines 106-129, we display the address, balance, and number of transactions and also build the table. We expand the rows using {for tx_html} on line 125.

Completed backend and frontend result

Now that we have finished the backend and frontend we can look at the end result. Let’s make sure both components are running:

  • backend: run the cargo run command from the backend directory.
  • frontend: trunk serve command from the frontend directory.

When we navigate to http://localhost:8080 in the browser we should see:

Let’s pick an address from: https://www.coincarp.com/currencies/ethereum/richlist/ so we can be sure we’ll see some transactions come back from the Etherscan API. For example, if we enter 0xdb3c617cdd2fbf0bb4309c325f47678e37f096d9:

Conclusion

In this tutorial, we created an Ethereum transactions viewer app that can search and display transaction data for a specific address. Our application is a full-stack Rust app, both backend and frontend written in Rust. Sharing common data model code between the components. We learned how to use Yew for the frontend and warp for the backend. Also, we learned how to communicate with our backend from the Yew frontend using gloo-net.

The completed project’s code can be found on my GitHub here.

Please follow me on Twitter to get notified of new Rust programming articles:

Leave a Reply

Your email address will not be published. Required fields are marked *