Solana from First Principles: Part 2

Het Dagli
14 min readApr 26, 2024

--

In the first part, we learned that Solana uses public-private key pairs to manage ownership and access control. A wallet generates a key pair, with the public key used as the address for receiving tokens and the private key used to sign transactions and authorize changes.

Now that we understand how key pairs provide the foundation for ownership and control, let’s take a closer look at how Solana organizes and stores data using accounts.

Solana’s Account Model

Imagine you’re using a traditional bank. When you open an account, the bank assigns you an account number (like a public key) that you can share with anyone who wants to send you money. However, to access the funds in your account, you need to prove your identity using a password or PIN (like a private key). The bank maintains a ledger of all the accounts and their balances.

In Solana, the blockchain itself acts as the bank ledger. Instead of bank accounts, Solana has programmable accounts. Each account is like a safety deposit box holding data or executable code.

Just like a bank account, each Solana account has a unique address derived from a public key. Anyone can send tokens to this address, but only the person with the corresponding private key can access the contents of the account.

There are two main types of accounts in Solana:

  1. Data Accounts: These are like standard bank accounts that store your data or tokens. They’re controlled by programs and can only be modified according to the program’s rules.
  2. Program Accounts: These special accounts hold the code that defines the rules for how the associated Data Accounts can be modified. You can think of them as the bank’s software that controls how funds can be transferred or withdrawn.
Program account and Data account

Data Storage and the Concept of State

Imagine you’re playing a game of chess. Each move changes the position of the pieces on the board. The “state” of your chess game is the arrangement of all the pieces at any given time.

Similarly, the state of an account is the current information it holds, such as the balance of tokens, ownership details, or data specific to a decentralized application (dApp).

The state of a blockchain refers to the current status or condition of all the accounts in the system at any given moment. It’s like a snapshot that captures all the transactions and decisions that have happened up to that point.

Ethereum State visualised

In Solana, the data is stored in Data accounts. Interestingly the program accounts don’t store any data and hence are stateless.

How Stateless Programs Work?

As we have seen before programs are essentially executable accounts that contain the logic or the code necessary to perform operations.

When a transaction occurs, the necessary data is passed to these programs via references to accounts or in simpler terms accounts are passed as an argument to the function.

This data and logic separation allows Solana to optimize transaction throughput by parallelizing the execution of transactions that do not operate on overlapping data, thereby preventing conflicts and ensuring efficient use of network resources.

Rent and Resource Management

Since storing data on the blockchain takes up space, Solana uses a rent mechanism to manage resource allocation.

Accounts holding data need to maintain a minimum balance, proportional to their size, to remain active. However, accounts can become “rent-exempt” by holding a balance sufficient to cover rent for an extended period, typically two years' worth of rent payments, ensuring they are not purged from the system.

Recent updates to Solana have mandated that all new accounts must be initialized with enough lamports (the smallest unit of SOL) to be rent-exempt, ensuring that they are not subject to rent payments and are not at risk of being purged.

Understanding Program Derived Addresses (PDAs)

This is by far the most complicated concept of Solana so bear with me.

Data accounts have some limitations:

  1. They are controlled by private keys. If these keys are compromised, the security of the account and its assets are at risk.
  2. They are typically passive and require external instructions signed by private keys to make changes. This means that every operation needs to be explicitly authorized by an external signer, which can complicate or slow down automated processes within dApps.
  3. While data accounts can interact with multiple programs, managing these interactions securely and efficiently can be cumbersome. Each interaction would typically require permissions and signatures, complicating the development and execution of complex dApps that require seamless interactions between different programs.

Imagine you’re using a highly secure app that needs to perform actions on your behalf without constantly asking for your password. In Solana, PDAs serve a similar function for programs. They allow programs to perform specific actions or control certain accounts without needing a traditional private key. This setup enhances security and efficiency.

PDAs are special types of addresses which do not have a corresponding private key.

How PDAs are Created?

Creating a PDA is somewhat like making a custom ID tag that doesn’t need a key to open but can be recognized and used by a specific program. Here’s how it works:

  1. Combination of Ingredients (Seeds and Program ID): To create a PDA, a program combines several pieces of data, known as “seeds” (these could be numbers, strings, or other public keys), with its own unique identifier (Program ID). This combination is unique to each PDA.
  2. Hashing Function: The program uses findProgramAddressSync function which takes in seeds and the program ID and passes it through a cryptographic function (SHA-512), which mixes them to produce a new, unique address.
  3. Bump Seed: Sometimes, the result of the hashing might accidentally create an address that could theoretically have a corresponding private key (even though it’s highly unlikely). To prevent this and ensure the address is truly “off-curve” (meaning it can’t have a private key), a small adjustment called a “bump seed” is used.
