In this tutorial, we will learn the basics of Solana development by programming a software wallet with Rust. What is a cryptocurrency wallet? A cryptocurrency wallet’s primary function is storing keys that allow you to send and receive cryptocurrency, in this case, Solana. This wallet can be a device or software program. For this tutorial, we will write a Solana wallet with Rust that can generate keys and perform actions.
With our wallet application, we will be able to:
- airdrop Sol to our own wallet (on test/dev networks)
- get account balance from a wallet
- perform transactions, for example sending Sol from one of our wallets to another wallet address we also own
- Creating and minting NFTs
All this using Rust code. To help us easily connect to Solana RCP servers we will use Solana crates like: solana_client
and solana_sdk
.
Of course, there are a bunch of Solana wallets out there, but for learning purposes writing a wallet is a good place to start.
For this tutorial, we will be connecting to the Solana developer network. There we can use fake SOL as much as we want to try things out. So that we don’t lose any real money if something goes wrong.
The completed Solana wallet Rust project can be found on my Github: https://github.com/tmsdev82/solana-wallet-tutorial
I have another Rust tutorial on Solana-related programming: Solana transactions per second: how to with Rust.
Contents
Solana Core concepts
Before we start programming our Solana wallet with Rust, let’s talk about Solana itself. Because if we are just starting out it helps to familiarize ourselves with some basic concepts first. We’ll just go through a relatively short summary here. For more detailed information on these concepts please visit the Solana site with official documentation.
What is Solana
Solana is a blockchain technology that aims for cheap, reliable, and fast transactions. One of its main selling points is handling a high number of transactions per second. Significantly more than other blockchain technologies. They also want to keep transaction costs low. Furthermore, Solana supports smart contracts called “programs” to perform actions.
Solana uses a mechanism called proof-of-history to validate blocks and transactions.
Important terms
In this section, we will explain some important terms that we will encounter in this tutorial.
- Account: A record in the Solana ledger that either holds data or is an executable program.
- Keypair: A keypair represents a wallet. A wallet has a public key and a private key. The public key is used as the wallet address. The wallet’s private key is used to sign transactions. This private key allows us to transfer Solana from our wallet to a different one.
- Solana or SOL: the native token of the Solana blockchain. The ticker symbol is “SOL”.
- Lamports: a unit of token that is a fraction of SOL. Currency values on the Solana blockchain are generally expressed in lamports. Doing this avoids computing with floating point numbers.
- Block: A continuous set of entries on the ledger covered by a vote.
- Blockhash: identifier for a block.
- Instruction: the smallest unit of execution logic in a program. A client can include one or multiple instructions in a single transaction.
- Program: executable code that interprets the instructions sent inside of each transaction. Also known as smart contracts.
- Signature: A 64-byte ed25519 signature of R (32-bytes) and S (32-bytes). Each transaction must have at least one signature for theĀ fee account. This first signature can be treated as a transaction id.
For more terminology definitions see the official Solana docs.
Solana wallet Rust project set up
Now let’s begin setting up our project. We’ll create a new project using cargo: cargo new solana-wallet-tutorial
.
Then let’s add some dependencies to our project’s Cargo.toml
file:
- clap: A simple to use, efficient, and full-featured Command Line Argument Parser. This will allow us to set up commands to execute specific parts of our program.
- solana-sdk: Use the Solana SDK Crate to write client-side applications in Rust.
- solana-client: Client for connecting to a Solana RPC server.
- tiny-bip39: Library for BIP-39 Bitcoin mnemonic codes. Tiny-bip39 is a fork of the bip39 crate with fixes to v0.6. Rust implementation of BIP-0039.
- chrono: Date and time library for Rust. We will use this to convert timestamps into a human-readable format.
[package] name = "solana-wallet-tutorial" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] clap = {version = "4.0.26", features = ["derive"]} solana-sdk = "1.14.5" solana-client = "1.14.5" tiny-bip39 = "0.8.2" chrono = "0.4.22"
Note: we use version 0.8.2 for tin-bip39
here because the 1.0 version is incompatible with the Solana crates.
Get Solana cluster information command
In this section, we will implement our first command: a command for getting the information of the cluster we connect to. This will teach us how to connect to a Remote Procedure Call (RPC) server.
Set up CLI commands structure
Let’s start by defining a struct for our command line interface and an enum for the subcommands in main.rs
:
use clap::{Parser, Subcommand}; #[derive(Parser)] #[clap(author, version, about, long_about=None)] struct Cli { #[command(subcommand)] command: Option<Commands> } #[derive(Subcommand)] enum Commands { ClusterInfo } fn main() { let cli = Cli::parse(); match &cli.command { Some(Commands::ClusterInfo) => { println!("Get cluster info"); }, None => {} } }
First, we define a struct for our command line interface (CLI) on lines 3-8. Using the clap
macros we can define the properties of the CLI. Meaning, what kind of information is available to view about the program: author, version, etc.
All the parts of our program will be accessed through subcommands defined using the Subcommand
macro on line 10. For now, we have only one command defined ClusterInfo
but as our program grows we will add more to this Commands
enum.
The clap crate will convert this enum name to a kabab case command: cluster-info
which we will try out in a moment.
Next, in the main()
function we call the parse()
function on our Cli
struct which creates an instance that has parsed the user’s command input. To extract the command we use a match
statement on line 18.
When we can build and run the program using the following command cargo run --release -- cluster-info
.
After the build is finished we can also run the program again using the following command from the root directory of our project ./target/release/solana-wallet-tutorial cluster-info
.
Implement the cluster info command
Now, let’s implement the actual function for retrieving some cluster information and print it to the command prompt or terminal.
For this tutorial, we will get the cluster version, the latest block number, and the block time. We have to bring quite a lot of things into scope for this:
use chrono::{DateTime, NaiveDateTime, Utc}; use clap::{Parser, Subcommand}; use solana_client::rpc_client::RpcClient; use solana_sdk::{ account::from_account, clock::Clock, commitment_config::CommitmentConfig, sysvar, }; #[derive(Parser)] #[clap(author, version, about, long_about=None)] struct Cli { #[command(subcommand)] command: Option<Commands>, } #[derive(Subcommand)] enum Commands { ClusterInfo, } const SERVER_URL: &str = "https://api.devnet.solana.com"; fn get_cluster_info(client: &RpcClient) { let version = client.get_version().unwrap(); let result = client .get_account_with_commitment(&sysvar::clock::id(), CommitmentConfig::finalized()) .unwrap(); let (slot, timestamp) = match result.value { Some(clock_account) => { let clock: Clock = from_account(&clock_account).unwrap(); (result.context.slot, clock.unix_timestamp) } None => { panic!("Unexpected None"); } }; let datetime = DateTime::<Utc>::from_utc( NaiveDateTime::from_timestamp_opt(timestamp, 0).unwrap(), Utc, ); println!("Cluster version: {}", version.solana_core); println!( "Block: {}, Time: {}", slot, datetime.format("%Y-%m-%d %H:%M:%S") ); } fn main() { let cli = Cli::parse(); let client = RpcClient::new(SERVER_URL); match &cli.command { Some(Commands::ClusterInfo) => { println!("Get cluster info"); get_cluster_info(&client) } None => {} } }
Get cluster information function
Let’s look at the get_cluster_info
function we added:
fn get_cluster_info(client: &RpcClient) { let version = client.get_version().unwrap(); let result = client .get_account_with_commitment(&sysvar::clock::id(), CommitmentConfig::finalized()) .unwrap(); let (slot, timestamp) = match result.value { Some(clock_account) => { let clock: Clock = from_account(&clock_account).unwrap(); (result.context.slot, clock.unix_timestamp) } None => { panic!("Unexpected None"); } }; let datetime = DateTime::<Utc>::from_utc( NaiveDateTime::from_timestamp_opt(timestamp, 0).unwrap(), Utc, ); println!("Cluster version: {}", version.solana_core); println!( "Block: {}, Time: {}", slot, datetime.format("%Y-%m-%d %H:%M:%S") ); }
This function takes a reference to an RpcClient
object as a parameter. With this object, we can connect to an RPC server and perform actions.
Getting the version of Solana that the servers are running is easy using the get_version()
function on line 25. This function returns a RpcVersionInfo
object we will use at the end of the function to print the value of version.solana_core
.
To get the cluster date we are going to get data from the Clock program. So, we need to know the Id of the program to look it up. We can get this from sysvar::clock::id()
, as seen on line 25. We want to get the latest finalized run. So we call get_account_with_commitment
and set the commitment to CommitementConfig::finalized()
.
We then deserialize the resulting data to a sysvar or system account, in this case Clock
using from_account
on line 30. If we successfully deserialize we can get the UNIX timestamp and also get the slot number.
Then we convert this timestamp to a DateTime
object on lines 39-42 so we can present the date and time in a human-readable format at the end of the function.
Solana client connection and calling the get_cluster_info function
Finally, we will update main.rs
to make a connection to a RPC cluster and call our new get_cluster_info
function. We will connect to the devnet, which is a network for developers:
fn main() { let cli = Cli::parse(); let client = RpcClient::new(SERVER_URL); match &cli.command { Some(Commands::ClusterInfo) => { println!("Get cluster info"); get_cluster_info(&client) } None => {} } }
Here on line 54, we create a new RpcClient
instance using the SERVER_URL
const we defined earlier on line 20 of main.rs
: const SERVER_URL: &str = "https://api.devnet.solana.com";
Then finally we call our function get_cluster_info
passing in a reference to the client instance on line 59.
Running the program now with the command cargo run --release -- cluster-info
will result in the following:
Get Solana supply command
In this section, we will look at how to get the total and circulating supply of Solana. Again, this is a fairly simple thing to do thanks to the Solana crates. However, this is a nice opportunity to see how easy it is to convert lamport values to SOL.
First, we need to bring a function into the scope and add a new command:
use chrono::{DateTime, NaiveDateTime, Utc}; use clap::{Parser, Subcommand}; use solana_client::rpc_client::RpcClient; use solana_sdk::{ account::from_account, clock::Clock, commitment_config::CommitmentConfig, native_token::lamports_to_sol, sysvar, }; #[derive(Parser)] #[clap(author, version, about, long_about=None)] struct Cli { #[command(subcommand)] command: Option<Commands>, } #[derive(Subcommand)] enum Commands { ClusterInfo, Supply, }
The Solana SDK crate has a convenient function for Lamport to Sol conversions: lamports_to_sol
.
Implementing the get_supply function
Next, let’s implement the function for retrieving and printing the Solana supply information:
fn get_supply(client: &RpcClient) { let supply_response = client.supply().unwrap(); let supply = supply_response.value; println!( "Total supply: {} SOL\nCirculating: {} SOL\nNon-Circulating: {} SOL", lamports_to_sol(supply.total), lamports_to_sol(supply.circulating), lamports_to_sol(supply.non_circulating) ); }
As we can see getting the supply is easy by using the supply()
function which returns a Response<RpcSupply>
object. We get the RpcSupply
object from the value
field on line 55. Then we simply pass the relevant fields from that object (total
, circulating
, non_circulating
) to the lamports_to_sol
function to get the values in terms of SOL.
Update command processing for supply command
Now we just have to add the Supply
command to the match
expression in our main()
function:
fn main() { let cli = Cli::parse(); let client = RpcClient::new(SERVER_URL); match &cli.command { Some(Commands::ClusterInfo) => { println!("Get cluster info"); get_cluster_info(&client) } Some(Commands::Supply) => { println!("Get supply info"); get_supply(&client); } None => {} } }
Then we can run the program with the following command: cargo run --release -- supply
to try out the command. The output should look something like this:
Generate wallet keypairs command
In this section, we will make our Solana wallet Rust program an actual Solana wallet by generating keypairs and then using those in other sections for receiving SOL and transferring SOL to a different wallet.
We are going to store the keypairs in a JSON file which can be specified by the user. And we will also let users set a passphrase and the number of words for the recovery mnemonic. This recovery mnemonic can be used to generate the keypair again. For example, in case the keypair file is lost.
New scope items and define the key generation command
Again we need to bring new elements into the scope and add the command for generating keypairs.
use bip39::{Language, Mnemonic, MnemonicType, Seed}; use chrono::{DateTime, NaiveDateTime, Utc}; use clap::{Parser, Subcommand}; use solana_client::rpc_client::RpcClient; use solana_sdk::{ account::from_account, clock::Clock, commitment_config::CommitmentConfig, native_token::lamports_to_sol, signature::{keypair_from_seed, write_keypair_file}, signer::Signer, sysvar, }; #[derive(Parser)] #[clap(author, version, about, long_about=None)] struct Cli { #[command(subcommand)] command: Option<Commands>, } #[derive(Subcommand)] enum Commands { ClusterInfo, Supply, KeyGen { #[arg(short, long, help = "Output file path for keypair file.")] output: String, #[arg( short, long, default_value_t = 12, help = "How many words to generate for the mnemonic. Valid values are: 12, 15, 18, 21, and 24." )] mnemonic_word_count: u32, #[arg(short, long, help = "Passphrase to use for extra security.")] passphrase: Option<String>, }, }
We bring items from the tiny-bip39
crate into scope for the mnemonic and generating a seed for the keypairs based on that. Also a function from the Solana SDK for writing a Keypair
object to a file and generating keys from a seed.
The key generation command is defined on lines 26-38. As mentioned we have parameters for the output file path, how many words the mnemonic should be, and an optional passphrase for extra security.
We specify some additional information for the parameters of this command in the form of: #[arg(short, long, help = "")]
.
short
: enable shortened param name, it takes the first letter of the variable name. For example,output
becomes-o
in this command.long
: enabled long param name. For example,output
becomes--output
in this command. Andmnemonic_word_count
becomes--mnemonic-word-count
.help
: help text to display when user types--help
at the end of the command.
Implementing the generate_keypair function
Let’s look at the function for generating the keypair. We want to generate the keypair based on a number of words (minimum 12) so that we can recover our keys using that mnemonic in the future when needed.
So we first generate the mnemonic, then a seed based on that and a passphrase, and then generate the keys from that seed:
fn generate_keypair(output_path: &str, mnemonic_word_count: usize, passphrase: &Option<String>) { let mnemonic_type = MnemonicType::for_word_count(mnemonic_word_count).unwrap(); let mnemonic = Mnemonic::new(mnemonic_type, Language::English); let seed = match passphrase { Some(phrase) => Seed::new(&mnemonic, phrase), None => Seed::new(&mnemonic, ""), }; let keypair = keypair_from_seed(seed.as_bytes()).unwrap(); write_keypair_file(&keypair, output_path).unwrap(); println!("Mnemonic: {:?}", mnemonic); println!("Public key: {}", &keypair.pubkey()); }
First, we generate the mnemonic based on word count and a language on lines 85-86. Languages other than English are supported and we could make it an option for the KeyGen command. However, to keep things simple we hard-code English here.
Then on lines 88-91, we check to see if there is a passphrase or not. Depending on that we generate the seed with an empty passphrase or not.
Next, we call keypair_from_seed
and we have to pass in the seed as a byte array to get a Keypair
object.
Finally, we write the keypair to the specified file using write_keypair_file
to store the keypair for later use. Also, we print the mnemonic and public key on lines 96-97. Since the public key is also the wallet’s address.
Update command processing for KeyGen command
Now let’s add the command execution to our match
expression in main()
:
fn main() { let cli = Cli::parse(); let client = RpcClient::new(SERVER_URL); match &cli.command { Some(Commands::ClusterInfo) => { println!("Get cluster info"); get_cluster_info(&client) } Some(Commands::Supply) => { println!("Get supply info"); get_supply(&client); } Some(Commands::KeyGen { output, mnemonic_word_count, passphrase, }) => { println!("Generate keys, output to: {}", output); generate_keypair(output, *mnemonic_word_count as usize, passphrase); } None => {} } }
Nothing note worthy here, we just pass all parameters directly to generate_keypair
.
Now we can run the command using cargo run --release -- key-gen --output ./keypair.json --mnemonic-word-count 12
. In this case, we don’t specify a passphrase. Executing this command will put the keypair.json file in the root directory of our project.
The output should look something like this:
We will use this wallet to test the remaining commands we’ll be implementing.
Get Solana balance command
Let’s implement a command for checking the balance at an address. With this, we will be able to check the balance of our wallet. Which will be 0 for now, but we will airdrop SOL into it in the next section.
We should implement the command such that it can use either a wallet file or an address string to retrieve the balance for convenience’s sake.
Scopes and command updates
Let’s update the top of main.rs
:
use bip39::{Language, Mnemonic, MnemonicType, Seed}; use chrono::{DateTime, NaiveDateTime, Utc}; use clap::{Parser, Subcommand}; use solana_client::rpc_client::RpcClient; use solana_sdk::{ account::from_account, clock::Clock, commitment_config::CommitmentConfig, native_token::lamports_to_sol, pubkey::Pubkey, signature::{keypair_from_seed, read_keypair_file, write_keypair_file}, signer::Signer, sysvar, }; use std::str::FromStr;
Here we bring pubkey::Pubkey
and FromStr
into scope, so that we can get a Pubkey
object from a string.
Next, the command definition:
#[derive(Subcommand)] enum Commands { ClusterInfo, Supply, KeyGen { #[arg(short, long, help = "Output file path for keypair file.")] output: String, #[arg( short, long, default_value_t = 12, help = "How many words to generate for the mnemonic. Valid values are: 12, 15, 18, 21, and 24." )] mnemonic_word_count: u32, #[arg(short, long, help = "Passphrase to use for extra security.")] passphrase: Option<String>, }, Balance { #[arg(group = "input")] address: Option<String>, #[arg(long, group = "input")] wallet_file: Option<String>, }, }
Using #[arg(group = "input")]
we can indicate that one of the elements in the group should be entered as a parameter for the command. So either an address or a wallet file should be entered.
Implementing the get_balance function
This is another simple function, only a few lines of code:
fn get_balance(address: &str, client: &RpcClient) { let pubkey = Pubkey::from_str(address).unwrap(); let balance = client.get_balance(&pubkey).unwrap(); println!("Balance for {}: {}", address, lamports_to_sol(balance)); }
The get_balance
function takes in an address as string slice, and a reference to the RpcClient
object.
We convert the address
string slice into a Pubkey
object on line 109 and then get the balance for this address using the RPC client function get_balance
passing in a reference to the Pubkey
object.
Finally, we convert the lamports value into Sol and print the result on line 112.
Updating the command processing by adding Balance
Let’s add the Balance
command to the match
expression in main()
:
fn main() { let cli = Cli::parse(); let client = RpcClient::new(SERVER_URL); match &cli.command { Some(Commands::ClusterInfo) => { println!("Get cluster info"); get_cluster_info(&client) } Some(Commands::Supply) => { println!("Get supply info"); get_supply(&client); } Some(Commands::KeyGen { output, mnemonic_word_count, passphrase, }) => { println!("Generate keys, output to: {}", output); generate_keypair(output, *mnemonic_word_count as usize, passphrase); } Some(Commands::Balance { address, wallet_file, }) => { if let Some(address) = address { println!("Get balance for address: {}", address); get_balance(address, &client); } else if let Some(wallet_path) = wallet_file { println!("Get balance for Wallet file: {}", wallet_path); let keypair = read_keypair_file(wallet_path).unwrap(); get_balance(&keypair.pubkey().to_string(), &client); } } None => {} } }
Here we do a little bit more work than for the other commands. We first have to determine if the user put in an address or a wallet file location. If we got a wallet file location we read the file using read_keypair_file
, a function from the Solana SDK, and then pass the public key to the get_balance
function.
Now we can run the command cargo run --release -- balance --wallet-file "./keypair.json"
to check the balance of our newly created wallet:
As expected the balance is 0. Let’s try the command with a different wallet address. Since we already built the latest version of our program we can run it from the release directory:
./target/release/solana-wallet-tutorial balance "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"
Next, let’s do something about the 0 balance of our wallet.
Airdrop to a Solana wallet address
In this section, we will airdrop (fake) Sol into our Solana Rust wallet. Note that we can only do this on test and dev nets, etc. This is not possible on the main network where real tokens are used.
Bringing new items into scope
We have some new “uses” for converting Sol to Lamports and some things for printing to stdout and other things:
use bip39::{Language, Mnemonic, MnemonicType, Seed}; use chrono::{DateTime, NaiveDateTime, Utc}; use clap::{Parser, Subcommand}; use solana_client::rpc_client::RpcClient; use solana_sdk::{ account::from_account, clock::Clock, commitment_config::CommitmentConfig, native_token::{lamports_to_sol, sol_to_lamports}, pubkey::Pubkey, signature::{keypair_from_seed, read_keypair_file, write_keypair_file}, signer::Signer, sysvar, }; use std::{ io::{self, Write}, str::FromStr, }; use std::{thread, time};
Updating the commands
Let’s add the airdrop command to the Commands
enum:
#[derive(Subcommand)] enum Commands { ClusterInfo, Supply, KeyGen { #[arg(short, long, help = "Output file path for keypair file.")] output: String, #[arg( short, long, default_value_t = 12, help = "How many words to generate for the mnemonic. Valid values are: 12, 15, 18, 21, and 24." )] mnemonic_word_count: u32, #[arg(short, long, help = "Passphrase to use for extra security.")] passphrase: Option<String>, }, Balance { #[arg(group = "input")] address: Option<String>, #[arg(long, group = "input")] wallet_file: Option<String>, }, Airdrop { #[arg(short, long)] address: String, #[arg(short, long)] sol: f64, }, }
We just have the address and Sol amount as parameters for this command.
Implement the airdrop_sol function
Next, let’s implement the function for doing airdrops we’ll call it airdrop_sol
. It will take and address and float, the amount of Sol, as parameters:
fn airdrop_sol(address: &str, sol: f64, client: &RpcClient) { let lamports = sol_to_lamports(sol); let pubkey = Pubkey::from_str(address).unwrap(); let signature = client.request_airdrop(&pubkey, lamports).unwrap(); let wait_milis = time::Duration::from_millis(100); print!("Waiting to confirm"); io::stdout().flush().unwrap(); loop { if let Ok(confirmed) = client.confirm_transaction(&signature) { if confirmed { println!("\nAirdrop to {}: {}", address, confirmed); break; } } print!("."); io::stdout().flush().unwrap(); thread::sleep(wait_milis); } }
First, we convert the Sol value to Lamports because Solana programs on the blockchain use lamports for values.
Then we create a Pubkey
object from the address string parameter on line 123.
Next, we call request_airdrop on the client object. This will send the request but will not wait for confirmation. That is why we enter a loop on lines 128-139 to wait for the confirmation. In the meantime, to make the process of waiting visible we print “.” each round of the loop. To print output in real time we need to call io::stdout().flush()
.
Updating the command processing for airdrop
Finally, we have to update the match
block where the commands are processed in main()
:
fn main() { let cli = Cli::parse(); let client = RpcClient::new(SERVER_URL); match &cli.command { Some(Commands::ClusterInfo) => { println!("Get cluster info"); get_cluster_info(&client) } Some(Commands::Supply) => { println!("Get supply info"); get_supply(&client); } Some(Commands::KeyGen { output, mnemonic_word_count, passphrase, }) => { println!("Generate keys, output to: {}", output); generate_keypair(output, *mnemonic_word_count as usize, passphrase); } Some(Commands::Balance { address, wallet_file, }) => { if let Some(address) = address { println!("Get balance for address: {}", address); get_balance(address, &client); } else if let Some(wallet_path) = wallet_file { println!("Get balance for Wallet file: {}", wallet_path); let keypair = read_keypair_file(wallet_path).unwrap(); get_balance(&keypair.pubkey().to_string(), &client); } } Some(Commands::Airdrop { address, sol }) => { println!("Airdrop {} SOL to {}", sol, address); airdrop_sol(address, *sol, &client); } None => {} } }
Now we can run the command with our wallet’s address. Make sure you use your own value here but mine is:
cargo run --release -- airdrop -a GJWwf5cNWj4o6A6kvqqTSkr7nkb84w3tYQEMkiXcdBuZ -s 2
Because the airdrop is free there are limits to how often we can get some. Sometimes we will get an error. If we get an error we should just try and try again until the operation succeeds:
If the request succeeds the output looks something like this:
Transfer Solana from our Rust wallet to another wallet
In this final section, we will write functionality to transfer Sol from our Solana wallet to a different address with our Rust program.
To do a transfer we need to create an instruction for transfer and put it in a transaction. We then have to sign that transaction with our private key to confirm that the transaction is from us and that we are allowing it.
Bringing new items into scope
Again we have to bring new items into scope to complete our Solana wallet Rust program. This time, for creating instructions and transactions:
use bip39::{Language, Mnemonic, MnemonicType, Seed}; use chrono::{DateTime, NaiveDateTime, Utc}; use clap::{Parser, Subcommand}; use solana_client::rpc_client::RpcClient; use solana_sdk::{ account::from_account, clock::Clock, commitment_config::CommitmentConfig, native_token::{lamports_to_sol, sol_to_lamports}, pubkey::Pubkey, signature::{keypair_from_seed, read_keypair_file, write_keypair_file, Keypair}, signer::Signer, system_instruction, sysvar, transaction::Transaction, }; use std::{ io::{self, Write}, str::FromStr, }; use std::{thread, time};
Updating the commands to add Transfer
Next, we’ll add the Transfer command to our Commands enum:
#[derive(Subcommand)] enum Commands { ClusterInfo, Supply, KeyGen { #[arg(short, long, help = "Output file path for keypair file.")] output: String, #[arg( short, long, default_value_t = 12, help = "How many words to generate for the mnemonic. Valid values are: 12, 15, 18, 21, and 24." )] mnemonic_word_count: u32, #[arg(short, long, help = "Passphrase to use for extra security.")] passphrase: Option<String>, }, Balance { #[arg(group = "input")] address: Option<String>, #[arg(long, group = "input")] wallet_file: Option<String>, }, Airdrop { #[arg(short, long)] address: String, #[arg(short, long)] sol: f64, }, Transfer { #[arg(short, long)] from_wallet: String, #[arg(short, long)] to: String, #[arg(short, long)] sol: f64, }, }
We have a parameter for the wallet file location: from_wallet
and a recipient address: to
and then the amount to transfer: sol
.
Implementing the transfer_sol function
As mentioned at the start of this section we have to create a transaction with a transfer instruction, then sign that transaction with our private key before we can finally send it.
Here is the code:
fn transfer_sol(client: &RpcClient, keypair: &Keypair, to_key: &str, sol_amount: f64) { let to_pubkey = Pubkey::from_str(to_key).unwrap(); let lamports = sol_to_lamports(sol_amount); let transfer_instruction = system_instruction::transfer(&keypair.pubkey(), &to_pubkey, lamports); let latest_blockhash = client.get_latest_blockhash().unwrap(); let transaction = Transaction::new_signed_with_payer( &[transfer_instruction], Some(&keypair.pubkey()), &[keypair], latest_blockhash, ); let wait_milis = time::Duration::from_millis(100); print!("Waiting to confirm"); io::stdout().flush().unwrap(); match client.send_transaction(&transaction) { Ok(signature) => loop { if let Ok(confirmed) = client.confirm_transaction(&signature) { if confirmed { println!("\nTransfer of sol was confirmed"); break; } } print!("."); io::stdout().flush().unwrap(); thread::sleep(wait_milis); }, Err(e) => { println!("Error transferring sol: {}", e); } } }
First, we have to create a Pubkey
object from the string slice parameter to_key
on line 156. Then we convert the Sol float value to Lamports on the next line because the transfer instructions expect the value in Lamports.
Next, we create the transfer instruction using system_instruction::transfer
.
Then we create the transaction on lines 163-168. Because there are costs associated with transfer transactions we use Transaction::new_signed_with_payer
to create the transaction. And we are the ones that have to pay.
This Transaction::new_signed_with_payer
function takes an array of instructions, in our case we only have one. Then a public key, representing the transaction fee payer’s address. And the keypair and the latest block hash are required for signing the transaction.
Next, we send the transaction on line 174. We then enter a loop on line 175 to wait for the transaction to be confirmed, like we did for the airdrop call as well.
The client.confirm_transaction
function will return Ok
with a boolean value when the transaction is confirmed. While waiting we print some text to the terminal to show activity to the users indicating that the program is still working. Otherwise, it would look like the program is stuck for a while doing nothing.
Transferring Sol from one wallet to another
Let’s test our new transfer command. The easiest way to do this is to create another wallet next to the one we already have and then transfer Sol between those wallets.
So, we’ll create another wallet using the following command: ./target/release/solana-wallet-tutorial key-gen -o ./wallet2.json -m 12
.
The output should look something like this:
This will create another JSON file in the root directory and display the new public key. We need this public key for the following commands.
Now let’s check the balance real quick: ./target/release/solana-wallet-tutorial balance "2bfDxD1h9jpaep9zgaHtZauv1EBmqoq6UVsFbWFYExCr"
Now we’ll transfer Sol from our first wallet to this second one. But first, let’s make sure the program is built with the latest code: cargo build --release
.
Then let’s run the command: ./target/release/solana-wallet-tutorial transfer --from-wallet ./keypair.json --to 2bfDxD1h9jpaep9zgaHtZauv1EBmqoq6UVsFbWFYExCr --sol 0.05
If we check our wallet2 address now we should see a balance of 0.05 Sol:
./target/release/solana-wallet-tutorial balance "2bfDxD1h9jpaep9zgaHtZauv1EBmqoq6UVsFbWFYExCr"
Let’s also look at the balance of our first wallet. We should see that balance has been reduced a little more than 0.05 since we had to pay transaction costs:
./target/release/solana-wallet-tutorial balance --wallet-file ./keypair.json
Conclusion
In this tutorial, we learned some basics of interacting with the Solana blockchain by building a Solana wallet Rust program. We learned a bit about transactions, instructions, getting cluster information, airdropping Sol, and getting the balance from a specified Solana address.
So now we can create our own wallet and perform transfer transactions.
The completed Solana wallet Rust project can be found on my Github: https://github.com/tmsdev82/solana-wallet-tutorial
If you enjoyed this tutorial, also check out my other Solana-related tutorial: Solana transactions per second: how to with Rust.
Please follow me on Twitter to get updates on more cryptocurrency programming-related tutorials:
Follow @tmdev82