A Beginner’s Guide to the Solana Web3 Stack
Swarnab Garang
Posted on June 19, 2023
Introduction
In this blog, we will talk about the Solana Blockchain, and how you as a developer can get started with building your dapps on Solana. This article has been written keeping new developers and beginners in mind, who have some general knowledge about smart contracts and dapps. We will look at some high-level concepts, tools, and technologies you need for Solana development, and at the end, we will build a small dapp. If that gets you excited, hop in and enjoy the ride!
Getting Started
Solana is a high-performance blockchain that offers a high throughput and very low gas fees. It achieves this through its Proof-of-History mechanism, which is used to improve the performance of its Proof-of-Stake consensus mechanism.
Now, talking about development on Solana, there are certain pros and cons. The pros are that the developer tools like the Solana CLI, the Anchor CLI, and their SDKs are great, and are easy to understand and implement. But, since the ecosystem and such tools are very new, the documentations are not perfect and lack necessary explanations.
That being said, the developer community in Solana is very strong and people will be eager to help out another fellow developer. I strongly recommend joining the Solana discord server and the Anchor discord server to be updated about the latest changes in the ecosystem. Also, if you are stuck in your Solana dev journey with any technical questions, a great place to get your problems resolved is the newly formed Solana Stack Exchange.
The Solana Web3 Tech Stack
Solana has a very nice tooling ecosystem and tech stack. Let's check out the tools we will need and use for our program.
1. Solana Tool Suite
The Solana Tool Suite comes with the Solana CLI Tools which makes the process of development smooth and easy. You can perform a lot of tasks with the CLI Tools ranging from deploying Solana Programs to transferring SPL Tokens to another account.
Download the tool suite here.
2. Rust
Solana Smart Contracts (called Programs) can be written in C, C++ or Rust programming languages. But the most preferred one is Rust.
Rust is a low-level programming language which has gained a lot of popularity due to its emphasis on performance, and type and memory safety.
Rust can feel a bit intimidating at first but once you start getting the hang of it, you will enjoy it a lot. It has a very well articulated documentation, which can be used as a good learning resource too. Some other resources for Rust include Rustlings and Rust-By-Example.
You can install Rust here.
3. Anchor
Anchor is a framework for Solana's Sealevel runtime providing several convenient developer tools for writing smart contracts.
Anchor makes our lives a lot easier by taking care of a lot of boilerplate code so that we can focus on the important bits. It also does a lot of checks on our behalf so that our Solana programs remain secure.
Anchor Book, which is Anchor's current documentation, has good references for writing Solana programs using Anchor. The Anchor SDK typedoc has all the methods, interfaces, and classes you can use in your JS client. The SDK does need better documentation.
You can install Anchor here.
4. A Frontend Framework
For your users to use your dapp, you need to have a frontend client which can communicate with the blockchain. You can write your client-side logic with any of the common frameworks (React / Vue / Angular).
You need to have NodeJS installed in your system if you want to build your client using these frameworks. You can install it here.
Buidling a Solana Dapp
Now since we have an overview of the Solana development workflow, let's build a Solana dapp. We are going to build a simple counter application. Let's dive in!
Setting Up The Environment
Before building the dapp, we need to make sure that the tools we need have been installed successfully. You need to have rust, anchor and solana installed in your system.
Note: If you are on windows, you will need a WSL Terminal to run Solana. Solana does not work well with Powershell.
Open your terminal and run these commands.
$ rustc --version
rustc 1.63.0-nightly
$ anchor --version
anchor-cli 0.25.0
$ solana --version
solana-cli 1.10.28
If you got the versions correctly, that means the tools were installed correctly.
Now run this command.
$ solana-test-validator
Ledger location: test-ledger
Log: test-ledger/validator.log
Initializing...
Version: 1.10.28
Shred Version: 483
Gossip Address: 127.0.0.1:1024
TPU Address: 127.0.0.1:1027
JSON RPC URL: http://127.0.0.1:8899
00:00:16 | Processed Slot: 12 | Confirmed Slot: 12 | Finalized Slot: 0 | Full Snapshot Slot: - | Incremental Snapshot Slot: - | Transactions: 11 | ◎499.999945000
If you got this as the terminal output, that means a test validator is running successfully on your system and you are ready to start buidling!
Now, if something went wrong you need not panic. Just take a step back and go through the installation process once again.
Overview of our Counter Program
Before writing code, let's take a step back and discuss what features we need for our counter program. There should be one function that initializes the counter. There should be one function that increments the counter. And there should be another function which decrements the counter.
Now the first thing you should know is, that Solana programs don't store state. To store the state of a program, you need to initialize something called an account
. There are essentially three types of accounts.
Program Accounts: Accounts that store executable code. These are where your contracts are deployed.
Storage Accounts: Accounts that store data. Typically, they store the state of a program.
Token Accounts: Accounts that store the balance of different SPL tokens and from where tokens are transferred.
In the case of the counter program we are building, our executable code will be stored in a program account
, and our counter data will be stored in a storage account
.
I hope that made sense, don't worry if it did not. It will eventually be intuitive. Okay, let's move on!
Buidling our Counter Program
Let's finally start building our program! Open up the terminal and run
$ anchor init counter
This will initialize a template program with a few files. Let's talk about the important ones here.
In the root of your project directory, you will find the file Anchor.toml
. It will contain configurations for the workspace-wide settings for our programs.
The file programs/counter/src/lib.rs
will contain our source code for our counter program. This is where most of the logic will go. It will already have some sample code in place.
The file programs/counter/Cargo.toml
will contain the information about package
, lib
, features
and dependencies
for our counter program.
And last but not the least, under the tests
directory, we will have all the tests necessary for our programs. Testing is very crucial for smart contract development because we cannot afford to have exploits in it.
Now, let's run anchor build
. This will build our workspace containing the counter program. It will create an IDL (Interface Description Language)
for us under ./target/idl/counter.json
. An IDL gives us an interface for any client to interact with our program after it has been deployed on chain.
$ anchor build
Running anchor build
will show a warning, but you can ignore that for now.
Now open up lib.rs
and delete some sample code so that it looks like this. We will start from a clean slate.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod counter {
use super::*;
}
Let's have a look at what we have here. In the line use anchor_lang::prelude::*;
, all we are doing is importing everything in the prelude
module from the anchor_lang
crate. In any program you write using anchor-lang, you need to have this line.
Next, declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
lets us have the unique id for our Solana program. The text Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS
is default, and we will be changing that in a bit.
#[program]
is an attribute that defines the module containing all instruction handlers (the functions we write) defining all entries into a Solana program.
Great, now that we understand what all that is, let's write the account that will go inside our transaction instructions. Our lib.rs
should look like this.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod counter {
use super::*;
}
// --new change--
#[account]
pub struct BaseAccount {
pub count: u64,
}
#[account]
is an attribute for a data structure representing a Solana account. We create a struct called BaseAccount
which stores the count
state as a 64-bit unsigned integer. This is where our count will be stored. BaseAccount
is essentially our storage account
.
Great! Now let's look at the transaction instruction to initialize the BaseAccount
.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod counter {
use super::*;
}
// --new change--
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = user, space = 8 + 16)]
pub base_account: Account<'info, BaseAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct BaseAccount {
pub count: u64,
}
Here, we are creating a struct called Initialize
where we declare all the accounts we need for this transaction. Let's look at them one by one.
base_account: To initialize the
base_account
, we need to have the account in our instruction (obviously). In theaccount
attribute, we pass in 3 arguments.
init
declares that we are initializing the account. Now one thing that might come to mind is how will we passbaseAccount
in the instruction if it has not been even initialized. The reason we can do so is, and we will see it while writing tests too, we will be creating and passing just a keypair forbaseAccount
. Only after the instruction happens successfully, thebaseAccount
account will be created on the Solana chain for the keypair we created. Hope that makes sense.
payer
declares the user who will pay to create the account. Now, one thing to note here is, that storing data on-chain is not free. It costs SOL. In this case, theuser
account will pay therent
to initializebase_account
.
space
denotes the amount of space we need to give to the account. 8 bytes for a unique discriminator, and 16 bytes for count data.user:
user
is the authority that willsign
the transaction to initializebase_account
.system_program:
system_program
is a native program on Solana that is responsible for creating accounts, allocating data on accounts, and assigning ownership of accounts to the connected programs. We need to pass it in the transaction instruction whenever we want to initialize an account.
Great! Now let's write our handler function which will initialize our base_account
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod counter {
use super::*;
// --new change--
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let base_account = &mut ctx.accounts.base_account;
base_account.count = 0;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = user, space = 8 + 16)]
pub base_account: Account<'info, BaseAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct BaseAccount {
pub count: u64,
}
Inside our counter
module, we write the initialize
function which takes the Initialize
account struct as the context. Inside our function, all we do is, take a mutable reference of base_account
, and set the count of base_account
to 0. As simple as that.
Great! We have successfully written the logic for initializing a base_account
on-chain which will store the count
.
Incrementing the Counter
Let's add the logic to increment our counter! Add the transaction instruction struct for increment.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod counter {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let base_account = &mut ctx.accounts.base_account;
base_account.count = 0;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = user, space = 8 + 16)]
pub base_account: Account<'info, BaseAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
// --new change--
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(mut)]
pub base_account: Account<'info, BaseAccount>,
}
#[account]
pub struct BaseAccount {
pub count: u64,
}
The only account we need in our transaction instruction for increasing our counter is base_account
.
Let's add the increment
handler function.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod counter {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let base_account = &mut ctx.accounts.base_account;
base_account.count = 0;
Ok(())
}
// --new change--
pub fn increment(ctx: Context<Increment>) -> Result<()> {
let base_account = &mut ctx.accounts.base_account;
base_account.count += 1;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = user, space = 8 + 16)]
pub base_account: Account<'info, BaseAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(mut)]
pub base_account: Account<'info, BaseAccount>,
}
#[account]
pub struct BaseAccount {
pub count: u64,
}
All we do here is take a mutable reference of base_account
and increment it by one. Simple enough!
Great! We now have logic to increment the counter.
Decrementing the Counter
The code will be very similar to incrementing the counter.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod counter {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let base_account = &mut ctx.accounts.base_account;
base_account.count = 0;
Ok(())
}
pub fn increment(ctx: Context<Increment>) -> Result<()> {
let base_account = &mut ctx.accounts.base_account;
base_account.count += 1;
Ok(())
}
// --new change--
pub fn decrement(ctx: Context<Decrement>) -> Result<()> {
let base_account = &mut ctx.accounts.base_account;
base_account.count -= 1;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = user, space = 8 + 16)]
pub base_account: Account<'info, BaseAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(mut)]
pub base_account: Account<'info, BaseAccount>,
}
// --new change--
#[derive(Accounts)]
pub struct Decrement<'info> {
#[account(mut)]
pub base_account: Account<'info, BaseAccount>,
}
#[account]
pub struct BaseAccount {
pub count: u64,
}
As a last step, let's build the workspace once again to check that our code compiles without errors.
$ anchor build
Great! We now have the smart contract for our counter program!
Testing our Programs
It is very crucial to test our smart contracts properly so that there are no exploits in our programs. For our counter program, we will implement basic tests to check if the handlers work properly.
Great! Head over to tests/counter.ts
. We will have all our tests here. Modify the test file in this way.
import * as anchor from "@project-serum/anchor";
import { Program } from "@project-serum/anchor";
import { assert } from "chai";
import { Counter } from "../target/types/counter";
describe("counter", () => {
const provider = anchor.AnchorProvider.local();
anchor.setProvider(provider);
const program = anchor.workspace.Counter as Program<Counter>;
let baseAccount = anchor.web3.Keypair.generate();
}
We are generating a new Keypair for baseAccount
which we will be using for our tests.
Test to Initialize the Counter
import * as anchor from "@project-serum/anchor";
import { Program } from "@project-serum/anchor";
import { assert } from "chai";
import { Counter } from "../target/types/counter";
describe("counter", () => {
const provider = anchor.AnchorProvider.local();
anchor.setProvider(provider);
const program = anchor.workspace.Counter as Program<Counter>;
let baseAccount = anchor.web3.Keypair.generate();
// -- new changes --
it("initializes the counter", async () => {
await program.methods
.initialize()
.accounts({
baseAccount: baseAccount.publicKey,
user: provider.wallet.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([baseAccount])
.rpc();
const createdCounter = await program.account.baseAccount.fetch(
baseAccount.publicKey
);
assert.strictEqual(createdCounter.count.toNumber(), 0);
});
});
We make a call to program.methods.initialize()
and pass in the accounts that are necessary for the instruction. Now one thing to note here is, that in the object we passed into accounts, we are using baseAccount
and systemProgram
as fields, although we defined them as base_account
and system_program
in the transaction instruction in rust.
This is because anchor allows us to follow the naming convention of the respective languages, which is camelCase
for typescript and snake_case
for rust.
Then, we pass in the signers' array for the transaction, which would be the account we passed and the user who creates the account. But you would see we haven't added provider.wallet
in the signers' array.
This is because signer adds provider.wallet
as a default signer in the array and we don't need to pass it explicitly. Had we created a keypair separately for a user, we would have to pass it in this array. Hope that made sense.
After the RPC call has been made, we try to fetch the created baseAccount
using the publicKey we created. After that, we just assert that the count inside the fetched baseAccount
is 0.
If the test passes, we know that everything went perfectly. First, we need to set the Solana config to use localhost. Spin up the terminal and run the command.
$ solana config set --url localhost
This should show
Config File: ~/.config/solana/cli/config.yml
RPC URL: http://localhost:8899
WebSocket URL: ws://localhost:8900/ (computed)
Keypair Path: /home/swarnab/.config/solana/id.json
Commitment: confirmed
Now, let's test our code.
$ anchor test
This should give a passing test as an output
Great! This means that our counter can be initialized successfully.
Test to Increment the Counter
Let's see the code. It's pretty straightforward here.
import * as anchor from "@project-serum/anchor";
import { Program } from "@project-serum/anchor";
import { assert } from "chai";
import { Counter } from "../target/types/counter";
describe("counter", () => {
const provider = anchor.AnchorProvider.local();
anchor.setProvider(provider);
const program = anchor.workspace.Counter as Program<Counter>;
let baseAccount = anchor.web3.Keypair.generate();
it("initializes the counter", async () => {
await program.methods
.initialize()
.accounts({
baseAccount: baseAccount.publicKey,
user: provider.wallet.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([baseAccount])
.rpc();
const createdCounter = await program.account.baseAccount.fetch(
baseAccount.publicKey
);
assert.strictEqual(createdCounter.count.toNumber(), 0);
});
// -- new changes --
it("increments the counter", async () => {
await program.methods
.increment()
.accounts({ baseAccount: baseAccount.publicKey })
.signers([])
.rpc();
const incrementedCounter = await program.account.baseAccount.fetch(
baseAccount.publicKey
);
assert.strictEqual(incrementedCounter.count.toNumber(), 1);
});
});
All we do here is make an rpc call to our increment
and after it happens we check that count
is 1.
Let's test the program.
$ anchor test
It should output
Great! Now we know our increment logic works as well.
Test to Decrement the Counter
import * as anchor from "@project-serum/anchor";
import { Program } from "@project-serum/anchor";
import { assert } from "chai";
import { Counter } from "../target/types/counter";
describe("counter", () => {
const provider = anchor.AnchorProvider.local();
anchor.setProvider(provider);
const program = anchor.workspace.Counter as Program<Counter>;
let baseAccount = anchor.web3.Keypair.generate();
it("initializes the counter", async () => {
await program.methods
.initialize()
.accounts({
baseAccount: baseAccount.publicKey,
user: provider.wallet.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([baseAccount])
.rpc();
const createdCounter = await program.account.baseAccount.fetch(
baseAccount.publicKey
);
assert.strictEqual(createdCounter.count.toNumber(), 0);
});
it("increments the counter", async () => {
await program.methods
.increment()
.accounts({ baseAccount: baseAccount.publicKey })
.signers([])
.rpc();
const incrementedCounter = await program.account.baseAccount.fetch(
baseAccount.publicKey
);
assert.strictEqual(incrementedCounter.count.toNumber(), 1);
});
// -- new changes --
it("decrements the counter", async () => {
await program.methods
.decrement()
.accounts({ baseAccount: baseAccount.publicKey })
.signers([])
.rpc();
const decrementedCounter = await program.account.baseAccount.fetch(
baseAccount.publicKey
);
assert.strictEqual(decrementedCounter.count.toNumber(), 0);
});
});
This is very similar to incrementing the counter. In the end we check in the count
is 0.
$ anchor test
This should output
That's it!
That brings us to the end of building and testing our own smart contracts on Solana! As the last step, let's deploy our counter program to Solana Devnet.
Deploying our Counter Program
Let's deploy our program, but first we need to change some stuff. Spin up the terminal and run this command.
$ anchor keys list
counter: 3fhorU8b8xLw75wRvAkvjRNqNgUQCZNCGJpmiRktLioQ
In my case 3fhorU8b8xLw75wRvAkvjRNqNgUQCZNCGJpmiRktLioQ
will be the unique id to my program.
Head over to lib.rs
and change the following line.
declare_id!("3fhorU8b8xLw75wRvAkvjRNqNgUQCZNCGJpmiRktLioQ");
Another change would be in Anchor.toml
[features]
seeds = false
skip-lint = false
[programs.localnet]
counter = "3fhorU8b8xLw75wRvAkvjRNqNgUQCZNCGJpmiRktLioQ"
[programs.devnet]
counter = "3fhorU8b8xLw75wRvAkvjRNqNgUQCZNCGJpmiRktLioQ"
[registry]
url = "https://api.apr.dev"
[provider]
cluster = "devnet"
wallet = "/home/swarnab/.config/solana/id.json"
[scripts]
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
We added [programs.devnet]
under [programs.localnet]
and changed the counter
in both of them.
Another change we need to do is under [provider]
. Change cluster
from localnet
to devnet
.
Great! Now we have to build the workspace. This is a very important step.
$ anchor build
Now we need to change the solana config to devnet
. Run the command
$ solana config set --url devnet
This should show
Config File: ~/.config/solana/cli/config.yml
RPC URL: https://api.devnet.solana.com
WebSocket URL: wss://api.devnet.solana.com/ (computed)
Keypair Path: /home/swarnab/.config/solana/id.json
Commitment: confirmed
Now let's deploy the program
$ anchor deploy
If you get Deploy success
, that means your program deployed successfully.
Awesome! Now let's go to the explorer and check with the program id. Make sure the cluster is set to devnet. You will get the id by running anchor keys list
.
Our program shows up on the explorer.
Bonus: Some Additional Technologies
There are some additional tools that you can use in your Solana dapps.
Arweave
Arweave is a community owned, decentralized and permanent data storage protocol. You can check it out here.
Metaplex
Metaplex is an NFT ecosystem built on top of the Solana blockchain. The protocol enables artists and creators to launch self-hosted NFT storefronts as easily as building a website. The Metaplex NFT Standard is the most used NFT Standard in the Solana ecosystem. Check them out here.
Conclusion
That brings us to the end of this tutorial. Hope you enjoyed it a lot and learned something along the way!
One thing I would like to say is that Solana development might feel a bit intimidating at first, but if you stay consistent, you will start to appreciate the beauty of the Solana ecosystem.
Just keep yourself updated with what's happening, be active on Twitter, and try to contribute to open-source Solana projects.
And if you are stuck somewhere, don't forget to visit the Solana Stack Exchange.
That's it from my side! Have a great Solana dev journey out there!
Posted on June 19, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.