PDAs are off-curve(no private key)

Only the program that generated the PDA can use it. This means the program can perform operations involving the PDA, like updating data stored at the address or transferring assets, without any other user or program being able to do so.

Why PDAs Are Necessary?

PDAs address the limitations of regular data accounts which we talked about earlier:

  1. No Private Keys: PDAs do not have private keys. They are derived programmatically using a combination of seeds and a program ID, which inherently makes them more secure against certain types of attacks, such as key theft or leakage.
  2. Exclusive Program Control: Only the program that created a PDA can control it. This exclusive control allows for secure and predictable interactions within and across programs without needing external signatures. For example, a program can autonomously update state or manage assets within its PDAs without any external intervention, streamlining operations and enhancing security.
  3. Efficient Cross-Program Invocations: PDAs facilitate smoother and more secure interactions between different programs. Since a PDA can be controlled directly by the program that created it, it simplifies the architecture of cross-program operations. This is particularly important in complex dApps involving multiple smart contracts that need to interact seamlessly and securely.
  4. Specific Use Cases: Certain functionalities, like managing user-specific states or creating unique identifiers for transactions within a program, are more efficiently handled using PDAs. For instance, a PDA can be used to uniquely identify and manage user sessions or specific tasks within a program, which would be cumbersome and less secure with a regular data account.

Next is my favourite part, let’s get our hands dirty.

Deploying Your First Program on Solana

We’ll create a simple program that integrates the concepts we’ve discussed: accounts, state management, and Program Derived Addresses (PDAs).

Our goal is to build a Solana program using Anchor, a framework that simplifies Solana development.

You can use Solana’s Web-Based IDE for this task here: SolPg. Here I will talk about how you can run a solana program locally.

Part 1: Setting Up Your Development Environment

Follow the steps here to set up locally: https://solana.com/developers/guides/getstarted/setup-local-development

Step 1: Install Rust

Solana programs are primarily written in Rust, so the first step is to install Rust on your machine. Open your terminal and run the following command:

curl - proto '=https' - tlsv1.2 -sSf https://sh.rustup.rs | sh

This script will install rustup, the Rust toolchain manager, along with the default compiler and Cargo, Rust’s package manager and build system.

Step 2: Install Solana Tools

Next, you’ll need the Solana command-line tools. Install them by running:

sh -c "$(curl -sSfL https://release.solana.com/stable/install)"

To check if your installation was successful, check the Solana CLI version:

solana --version

Step 3: Install Anchor using AVM

cargo install --git https://github.com/coral-xyz/anchor avm --locked --force
avm install latest
avm use latest

Verify if the installation was correct as of now, latest Anchor version is 0.3.0

anchor --version

Step 4: Using Devnet

Solana runs on 3 environments mainnet-beta, testnet, and devnet.

We’ll use Devnet.

solana config set --url devnet

You can verify your configuration anytime using

solana config get

Step 5: Setting Up Solana Wallet

You’ll need a Solana wallet. Create one by running:

solana-keygen new

This command will generate a new key pair and show you the path to your wallet file.

We would use this account to deploy programs on-chain.

Airdrop some tokens to pay for rent and deployment costs.

solana airdrop 2

You can get more tokens by using this faucet: https://faucet.solana.com/

Check Balance

solana balance

You should see 2 SOL as your balance.

Part 2: Writing Your First Solana Program

Step 1: Initialize a New Anchor Project

Create a new directory for your project and navigate into it:

mkdir my-solana-project
cd my-solana-project

Initialize a new Anchor project:

anchor init counter

This command creates a new project named counter.

Step 2: Understanding the Project Structure

Your new Anchor project has several key components:

  • programs/: Contains your Rust programs.
  • tests/: Where your JavaScript (or TypeScript) tests will reside.
  • Anchor.toml: Configuration file for Anchor.
  • app/: A simple frontend application (if you chose to generate it).

In Anchor.toml, change the configuration to devnet.

Change programs.localnet to programs.devnet and change the cluster value from “localnet” to “devnet”.

Step 3: Write the Program

Navigate to programs/counter/src/lib.rs. This is where you’ll write your Rust code for the program.

Here’s a simple counter program:

use anchor_lang::prelude::*;

declare_id!("4gdRvoPjjwbkMtDfWzUcrDghfQTxoV4oU6L2ELz88qUW");

