Solana transactions per second: how to with Rust

solana and rust

In this tutorial, we will learn how to count Solana transactions per second (TPS) using the Rust programming language. We will look at how to connect to the Solana blockchain API using the Solana SDK crates. Using this simple program we will learn some basics about using the Solana crates for Rust.

Our program will count non-vote transactions over the course of some time period and then calculate the transactions per second.

The repository with the whole project can be found here: https://github.com/tmsdev82/solana-count-tps

What is Solana?

Of course, most people will probably already know what Solana is before opening this tutorial. However, just in case:

Solana is a decentralized blockchain built to enable scalable, user-friendly apps for the world.

https://solana.com/

Solana is a blockchain technology that promises to be “low cost, forever” and “fast, forever”. In this tutorial, we will try to measure how fast Solana is.

Prerequisites

Having some Rust knowledge is recommended, before starting this tutorial.

Because we will connect and make requests to the remote procedure call (RPC) API, no Solana software needs to be installed. A wallet is also not necessary for this tutorial

Project setup

Let’s start by creating our project using cargo in the usual way: cargo new solana-count-tps

Then in Cargo.toml we’ll put the following dependencies:

Our dependencies are as follows:

  • solana-client: client crate which we will use to connect to an RPC server address.
  • solana-sdk: used to write client-side applications in Rust.
  • solana-transaction-status: defines solana status types.
  • chrono: Date and time library for Rust. We will use this to do time calculations.
  • log: A lightweight logging facade for Rust.
  • env_logger: A logging implementation for `log` which is configured via an environment variable.
  • dotenv: A dotenv implementation for Rust. We will use this to load a .env file with configuration for our application.
[package]
name = "solana-count-tps"
version = "0.1.0"
edition = "2021"

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

[dependencies]
solana-client = "1.14.6"
solana-sdk = "1.14.6"
solana-transaction-status = "1.14.6"
chrono = "0.4"
log = "0.4.17"
env_logger = "0.9.1"
dotenv ="0.15.0"

Let’s also add a file called .env to the root of our project to configure the log level of our logger. Add the following to .env: RUST_LOG=debug.

Connecting to a Solana RPC server

If we want to count the number of Solana transactions per second, we have to get information from blocks on the Solana blockchain.

We can get transaction data from a Solana Cluster RPC endpoint. If we look in the official Solana documentation we can find some there: https://docs.solana.com/cluster/rpc-endpoints. For our purposes we will use: https://solana-api.projectserum.com. Because this appears to not be rate limited (too much). Not being rate limited is helpful since we plan to calculate the average transactions per second over a period of time. That means we have to request information from a lot of blocks.

Get Solana core version

In this section, we will set up a simple initial project that gets the Solana version running on the RPC server.

Let’s add the following code to main.rs:

use dotenv::dotenv;
use solana_client::rpc_client::RpcClient;

fn main() {
    dotenv().ok();
    env_logger::init();

    log::info!("Solana count transactions per second!");

    let client = RpcClient::new("https://solana-api.projectserum.com");

    let solana_version = client.get_version().unwrap().solana_core;
    log::info!("Solana version: {}", &solana_version);
}

On lines 5 and 6 we initialize dotenv to read in the .env file and then initialize env_logger to configure the logger.

Next, on line 10 we create a new instance of the RpcClient and set the endpoint URL to: https://solana-api.projectserum.com.

Then we call the get_version() function which returns a ClientResult<RpcVersionInfo> which is a Result type. Therefore we call unwrap (not caring about handling the error for this project) to get the RpcVErsionInfo object and then get the value of the solana_core field.

Finally, we log this version to the terminal on line 13.

Result

When we run the program using cargo run --release, we should get some output in our terminal:

solana transactions per second project: example output of solana version.
output example

Get Solana blocks and transactions count

In this section, we will retrieve information about the latest block and the transactions it contains.

Get block function

Let’s start by writing a function that retrieves a block object based on a given block number in main.rs:

use dotenv::dotenv;
use solana_client::{rpc_client::RpcClient, rpc_config::RpcBlockConfig};
use solana_transaction_status::{EncodedConfirmedBlock, UiTransactionEncoding};

fn get_block(client: &RpcClient, block_num: u64) -> EncodedConfirmedBlock {
    log::debug!("Getting block number: {}", block_num);

    let config = RpcBlockConfig {
        encoding: Some(UiTransactionEncoding::Base64),
        max_supported_transaction_version: Some(0),
        ..Default::default()
    };
    
    let block = client.get_block_with_config(block_num, config);
    let encoded_block: EncodedConfirmedBlock = block.unwrap().into();

    encoded_block
}

