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
Contents
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:
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:
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:
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(¤t_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(¤t_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:
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:
- Ethereum transactions viewer app: Rust how to
- Build a crypto wallet using Rust: steps how to
- Rust Web3 connect to Ethereum blockchain: how to
Please follow me on Twitter to get updates on more cryptocurrency programming-related tutorials:
Follow @tmdev82