Solana, Rust basics and Reverts
Brief Intro and Background #
Edit: Rust is still a focus, but more for tooling. Learning the basics via a ’newer’ stack was good fun. Now I plan to just use the language in security and my own way.
I enjoy writing posts and notes alongside learning. I’ve done this in the past in the forms of blogs, twitter threads, github readmes (etc). They have mostly leaned into the security/hacking side of things, but now a new one has come to mind.
A few years ago I started learning some Rust. My progress was slow, work was busy. I didn’t get very far, but I learnt enough to write a few little tools that were both handy (and pretty speedy). For anyone that knows me, they will also know that I am a pretty big fan of cryptocurrencies. Since 2017 I’ve dabbled, messed with developing/coding smart contracts across a wide range of EVM based chains. I’ve seen tokens rise, fall, turned into big scams, have teams abandom and communities take over (it’s all a fun part of the journey). During my time, I spent a lot of hours writing Solidity. Optimising contracts, finding bugs, helping others work on their pet projects, building little tools. I loved it, the language was great (basically JS) and it forced me to learn a whole new programming style (decentralized state machine stuff). There were lots of challenges I’d never come across and it was great that I couldn’t just Google everything… honestly some of the most fun I’ve ever had with tech.
One chain/stack I haven’t really ever looked into and heavily focused on is Solana. Yes, I’ve held some coins here and there and messed with some on-chain games and NFTs across Solana, but I’ve never done any development there. So here’s my chance. I’m going to learn Solana (internals and program/contract development), with a focus on Rust. The plan is to document things I do/learn in note format and write ups. Whatever feels like value for readers and myself to be able to refer back to in the future.
I don’t really have any goals other than learning something new and enjoying the journey.
First Steps, Installing Everything #
Install rust:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Install yarn (assums NodeJS exists, if not, install NodeJS - I already had it from other projects)
corepack enable
Download the latest version of Solana
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
Download and set up Anchor
cargo install --git https://github.com/coral-xyz/anchor avm --locked --force
avm install latest
avm use latest
Create our first ‘workspace’ or project directory:
anchor init first_project
cd first_project
anchor build
This is where my first problem/error came that I needed to do a little digging into.
Error:
error: package `toml_edit v0.21.1` cannot be built because it requires rustc 1.69 or newer, while the currently active rustc version is 1.68.0-dev
Either upgrade to rustc 1.69 or newer, or use
cargo update -p [email protected] --precise ver
where `ver` is the latest version of `toml_edit` supporting rustc 1.68.0-dev
gary@pc first_project % rustc --version
rustc 1.78.0 (9b00956e5 2024-04-29)
gary@pc first_project % solana-install update
Install is up to date. d0ed878 is the latest commit for stable
gary@pc first_project % solana --version
solana-cli 1.17.25 (src:d0ed878d; feat:3580551090, client:SolanaLabs)
My current lack of knowledge didn’t make this make sense. But it turned out all I needed to do was look up the latest version of Solana and set that up with the CLI tool. I referenced https://docs.solanalabs.com/cli/install and their current version numbers, so assuming they update their docs this is a safe start.
gary@pc first_project % solana-install init 1.18.14
✨ 1.18.14 initialized
gary@pc first_project % solana --version
solana-cli 1.18.14 (src:d8e08244; feat:4215500110, client:SolanaLabs)
After this, I could attempt to build again and all was green!
Running Solana Locally #
Shell 1: #
Setup our localhost as our chain:
solana config set --url localhost
Shell 2: #
Run our test validator in a seperate shell:
solana-test-validator
Shell 1: #
Sync keys (may already be synced):
anchor keys sync
Generate our new ‘wallet’ (store the output):
solana-keygen new -o /Users/gary/.config/solana/id.json
Example output:
Wrote new keypair to /Users/gary/.config/solana/id.json
==========================================================================================
pubkey: 3LhfX54dPc7KV6vbGxFKqc7uy3AxynWFFhXXxxXXxxXX
==========================================================================================
Save this seed phrase and your BIP39 passphrase to recover your new keypair:
xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx
==========================================================================================
Using our newly generated wallet address, airdrop 100 SOL into the account for all on-chain activities and gas
solana airdrop 100 3LhfX54dPc7KV6vbGxFKqc7uy3AxynWFFhXXxxXXxxXX
Expected output(ish):
Requesting airdrop of 100 SOL
Signature: 54B1191HMeWX1y237xLnRkCkEMLWtWNWvwZZ7fWkrh3H86Typz7RRR1HRAxEgfjFnmXBNQEtZptd8dTmCpzcy4u6
100 SOL
Finally, we can run our anchor test for our first project!
anchor test --skip-local-validator
Ultimately, we are looking for passing test unit cases (shown via a green tick). This process appears to do a collection of the following all in one command:
- compile and build the program for the Solana chain
- sign all transactions with the local Solana wallet we funded with 100 SOL
- execute all ’test’ functions within the project directory test file
- output transactions made, their hashes and their status
first_project
Your transaction signature tqdAD8wRYD84AQFNenrTC5hhMAWz5GYEZ1Yf2aScN7MBaY6nBgtHgCVziMR59vEPvqU6efkw3sw4g7wcNLTfKQZ
✔ Is initialized! (241ms)
1 passing (243ms)
✨ Done in 1.61s.
By running the following command, we can see the balance of the wallet after we performed out unit tests:
gary@pc first_project % solana balance 3LhfX54dPc7KV6vbGxFKqc7uy3AxynWFFhAkGy84pEXM
98.70721636 SOL
So our compile/deploy and test cost us (100-98.70721636) SOL, which equates too: 1.29278364 SOL As of right now (25th May 2024, 16:02), if this was the exact amount on the Solana mainnet - we would have paid:
$168.47 * 1.29278364 ~= $217.80 (ref: https://coinmarketcap.com/currencies/solana/)
First Edits #
The program that is generated by Anchor above is very simple. It contains a single publc function and simply runs a Rust function called Ok(())
. This function (initialize) is what is called by the test file, so we know we can add some simple edit here and it will automatically be used in our test. Let’s do that…
The Program can be seen in the “programs/first_project/src/lib.rs” file:
#[program]
pub mod first_project {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
}
As this doesn’t do anything, let’s use a Rust macro (msg!("")
) to print a string… ultimately, we make our own version of Hello, World but on-chain!
#[program]
pub mod first_project {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
msg!("Hey, from Solana program!"); # added
Ok(())
}
}
Rerunning the anchor test command from previous steps, I was hit by this error…
Error: Deploying program failed: RPC response error -32002: Transaction simulation failed: Error processing Instruction 0: account data too small for instruction [3 log messages]
There was a problem deploying: Output { status: ExitStatus(unix_wait_status(256)), stdout: "", stderr: "" }.
Doing some info checks on the ‘go to’ for programmers, I found https://stackoverflow.com/questions/71267943/solana-deploy-account-data-too-small-for-instruction
Specifically, this answer:
“When you deploy a program on Solana, the amount of space allocated for that program is 2x the original program size.
This is to ensure there is a good amount of space if you upgrade the program, up to 2x the original program size.
The program that you are deploying is exceeding this limit. You will have to get a new programId and deploy again.”
A little further down on this question/answer post was a perfect breakdown of exactly what to do. This was:
- Delete the /target directory
- Run
anchor build
with the new code changes - List the keys with
anchor keys list
- Copy/Paste the correcy Program ID into the src code, for me it was:
declare_id!("81dTrbBYZPAwWkv426ENxENVaWiUpFBfv8Dxxxxxxxx");
- Re-run
anchor build
(may be able to skip this step as test could include a build?) - Finally, run the test
anchor test --skip-local-validator
And we have a passing program with output. To see the output, see the log file (.anchor/program-logs/xxxx.log). For me, I could just cat the content of the following:
gary@pc first_project % cat .anchor/program-logs/81dTrbBYZPAwWkv426ENxENVaWiUpFBfv8Dxxxxxxxx.first_project.log
Streaming transaction logs mentioning 81dTrbBYZPAwWkv426ENxENVaWiUpFBfv8Dxxxxxxxx. Confirmed commitment
Transaction executed in slot 5818:
Signature: SWgVaRzbtEHrYcXT3MunxHMa7c4saxP3TS7WnMLjPAAJXw719B3VXS1HW24JE4bSfspUvpFEUf3R92D8wA46WQc
Status: Ok
Log Messages:
Program 81dTrbBYZPAwWkv426ENxENVaWiUpFBfv8Dxxxxxxxx invoke [1]
Program log: Instruction: Initialize
Program log: Hey, from Solana program! <----- our print line is here!
Program 81dTrbBYZPAwWkv426ENxENVaWiUpFBfv8Dxxxxxxxx consumed 324 of 200000 compute units
Program 81dTrbBYZPAwWkv426ENxENVaWiUpFBfv8Dxxxxxxxx success
Logs can also be seen at runtime/realtime by opening a new shell (shell #3) and running solana logs
. Be warned, this gets very noisey - fast!
Just the last three logs alone took up a fair amount of room, but you can see the bottom one was my output from above:
<snipped>
Transaction executed in slot 6437:
Signature: 4LDbxddVYg7mz88nJ9RCNfKphxaacVEbNrDgJPt7uzZaPMvqGfUPbn489fgUvF6MUmMsx9FhbYY76aGz4Fcgq1Ka
Status: Ok
Log Messages:
Program BPFLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program BPFLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 6440:
Signature: 587CtfWykqAEzydRKbKWHrKTKYJrCbDrQaQ4YreDpJLMRQPo6h3N5TWUrGEhgR9UR1gbo8a28tKfDkqtZfkTNyXE
Status: Ok
Log Messages:
Program BPFLoaderUpgradeab1e11111111111111111111111 invoke [1]
Upgraded program 81dTrbBYZPAwWkv426ENxENVaWiUpFBfv8Dxxxxxxxx
Program BPFLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 6442:
Signature: 456QXGBfRmqs7dN1tiCmfSWBrSUT7fE19EuL6viftoqn9dwhwC6WQr7xSFz4wmcc76qZiXQUMqyNWKyLzteB9c4n
Status: Ok
Log Messages:
Program 81dTrbBYZPAwWkv426ENxENVaWiUpFBfv8Dxxxxxxxx invoke [1]
Program log: Instruction: Initialize
Program log: Hey, from Solana program! <----- our print line is here!
Program 81dTrbBYZPAwWkv426ENxENVaWiUpFBfv8Dxxxxxxxx consumed 324 of 200000 compute units
Program 81dTrbBYZPAwWkv426ENxENVaWiUpFBfv8Dxxxxxxxx success
Note: through this I have redacted and stripped a few keys/IDs and my pass phrase. Although I am running on a localhost validator and my wallet is purely testnet and NOT real, I’ve always found it best practice to nullify or remove anything that would be sensitive in prod/mainnet. When working on EVM projects, I came across lots of devs that just didn’t seem to care and would post keys in screenshots, leave things in code. This happens daily in big orgs too… but for what it’s worth, don’t do this 🙃
Post 2 #
Convert a simple Solidity (EVM) smart contract that performs some simple math on-chain and returns the answer the caller. The exposed function will allow a user to send values to calculate the result. The answer will be emitted from the chain allowing the caller to pick up the result (transaction data/receipt, subgraphs, etc).
This should help understand the following: - Solidity to Solana comparisons (code/functions/operators/etc) - Use of basic types such as numbers - Designing and ensuring the functions are public, accept the correct parameters and perform the right math - Basic types within Rust that are accepted and usable on Solana - Emitting data from the chain to allow frontends/apps/explorers to track the data without having to query everything
Solidity Contract #
I don’t want to go into the following too much as this is out of scope, but below is a simple Solidity smart contract that simply performs some basic math on 2 values and returns the result to the caller.
contract BasicMath {
event Result(uint256);
function doMath(uint256 _a, uint256 _b) public {
uint256 result = _a + _b;
emit Result(result);
}
}
Very simple contract. One exposed ‘public’ function, takes two numbers, adds them together and emits the data from chain. Note, this function doesn’t return the value.
Moving Into Solana #
Similar to the previous post, we want to create a new workspace/project using anchor and make sure we have the local instance/validator working and our initial build and test performs as expected:
Shell 1: #
solana-test-validator
Shell 2: #
anchor init basic_math # initialise project
cd basic_math # change dir
anchor build # perform the compile/build with anchor
solana config set --url localhost # ensure Solana is configured to use local validator
anchor keys sync # make sure our keys are synced to the local validator
anchor test --skip-local-validator # run the tests
Shell 3 (optional): #
solana logs
Note: if there are issues with wallets and Solana amounts for deploying/gas, refer to the previous post here.
Good news, we can start customising this program to match the Solidity contract above!
basic_math
Your transaction signature 3HK36niBJxAkTiA2bNV7hLgygjwP21kJYvc5265vaxNc4ztuZfk9eZeMX2LbiigBrkDCDA9aQSm4BiquZys1UFeo
✔ Is initialized! (315ms)
1 passing (318ms)
✨ Done in 1.70s.
Editing the Program #
So the changes are pretty simple, the main thing that threw me off was not knowing the specific types (and forgetting how to use them) in Rust. We can use a u64 for the two numbers that are used as parameters. We add them like this:
pub fn initialize(
ctx: Context<Initialize>,
a: u64, // first number
b: u64 // second number
) -> Result<()> {
// return
Ok(())
}
Now we want to perform some math on them within the initialize function, using the those specific parameter values giving the caller the control of the required arithmetic.
pub fn initialize(
ctx: Context<Initialize>,
a: u64, // first number
b: u64 // second number
) -> Result<()> {
// add the two numbers together
let result: u64 = a + b;
// return
Ok(())
}
Right now, we add the two numbers and store them into a new u64 variable called ‘result’. At this point, nothing is saved or stored within the blockchain program, and the value will go after we leave the function scope.
Finally, to match (sort of) the ’event emit’ within the Solidity contract, we can use the msg! macro again to print the data we want. At this point I’m not entirely sure if this is exactly the same as the emit function, but based on the logs we can see in from the program, the transaction seems to output this data which, if possible, it may be able to be picked up by other tools such as frontend apps, blockchain explorers (etc).
These msg! macros look similar to the following, giving our user the information they may want to know:
pub fn initialize(
ctx: Context<Initialize>,
a: u64, // first number
b: u64 // second number
) -> Result<()> {
// output the supplied numbers to the user for 'confirmation'
msg!("Caller sent {} and {}", a, b);
// add the two numbers together
let result: u64 = a + b;
// output the final result
msg!("Final result: {}", result);
// return
Ok(())
}
If we refer back to the previous post, when we attempt to test we will see an error and have to update the program ID with the following steps:
- Delete the /target directory
- Run
anchor build
with the new code changes - List the keys with
anchor keys list
- Copy/Paste the correcy Program ID into the src code, for me it was:
declare_id!("DRDDrC7F994e7JsCcrmvws2avGaCC9w58F6bxxxxxxxx");
- Re-run
anchor build
- Finally, run the test
anchor test --skip-local-validator
Looking at the test output, we see passing cases:
basic_math
Your transaction signature 239iHnNhEbg4Epa668YcfVjgVizHwqtLXh7Mk8MGMNwNvcotL3ny7tFoZzhq3cEHMiEhQXyJ1DYnZDaf77CN8c2M
✔ Is initialized! (458ms)
1 passing (462ms)
✨ Done in 1.45s.
And reading checking the ’logging’ shell, we see our output is working nicely too:
Transaction executed in slot 21441:
Signature: 239iHnNhEbg4Epa668YcfVjgVizHwqtLXh7Mk8MGMNwNvcotL3ny7tFoZzhq3cEHMiEhQXyJ1DYnZDaf77CN8c2M
Status: Ok
Log Messages:
Program DRDDrC7F994e7JsCcrmvws2avGaCC9w58F6bxxxxxxxx invoke [1]
Program log: Instruction: Initialize
Program log: Caller sent 2337 and 1000 <--- first msg! output
Program log: Final result: 3337 <--- second msg! output, and the result of our math
Program DRDDrC7F994e7JsCcrmvws2avGaCC9w58F6bxxxxxxxx consumed 1494 of 200000 compute units
Program DRDDrC7F994e7JsCcrmvws2avGaCC9w58F6bxxxxxxxx success
Extending and Adding More Math #
The initial goal was to just port the Solidity contract into a Solana program. This was done with relative ease, and nothing really blocked the progress. With that said, let’s extend the functionality of the program to have the following:
- 4 functions that takes 2 numbers and an operator for each simple math functionality (+, -, /, *)
- 1 function that takes 2 numbers and a ’type’ value. From this type value we determine which operator to use
- Extend the test TypeScript tests to cater for these new functions
After some trial and error, and some silly debugging problems (compiler complaints), I came up with the following program:
use anchor_lang::prelude::*;
declare_id!("56XKRhMQTvi9GPMKpJQ8XJBWyiVLzbCtZPDAxxxxxxxx");
#[program]
pub mod basic_math {
use super::*;
pub fn initialize(
ctx: Context<Initialize>
) -> Result<()> {
Ok(())
}
pub fn add(
_ctx: Context<Initialize>,
a: u64, // first number
b: u64 // second number
) -> Result<()> {
let result: u64 = a + b;
// output the final result
msg!("Final result: {}", result);
Ok(())
}
pub fn subtract(
_ctx: Context<Initialize>,
a: u64, // first number
b: u64 // second number
) -> Result<()> {
if a < b {
return err!(MyError::SubtractionError);
}
let result: u64 = a - b;
// output the final result
msg!("Final result: {}", result);
Ok(())
}
pub fn multiply(
_ctx: Context<Initialize>,
a: u64, // first number
b: u64 // second number
) -> Result<()> {
let result: u64 = a * b;
// output the final result
msg!("Final result: {}", result);
Ok(())
}
pub fn divide(
_ctx: Context<Initialize>,
a: u64, // first number
b: u64 // second number
) -> Result<()> {
if b == 0 {
return err!(MyError::DivideByZero);
}
let result: u64 = a / b;
// output the final result
msg!("Final result: {}", result);
Ok(())
}
pub fn calculate(
_ctx: Context<Initialize>,
a: u64,
b: u64,
op: u8
) -> Result<()> {
// perform the correct operation based on the op param
if op == 1 {
return add(_ctx, a, b);
} else if op == 2 {
return subtract(_ctx, a, b);
} else if op == 3 {
return multiply(_ctx, a, b);
} else if op == 4 {
return divide(_ctx, a, b);
} else {
return err!(MyError::IncorrectOperator);
}
}
}
#[derive(Accounts)]
pub struct Initialize {}
#[error_code]
pub enum MyError {
#[msg("Illegal operator type selected")]
IncorrectOperator,
#[msg("Subtract error: A is smaller than B")]
SubtractionError,
#[msg("Division by Zero")]
DivideByZero
}
To break it down a little, I followed the plan and implemented the 4 separate functions (all public) for each operator. Their logic is nice and straight forward and not too much to explain.
Following this, a calculate()
function was written that takes a integer value as a parameter. This parameter is checked within the function body and performs the correct math function based on the choice. It’s ultimately just a wrapper around the other 4 public functions.
A keen eye will also see that I have added some if statements to perform some simple logic checks, which if they fail, then custom errors are thrown/returned from the program. I won’t be going too into the errors in this post, however I plan to dig into them and correct unit testing surrounding them in the future.
At this point in time, the above program compiles, deploys, passes some extra very simple test cases (simply just calls each public function and checks they return Ok(()))
correctly) and logs output what is expected.
For completion, the test script now looks like this:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Day2 } from "../target/types/day_2";
describe("basic_math", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.Day2 as Program<Day2>;
it("Is initialized!", async () => {
const tx = await program.methods.initialize().rpc();
console.log("Your transaction signature", tx);
});
it("Can Add", async () => {
// Add your test here.
const a = new anchor.BN(2337);
const b = new anchor.BN(1000);
const tx = await program.methods.add(a, b).rpc();
console.log("Add tx sig: ", tx);
});
it("Can Subtract", async () => {
// Add your test here.
const a = new anchor.BN(2337);
const b = new anchor.BN(1000);
const tx = await program.methods.subtract(a, b).rpc();
console.log("Subtract tx sig: ", tx);
});
it("Can Multiply", async () => {
// Add your test here.
const a = new anchor.BN(101);
const b = new anchor.BN(5);
const tx = await program.methods.multiply(a, b).rpc();
console.log("Multiply tx sig: ", tx);
});
it("Can Divide", async () => {
// Add your test here.
const a = new anchor.BN(100);
const b = new anchor.BN(10);
const tx = await program.methods.divide(a, b).rpc();
console.log("Divide tx sig: ", tx);
});
});
And finally, our unit test results:
basic_math
Your transaction signature 3vGhWkwR4tcFnmAyNxbajkGnJfQ2Aw71f8ifkaEE1FnL87MBgJUuEeZTTAwh7zHRbQoTYBz7mGpscgifhds5DmLd
✔ Is initialized! (368ms)
Add tx sig: 2MLuZcAdm2eA2PEeXc9pfxFg3WbXVhjXaL4q4vsjjip3ovT13AkX3p6Rrqw2mCx8agsCXgcqwjJQtFnp9oVdHyQq
✔ Can Add (464ms)
Subtract tx sig: 51AVntXtrU1DXegW1oFDZxZaEXfHcmMekRnobMn3omPjNCjFA9YgnzczNzvxXoFZsXek6VppGtLCPEb2wMaFmsCW
✔ Can Subtract (469ms)
Multiply tx sig: 61ZCUu3JYAmaWKcHejpXa7W9SkpeSZD2GKQHqVFD4tPT4WAuFHhxj4cP1gpQNco9oRukm9gKGpFwdGAyN7vao5h2
✔ Can Multiply (473ms)
Divide tx sig: 2a9EPwuzzBTrnt47mi9oSzSkfkqufNvS7RRg9PhEFG5rHFdKzWLHwMEvkJxso4D8X2Z4GKX8sDTpcUP44kCK89Vy
✔ Can Divide (469ms)
5 passing (2s)
✨ Done in 3.17s.
Post 3 #
In the previous post, I converted a simple EVM smart contract written in Solidity into a Solana program written in Rust. It was then extended with a little more functionality just to help dig into a few more of the basics that are required to have working programs on chain.
This post will dig a little deeper into something I started touching on, which was reverting transactions with custom errors.
Reverting #
First off, a little basic knowledge about reverts. When you perform a transaction on a blockchain, all validators (nodes) have to produce the same result in order to sign the transaction and confirm it on the ledger as a successfully/completed action. In the event that one node cannot validate the transaction, it will revert and will ultimately be cancelled.
A very simple example of this could be:
- User A has 100 tokens of a certain currency
- User A tries to send 1000 tokens to User B
- When it comes to performing the transfer, the token will throw an error and state that user A’s balance is not equal or greater than 1000
- The transaction will be cancelled (reverted) and an error will be returned
Within a smart contract or program, there will ultimately be something similar to the following pseudo code:
requested_amount = 1000
if userA.balance < requested_amount:
revert "not enough tokens"
else:
send(userB, requested_amount)
Reverts can be tracked within explorers and it’s common for developers to add custom errors to reverts in order to help their user’s understand what might have gone wrong as well as the ability to instruct frontend applications on what to do if specific errors were to occur during usage.
Reverting and Returning Errors #
In a similar fashion to EVM (Solidity) smart contracts, transactions can be reverted if a decision is made within the code flow. Similar to our basic pseudo example above, reverts can be triggered and the program will return a different value in comparison to the usual Ok(())
return.
An example of this was included in the previous post, specifically surrounding a divide by zero sanity check within the exposed divide()
function of the program.
if b == 0 {
return err!(MyError::DivideByZero);
}
In the above snippet, a user controlled parameter b
is sanity checked to ensure it does not equal the value of 0
. In the event it does, the function returns early with a custom error DivideByZero
, which will ultimately cause the transaction to revert.
This error is part of a custom error struct, which includes a collection of errors and their respective error messages which can be assessed off-chain following the revert.
#[error_code]
pub enum MyError {
<...snipped...>
#[msg("Division by Zero")]
DivideByZero
}
It is important to note that all actions taken on-chain up to the point of reverting will also be completely reverted as well. This is to ensure all actions on-chain are correct and agreed upon by all validators. For example, let’s say a function transfer tokens between 2 users but also charged a ’tax’ for sending that token. This might look something like this:
- User A calls a transfer function
- User A balance is checked to ensure they can afford the amount sent
- The ‘handler’ contract receives 5% tax for performing the transfer, which is sent from User A first
- User A then transfers the total minus 5% to User B
At certain stages there will be balance checks to ensure everything is correct and return values from transfer requests will also be checked to ensure they succeeded. Let’s say step 1,2 and all succeed, they will be performed and the current state of the target accounts will have changed. Now, when attempting step 4 - for some reason this could fail, for example User B’s address could trigger a different revert such as a block list (or something) causing the transaction to fail and trigger a revert. In this case, all actions and changes performed in the previous steps will also revert and the state will be the same as it was before the overall transaction was ever attempted.
A key difference that needs to be remembed is that when a revert or error is thrown within a Solidity (EVM) smart contract, the execution is halted - ultimately saving gas and transaction costs if errors occur. This means that you will commonly see functions contain a bunch of require
statements in the early stages of each function to ensure they can revert quickly, and hopefully save the user some gas money. When it comes to Solana, the execution doesn’t halt or stop, the functionality will complete it’s expected logic flow, however it will return early which ultimately saves computational power - it’s just important to note that it doesn’t just stop like the EVM contracts.
Unit Testing #
As the custom errors were already added in the previous post’s program, I won’t be extending the functionality for this post - however a focus will be made on the test script as all these fail states and reverts must be tested to ensure the program logic is doing exactly what is intended. In some cases, these reverts will be designed to protect assets and insure maximum integrity is maintained whilst a user is performing a transaction. This could be anything from basic sanity checks made on the user’s behalf through to convoluted security rules.
The unit tests we have done so far simply ensure that functions can be called and the logic within the function is as intended. What we haven’t done is check whether the errors actually work and if calls are reverted when a fail state is forced.
In order to check these errors, I added the following test code which utilises the assert
function from ‘chai’ as well as the AnchorError type:
// new import within the test source:
import { assert } from "chai";
import { Program, AnchorError } from "@coral-xyz/anchor";
<... snipped ...>
// now we check for error triggers
it("Error check: Divide by Zero", async () => {
try {
const tx = await program.methods.divide(new anchor.BN(10), new anchor.BN(0)).rpc();
console.log("Your transaction signature", tx);
} catch (_err) {
assert.isTrue(_err instanceof AnchorError);
const err: AnchorError = _err;
const errMsg = "Division by Zero";
assert.strictEqual(err.error.errorMessage, errMsg);
console.log("Error number:", err.error.errorCode.number);
}
});
it("Error check: subtracting (B is less than A)", async () => {
try {
const tx = await program.methods.subtract(new anchor.BN(10), new anchor.BN(20)).rpc();
console.log("Your transaction signature", tx);
} catch (_err) {
assert.isTrue(_err instanceof AnchorError);
const err: AnchorError = _err;
const errMsg = "Subtract error: A is smaller than B";
assert.strictEqual(err.error.errorMessage, errMsg);
console.log("Error number:", err.error.errorCode.number);
}
});
it("Error check: Illegal operator type", async () => {
try {
const tx = await program.methods.calculate(new anchor.BN(10), new anchor.BN(10), 0).rpc();
console.log("Your transaction signature", tx);
} catch (_err) {
assert.isTrue(_err instanceof AnchorError);
const err: AnchorError = _err;
const errMsg = "Illegal operator type selected";
assert.strictEqual(err.error.errorMessage, errMsg);
console.log("Error number:", err.error.errorCode.number);
}
});
Rebuilding, checking program IDs and ensuring the localhost Solana chain is being used for the program, I ran the complete set of tests and received the following output confirming the tests passed and errors were causing the transaction reverts to happen:
<... snipped ...>
Error number: 6002
✔ Error check: Divide by Zero
Error number: 6001
✔ Error check: subtracting (B is less than A)
Error number: 6000
✔ Error check: Illegal operator type
9 passing (4s)
✨ Done in 5.32s.
We can see these errors within the Solana logs for debugging and further understanding of where the program failed:
Log Messages:
Program 3DkTKr8PGBjroeGoXvBf12WsMh58yRsVRWe6xxxxxxxx invoke [1]
Program log: Instruction: Divide
Program log: AnchorError thrown in programs/day_2/src/lib.rs:58. Error Code: DivideByZero. Error Number: 6002. Error Message: Division by Zero.
Program 3DkTKr8PGBjroeGoXvBf12WsMh58yRsVRWe6xxxxxxxx consumed 1961 of 200000 compute units
Program 3DkTKr8PGBjroeGoXvBf12WsMh58yRsVRWe6xxxxxxxx failed: custom program error: 0x1772
In this case, we can see the information that we used to perform our checks within the unit test to ensure the correct error was being thrown (error number: 6002, error message: Division by Zero)
Optional: Short Handed Error Statements #
In Solidity, programmers will commonly use require()
functions to perform their logic checks. If the require function fails (returns false) then the transaction will immediately halt and revert. This could look something like this:
require(userA.balance >= 1000, "Insufficient balance");
In this case, if userA’s balance was less than 1000, it would revert and throw the error string “Insufficient balance” which could be picked up by a frontend/explorer and prompt the user on why it errored. There is also a (cheaper) way to create and use custom errors in Solidity, but that is out of scope for this specific post.
This same sort of approach is possible within Solana programs, using the require!
macro. The same example as above could be achieved in Solana like so:
// cache the users balance in 'balanceVar'
require!(balanceVar >= 1000, MyError::InsufficientBalance);
If this were to fail, a custom defined error InsufficientBalance
would be returned and the program/transaction would exit.
Extending the previous program, the Rust used to achieve a require!
check is the following:
pub fn greaterThanOrError(
_ctx: Context<Initialize>,
a: u64
) -> Result<()> {
// perform the correct operation based on the op param
require!(a > 1000, MyError::NotGreaterThan);
return Ok(());
}
And finally, the passing test can be seen here (the unit test code is effectively the same with a different error message being checked):
Error number: 6003
✔ Require Error check: greaterThan