On lines 2 and 3 we bring some new items into scope for our code here.

Then we have the definition of our function on line 5. It takes in a reference to a RpcClient and a u64 for block number.

On lines 8-12 we create a RpcBlockConfig object. The main points here are that we want the transaction encoding to be Base 64. Since base 64 encoded can hold account information of any supported size.

Then the maximum supported transaction version is set to be 0. Otherwise, we will run into errors with unsupported versions.

We then use this configuration object to get the block on line 14 with a call to client.get_block_with_config. This returns a Result<UiConfirmedBlock, ...> which we convert into a EncodedConfirmedBlock on line 15 since we put an encoding in the config (RpcBlockConfig).

Finally, we return the resulting block data.

Get the latest block number and block

Now let’s get the latest block number and use the function we wrote just now to get the block.

We’ll add the following code to fn main():

fn main() {
    dotenv().ok();
    env_logger::init();

    log::info!("Solana count transactions per second!");

    let client = RpcClient::new("https://solana-api.projectserum.com");

    let solana_version = client.get_version().unwrap().solana_core;
    log::info!("Solana version: {}", &solana_version);

    let latest_block_number = client.get_slot().unwrap();
    let block = get_block(&client, latest_block_number);
    log::info!("Transactions count: {}", block.transactions.len());
}

On line 31, get_slot() gives us the latest block number. We then use that number to call our get_block function on line 32.

Running the program now results in the following:

Terminal outputs: transactions count.
show transactions count

We have a count for transactions here, however, this is all transactions. Meaning, voting transactions are also counted. These are not relevant to how many user transactions the Solana blockchain can handle per second, however. So let’s write a function that determines what transactions to count and which ones to ignore.

Counting only “real” transactions

In this section, we will write a function that basically splits the number of transactions into vote transactions and user transactions.

To determine what kind of transaction took place our program can look at the program Ids in the instructions. If the program Id matches the Id for the vote program, then it is a vote instruction.

Then if all instructions use the vote program then it is a vote transaction that can be ignored by our counter.

Count Solana user transactions

Let’s add the following function to our code that implements this reasoning:

fn count_user_transactions(block: &EncodedConfirmedBlock) -> u64 {
    let mut user_transactions_count: u64 = 0;

    for transaction_status in &block.transactions {
        let transaction = transaction_status.transaction.decode().unwrap();
        let account_keys = transaction.message.static_account_keys();

        let mut num_vote_instructions = 0;
        for instruction in transaction.message.instructions() {
            let program_id_index = instruction.program_id_index;
            let program_id = account_keys[usize::from(program_id_index)];

            if program_id == solana_sdk::vote::program::id() {
                num_vote_instructions += 1;
                log::debug!("Found vote instruction");
            } else {
                log::debug!("non-vote instruction");
            }
        }
        if num_vote_instructions == transaction.message.instructions().len() {
            log::debug!("It's a vote transaction");
        } else {
            log::debug!("it's a user transaction");
            user_transactions_count += 1;
        }
    }

    let vote_transactions_count = block
        .transactions
        .len()
        .checked_sub(user_transactions_count as usize)
        .expect("underflow");

    log::debug!("solana total txs: {}", block.transactions.len());
    log::debug!("solana user txs: {}", user_transactions_count);
    log::debug!("solana vote txs: {}", vote_transactions_count);

    user_transactions_count
}

Our function takes a reference to a EncodedConfirmedBlock object and returns a u64: the user transactions count.

We start a loop through all the transactions on line 8. Then we have to decode this object on line 9. Which gets us a VersionedTransaction object. This object contains a message field that holds the instructions and static_account_keys. This static_account_keys array holds the program Ids (Program public keys) used in the transaction.

Next, we loop through the instructions for the transaction on line 13. To determine what program is used in the instruction we use instruction.program_id_index and index into account_keys to get the program id.

On line 17 we compare the id to the vote program id using vote::program::id() from the solana_sdk.

Then on lines 24-29, we check to see if the number of vote instructions is the same as the number of total instructions. If not then this is a user transaction.

Finally, for the sake of debugging we determine the number of total vote transactions on lines 32-36, by subtracting the total number of user transactions from the total number of transactions. We use a checked_sub in order to prevent overflow.

Our function only returns the number of user transactions, however.

Result

Now let’s call this count_user_transactions function to see what the output is:

fn main() {
    dotenv().ok();
    env_logger::init();

    log::info!("Solana count transactions per second!");

    let client = RpcClient::new("https://solana-api.projectserum.com");

    let solana_version = client.get_version().unwrap().solana_core;
    log::info!("Solana version: {}", &solana_version);

    let latest_block_number = client.get_slot().unwrap();
    let block = get_block(&client, latest_block_number);
    let user_transactions_count = count_user_transactions(&block);
}

The output should looks something like this:

User transactions vs vote transactions count.
user transactions vs. vote transactions console output

As this example shows, the number of vote transactions in a block can be quite high.

Calculating Solana transactions per second

Now that we have a function for counting the number of user transactions for the Solana blockchain, we can calculate how many per second are processed.

To do this we should average the number of transactions over a number of blocks or a period of time. Therefore we will start with the latest block and get the block before it and then the block before that one, etc. until we reach a certain threshold. That threshold will be a variable number of seconds in the past.

Function for calculating transactions per second

First, let’s write the function that calculates the transactions per second. This function calculates based on subtracting the oldest timestamp from the newest timestamp to get the “seconds that have passed” and then dividing the transaction count by that number:

fn calculate_tps(oldest_timestamp: i64, newest_timestamp: i64, transactions_count: u64) -> f64 {
    let total_seconds_diff = newest_timestamp.saturating_sub(oldest_timestamp);

    let total_seconds_diff_f64 = total_seconds_diff as f64;
    let transaction_count_f64 = transactions_count as f64;

    let mut transactions_per_second = transaction_count_f64 / total_seconds_diff_f64;

    if transactions_per_second.is_nan() || transactions_per_second.is_infinite() {
        transactions_per_second = 0.0;
    }

    transactions_per_second
}

We call saturating_sub to make sure we don’t cause an overflow when subtracting on line 6. Then we convert the relevant values to a float since we will do a division next.

Finally, we do a check on if the resulting transactions_per_second is a valid number or else set it to 0.0 on lines 13-15. Then return the value on line 17.

Loop through blocks counting transactions

Now we can write the function for looping through blocks, counting the total number of transactions. And then finally doing the transactions per second calculation:

use chrono::{DateTime, NaiveDateTime, Utc};
use dotenv::dotenv;
use solana_client::{rpc_client::RpcClient, rpc_config::RpcBlockConfig};
use solana_transaction_status::{EncodedConfirmedBlock, UiTransactionEncoding};

fn calculate_for_range(client: &RpcClient, threshold_seconds: i64) {
    let calculation_start = Utc::now();

    let newest_block_number = client.get_slot().unwrap();
    let mut current_block = get_block(client, newest_block_number);

    let newest_timestamp = current_block.block_time.unwrap();
    let timestamp_threshold = newest_timestamp.checked_sub(threshold_seconds).unwrap();

    let mut total_transactions_count: u64 = 0;

    let oldest_timestamp = loop {
        let prev_block_number = current_block.parent_slot;
        let prev_block = get_block(client, prev_block_number);

        let transactions_count = count_user_transactions(&current_block);
        let naive_datetime = NaiveDateTime::from_timestamp(current_block.block_time.unwrap(), 0);
        let utc_dt: DateTime<Utc> = DateTime::from_utc(naive_datetime, Utc);

        log::debug!("Block time: {}", utc_dt.format("%Y-%m-%d %H:%M:%S"));

        total_transactions_count = total_transactions_count
            .checked_add(transactions_count)
            .expect("overflow");

        let prev_block_timestamp = prev_block.block_time.unwrap();

        if prev_block_timestamp <= timestamp_threshold {
            break prev_block_timestamp;
        }

        if prev_block.block_height.unwrap() == 0 {
            break prev_block_timestamp;
        }

        current_block = prev_block;
    };

    let transactions_per_second =
        calculate_tps(oldest_timestamp, newest_timestamp, total_transactions_count);
    let calculation_end = Utc::now();

    let duration = calculation_end
        .signed_duration_since(calculation_start)
        .to_std()
        .unwrap();

    log::info!("calculation took: {} seconds", duration.as_secs());
    log::info!(
        "total transactions per second over period: {}",
        transactions_per_second
    );
}

This is a fairly long function so let’s look at it in parts.

Setup before the loop

First, we set up some variables and values before we enter the loop. Also, we bring some items from chrono into scope.