#[program]
pub mod counter {
use super::*;
pub fn initialize(ctx: Context<Initialize>, counter: u8) -> Result<()> {
let counter_account = &mut ctx.accounts.counter_account;
counter_account.counter = counter;
Ok(())
}

pub fn increment(ctx: Context<Increment>) -> Result<()> {
let counter_account = &mut ctx.accounts.counter_account;
counter_account.counter += 1;
Ok(())
}
}

#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = user, space = 8 + 8, seeds = [b"counter", user.key().as_ref()], bump)]
pub counter_account: Account<'info, CounterAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Increment<'info> {
#[account(mut, seeds = [b"counter", user.key().as_ref()], bump)]
pub counter_account: Account<'info, CounterAccount>,
pub user: Signer<'info>,
}

#[account]
pub struct CounterAccount {
pub counter: u8,
}

Anchor programs are generally divided into two main parts: instructions and accounts.

Instructions define the actions our program can perform. In this case, we have two instructions:

  1. initialize: initializes a new data account with a counter set by the input given by the user.
  2. increment: increments the counter in the data account.

Accounts define the data structures used by our program. We have two account structs:

  1. Initialize: defines the accounts required for the initialize instruction.
  2. Increment: defines the accounts required for the increment instruction.
  3. CounterAccount : defines the counter variable which would be manipulated.

Code Walkthrough

use anchor_lang::prelude::*;

This line brings in all the necessary types and macros from the anchor_lang crate. You can think of crates as the npm equivalent of Rust.

declare_id!("Fg6PaFyL1JpZsh2sfyZ1LWQVnJDs9ub6QUT7QXeDx4e");

The declare_id! macro is used to define the unique identifier for our program. This would be the Program ID of your Program. Ignore this for now, we will change this later.

#[program]
pub mod first_solana_app {
use super::*;
// …
}

The #[program] attribute indicates that the following module contains our Solana program’s logic. Inside this module, we define our instructions: initialize and increment.

pub fn initialize(ctx: Context<Initialize>, counter: u8) -> Result<()> {
let counter_account = &mut ctx.accounts.counter_account;
counter_account.counter = counter;
Ok(())
}

The initialize instruction takesContext<Initialize> and counter integer as inputs. We extract the counter_account from the context and set its counter field to the integer passed on by the client. The Ok(()) at the end indicates that the instruction was executed successfully.

pub fn increment(ctx: Context<Increment>) -> Result<()> {
let counter_account = &mut ctx.accounts.counter_account;
counter_account.counter += 1;
Ok(())
}

Similarly, the increment instruction takes Context<Increment> and extracts the counter_account. It then increments the counter field by 1.

#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = user, space = 8 + 8, seeds = [b"counter", user.key().as_ref()], bump)]
pub counter_account: Account<'info, CounterAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}

TheInitialize struct defines the accounts required for the initialize instruction. It has three fields:
1. counter_account: This is the account where we’ll store our counter. #[account(…)] attribute specifies that this account should be initialized with the given constraints (payer, space, seeds, bump). This account is our PDA.
2. user: This is the account of the user initializing the program. It must be a signer.
3. system_program: This is the Solana System Program, which is required for account creation.

#[derive(Accounts)]
pub struct Increment<'info> {
#[account(mut, seeds = [b"counter", user.key().as_ref()], bump)]
pub counter_account: Account<'info, CounterAccount>,
pub user: Signer<'info>,
}

The Increment struct defines the accounts for the increment instruction. It has two fields:
1. counter_account: This is the same account used in Initialize, but now it’s mutable so we can increment the counter.
2. user: The user account, which must be a signer.

#[account]
pub struct CounterAccount {
pub counter: u64,
}

Finally, the CounterAccount struct defines the structure of our data account. It has a single field, counter which is an unsigned 64-bit integer.

Step 4: Build and Deploy

Run the following commands to build and deploy, make sure you are at the root where you initialized your program.

anchor build
anchor deploy

If everything goes as planned, the program should build and deploy successfully.

You may not have enough SOL to deploy, use solana airdrop 2 to get more SOL in your account.

Once deployed you would get a Program ID, replace the declare_id! macro at the top of the program with your program ID.

You’ll see a counter.json file in target/idl folder. This is the IDL of your program. An IDL (Interface Definition Language) is a JSON file that defines the public interface of a Solana program. It specifies the program’s instructions, account structures, and error codes, making it easier for client applications to interact with the program

In target/deploy folder you will see the counter.so and counter-keypair.json files. counter.so is the compiled code which lives on the chain and counter-keypair.json is the keypair of your program.

Part 3: Testing Your Solana Program

Step 1: Write Tests

Navigate to the tests/ directory. Here, you’ll find a TypeScript file where you can write tests for your program.

Here’s how you might write tests for the initialize and increment functions:

import * as anchor from '@project-serum/anchor';
import { Program } from '@project-serum/anchor';
import { Counter } from '../target/types/counter';

describe('counter', () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.Provider.env());

const program = anchor.workspace.Counter as Program<Counter>;

it('Initializes the counter', async () => {
// Call the initialize function
const counter = anchor.web3.Keypair.generate();
await program.rpc.initialize(new anchor.BN(0), {
accounts: {
counter: counter.publicKey,
user: program.provider.wallet.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
},
signers: [counter],
});

// Fetch the account and check the value
const account = await program.account.counter.fetch(counter.publicKey);
assert.equal(account.count.toNumber(), 0);
});

it('Increments the counter', async () => {
const counter = anchor.web3.Keypair.generate();
await program.rpc.initialize(new anchor.BN(0), {
accounts: {
counter: counter.publicKey,
user: program.provider.wallet.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
},
signers: [counter],
});

// Increment the counter
await program.rpc.increment({
accounts: {
counter: counter.publicKey,
},
});

// Fetch the account and check the value
const account = await program.account.counter.fetch(counter.publicKey);
assert.equal(account.count.toNumber(), 1);
});
});

Make sure Mocha is installed: npm i mocha

This code is semi-broken purposefully.

Run anchor test and the tests should pass for the first time, but if you run it again, it will show an error.

Code Walkthrough

import * as anchor from "@coral-xyz/anchor";
import { Program, web3 } from "@coral-xyz/anchor";
import { Counter } from "../target/types/counter";
import * as assert from "assert";

We import the necessary dependencies

describe('counter', () => {
// …
});

The describe block is a way to group related test cases together. In this case, we’re grouping all the tests related to the counter program.

const provider = anchor.AnchorProvider.env()
anchor.setProvider(provider)
const program = anchor.workspace.counter as Program<Counter>

Here, we set up the Anchor provider and the program instance:
- anchor.AnchorProvider.env() creates a provider using the environment configuration (e.g., the Solana cluster and wallet specified in the Anchor.toml file).
- anchor.setProvider(provider) sets the provider for the Anchor framework.
- anchor.workspace.counter retrieves the counter program from the Anchor workspace, and we cast it to the Program<Counter> type.

it('Initializes the counter', async () => {
// …
});

The it block defines an individual test case. This test case checks if the initialize function correctly initializes the counter.

const [counterAccountPda, _] = web3.PublicKey.findProgramAddressSync(
[Buffer.from('counter'), provider.wallet.publicKey.toBuffer()],
program.programId
);

Here, we find the Program Derived Address (PDA) for the counter account using web3.PublicKey.findProgramAddressSync:
- We pass the seeds (string ‘counter’ and the provider’s wallet public key) and the program ID to generate the PDA.
- The PDA will be used to interact with the counter account.

If you look closely this PDA is the reason why our tests are broken.

Give yourself a few minutes and think why…

Remember every PDA is unique for the seeds being passed on, in our case the seeds are the string ‘counter’ and the user's public key. Given these inputs, the findProgramAddressSync function would generate the same public key every time it runs.

When we run our tests for the first time, there is a PDA generated, but when we run it again, the same PDA is generated which causes the conflict and the program fails.

Now in real-life situations, your dapp would interact with multiple wallets, so every time someone interacts with the program one of the seeds i.e. the wallet address would change. Generating a new PDA for every unique wallet.

As a dev, this provides a powerful tool to store user-specific information on-chain through PDAs. Changing the static string allows you to have multiple PDAs for a single-user wallet.

As homework, you can try changing the public key to make the tests work.

const tx = await program.methods
.initialize(69)
.rpc()

In this block, we call the initialize function of our program:
- program.methods.initialize(69) prepares the initialize method with the argument 69.
- .rpc() sends the transaction to the Solana cluster using RPC (Remote Procedure Call).

const account = await program.account.counterAccount.fetch(counterAccountPda);
console.log(account.counter)
assert.ok(account.counter === 69);
console.log('Counter is initialized to', account.counter.toString());

After initializing the counter, we fetch the counter account using program.account.counterAccount.fetch the counterAccountPda.
- We use assert.ok to check if the counter value is equal to 69, ensuring that the initialization was successful.

The second test case, Increments the counter, follows a similar structure:
- We find the PDA for the counter account.
- We call the increment method using program.methods.increment().rpc().
- We fetch the updated counter account and assert that the counter value has been incremented to 70.

I hope I have tickled your curiosity enough to explore Solana further.

Further, I have prepared a doc for you to get started on Solana: Everything about Solana

If you have any doubts, feel free to dm me on Twitter: @daglihet

See you in Part 3…

--

--