use chrono::{DateTime, NaiveDateTime, Utc};
use dotenv::dotenv;
use solana_client::{rpc_client::RpcClient, rpc_config::RpcBlockConfig};
use solana_transaction_status::{EncodedConfirmedBlock, UiTransactionEncoding};

fn calculate_for_range(client: &RpcClient, threshold_seconds: i64) {
    let calculation_start = Utc::now();

    let newest_block_number = client.get_slot().unwrap();
    let mut current_block = get_block(client, newest_block_number);

    let newest_timestamp = current_block.block_time.unwrap();
    let timestamp_threshold = newest_timestamp.checked_sub(threshold_seconds).unwrap();

    let mut total_transactions_count: u64 = 0;

We are going to time how long it takes to loop through the blocks and calculate the Solana blockchain’s transactions per second. So, get the current time as the start time on line 7.

Then we get the newest block, as we did earlier in the main() function on lines 9-10.

Next, we calculate the timestamp threshold on lines 12-13. When we reach a block that is equal to or older (less) than this threshold we break out of the loop.

We declare a mutable variable total_transactions_count to keep track of the total user transactions count.

The loop through blocks

Next, let’s look at the loop that goes through blocks from new to older:

    let oldest_timestamp = loop {
        let prev_block_number = current_block.parent_slot;
        let prev_block = get_block(client, prev_block_number);

        let transactions_count = count_user_transactions(&current_block);
        let naive_datetime = NaiveDateTime::from_timestamp(current_block.block_time.unwrap(), 0);
        let utc_dt: DateTime<Utc> = DateTime::from_utc(naive_datetime, Utc);

        log::debug!("Block time: {}", utc_dt.format("%Y-%m-%d %H:%M:%S"));

        total_transactions_count = total_transactions_count
            .checked_add(transactions_count)
            .expect("overflow");

        let prev_block_timestamp = prev_block.block_time.unwrap();

        if prev_block_timestamp <= timestamp_threshold {
            break prev_block_timestamp;
        }

        if prev_block.block_height.unwrap() == 0 {
            break prev_block_timestamp;
        }

        current_block = prev_block;
    };

On line 17 we declare that the loop will return the oldest timestamp as a value.

Then on lines 18-19 we get the previous block. We need this block to determine if we should stop the loop, or otherwise use it for calculations in the next turn of the loop. As seen on line 41 we assign current_block = prev_block.

On line 21 we count the user transactions for the current block (current_block) using the count_user_transactions function we wrote earlier.

For the sake of seeing what is going on while the program runs, we display a formated date time string for the current block on lines 22-25.

Then on lines 27-29 we add the block’s user transaction count to the total.

On lines 31-39 we check if the timestamp threshold has been reached or if we have reached the first block in the chain. If either of these conditions is true we break out of the loop and return the previous block’s time stamp.

Otherwise, we continue the loop by assigning prev_block to current_block.

Displaying Solana transactions per second

Finally, we call calculate_tps plugging in the gathered information as parameters and displaying the result as well as calculating how long the process took:

    let transactions_per_second =
        calculate_tps(oldest_timestamp, newest_timestamp, total_transactions_count);
    let calculation_end = Utc::now();

    let duration = calculation_end
        .signed_duration_since(calculation_start)
        .to_std()
        .unwrap();

    log::info!("calculation took: {} seconds", duration.as_secs());
    log::info!(
        "total transactions per second over period: {}",
        transactions_per_second
    );
}

The end result

Finally, all that is left for us is to call our calculate_for_range function in fn main():

fn main() {
    dotenv().ok();
    env_logger::init();

    log::info!("Solana count transactions per second!");

    let client = RpcClient::new("https://solana-api.projectserum.com");

    let solana_version = client.get_version().unwrap().solana_core;
    log::info!("Solana version: {}", &solana_version);

    calculate_for_range(&client, 60 * 5);
}

On line 141 we call the function and set the threshold to 5 minutes in the past. Depending on our connection and computer speed it can take a couple of minutes to get all the data.

The end result should look something like this:

Terminal showing solana transactions per second end result.
Solana transactions per seconds result

Conclusion

We learned how to calculate the transactions per second for Solana user transactions. Doing so we also learned some basics about using the Solana Rust libraries. Our simple project shows how to get transaction data, decode it, get program Ids for instructions etc. It is also a nice program to learn some basic Rust programming with.

The repository with the complete project can be found here: https://github.com/tmsdev82/solana-count-tps

For other blockchain-related tutorials look here:

Please follow me on Twitter to get updates on more cryptocurrency programming-related tutorials:

Leave a Reply

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