Solana by Example

Inspired by Rust By Example

Solana by Example is a collection of runnable examples that illustrate various Solana concepts and its libraries.

Now let's begin!

Hello Solana

This section cover:

  • Get the idea of client and program.
  • How to deploy a Solana program
  • Use a client to interact with the program

We will make a simple program that only log message: "Hello Solana" without framworks. And we will demo how to use Typescript and Rust client to interact with the program.

Program

Create a project called program

cargo new program --lib

Paste below code to Cargo.toml

[features]
https://docs.solana.com/developing/on-chain-programs/developing-rust#project-layout

When toggled on this feature will cause the crate to not compile a
bpf entrypoint.

See the corresponding `#[cfg(not(feature = "exclude_entrypoint"))]`
in lib.rs. This is needed so that other Solana programs can import
helper functions from this library without causing symbol conflicts
with our entrypoint.
exclude_entrypoint = []

[dependencies]
solana-program = "1"

[lib]
This is the name of the compiled file
For this example, the compiled file path is `target/deploy/hellosolana.so`
name = "hellosolana"
crate-type = ["cdylib", "lib"]

In lib.rs

use solana_program::{
    account_info::AccountInfo,
    entrypoint,
    pubkey::Pubkey,
    msg
};

// Declare the programs entrypoint. The entrypoint is the function
// that will get run when the program is executed.
#[cfg(not(feature = "exclude_entrypoint"))]
entrypoint!(process_instruction);

pub fn process_instruction(
    _program_id: &Pubkey,
    _accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> entrypoint::ProgramResult {
    msg!("Hello Solana");
    Ok(())
}

Compile the program

cargo build-bpf

Run test validator before deploy!

solana-test-validator

Deploy the program

solana program deploy target/deploy/hellosolana.so

If the deploy fails, check if you have enough SOL in your account

Check account balance

solana balance

Airdrop some SOL

solana airdrop 1

If the deploy success, you should see your program ID on the terminal. You should see different ID as mine.

$ solana program deploy target/deploy/hellosolana.so
Program Id: 4kwL8iV4WuCJv41rxeLqhAVxnuKRrZ9PFUSeBkY78BiW

Client (Rust)

Here is a simple Rust client.

Create a project called client_rust

cargo new client_rust

In main.rs:

use solana_client::rpc_client::RpcClient;
use solana_sdk::commitment_config::CommitmentConfig;
use solana_sdk::signer::keypair::read_keypair_file;
use solana_sdk::instruction::Instruction;
use solana_sdk::message::Message;
use solana_sdk::signature::Signer;
use solana_sdk::transaction::Transaction;

fn main() {
    let args = std::env::args().collect::<Vec<_>>();
    if args.len() != 3 {
        eprintln!(
            "usage: {} <path to program keypair> <path to signer keypair>",
            args[0],
        );
        std::process::exit(-1);
    }
    let program_keypair_path = &args[1];
    let signer_keypair_path = &args[2];

    // Establish connection
    let url = "http://localhost:8899".to_string();
    let commitment_config = CommitmentConfig::processed();
    let connection = RpcClient::new_with_commitment(url, commitment_config);

    // Get signer's Pubkey
    let payer = read_keypair_file(signer_keypair_path).map_err(|e| {
        panic!("failed to read keypair file ({}): ({})", signer_keypair_path, e);
    }).unwrap();

    // Get program's Pubkey (Program ID)
    let program_keypair = read_keypair_file(program_keypair_path).map_err(|e| {
        panic!(
            "failed to read program keypair file ({}): ({})",
            program_keypair_path, e
        );
    }).unwrap();

    // This will fail if the program is not deployed
    let program_info = connection
        .get_account(&program_keypair.pubkey())
        .expect("Fail to find the program");

    // On Solana every data are stored in Accounts
    // There are seversal types of accounts which we will cover later
    // So programs are stored as "binary" codes in "executable" accounts
    if !program_info.executable {
        panic!(
            "program with keypair ({}) is not executable",
            program_keypair_path
        );
    }

    // Make and send a transaction
    // Take this step as you send a HTTP request in web2
    let instruction = Instruction::new_with_bytes(
        program_keypair.pubkey(),
        &[],
        vec![],
    );
    let message = Message::new(&[instruction], Some(&payer.pubkey()));
    let transaction = Transaction::new(
        &[&payer],
        message, 
        connection.get_recent_blockhash().unwrap().0
    );

    connection.send_and_confirm_transaction(&transaction);
}

In Cargo.toml:

[package]
name = "miniclient"
version = "0.1.0"
edition = "2021"

See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
solana-sdk = "1"
solana-client = "1"

Usage

  1. Run local test validator.
    solana-test-validator
    
  2. Open another terminal tab to run Solana console log where "Hello Solana" will print at
    solana logs
    
  3. Open the third terminal tab to run this rust client
    cargo run ../program/target/deploy/helloSolana-keypair.json \
    ~/.config/solana/id.json
    

You should see this on the second terminal:

Streaming transaction logs. Confirmed commitment
Transaction executed in slot 204888:
  Signature: 3tRkKQFttGMkRvE6LzD1WUFfjxqjCBGFBqiioFPx5TDztYXUdaod3AERdJBRj9jMS7hw9WjYAxcrLoiFgam6gogF
  Status: Ok
  Log Messages:
    Program 4kwL8iV4WuCJv41rxeLqhAVxnuKRrZ9PFUSeBkY78BiW invoke [1]
    Program log: Hello Solana
    Program 4kwL8iV4WuCJv41rxeLqhAVxnuKRrZ9PFUSeBkY78BiW consumed 197 of 1400000 compute units
    Program 4kwL8iV4WuCJv41rxeLqhAVxnuKRrZ9PFUSeBkY78BiW success

Congrat! we already finished our first progarm on Solana!

Client (Typescript)

Here is a simple Typescript client.

Scaffolding:

client_ts/
├── src/
│  └── main.ts
├── package.json
└── tsconfig.json

In package.json:

{
  "name": "hellosolana",
  "version": "0.0.1",
  "description": "",
  "repository": {
    "type": "git",
    "url": "https://github.com/n795113/solana-by-example"
  },
  "keywords": [],
  "author": "",
  "license": "MIT",
  "dependencies": {
    "@solana/web3.js": "^1.33.0",
    "mz": "^2.7.0"
  },
  "devDependencies": {
    "ts-node": "^10.0.0",
    "@types/mz": "^2.7.2",
    "@tsconfig/recommended": "^1.0.1"
  },
  "engines": {
    "node": ">=14.0.0"
  }
}

In tsconfig.json:

{
  "extends": "@tsconfig/recommended/tsconfig.json",
  "ts-node": {
    "compilerOptions": {
      "module": "commonjs"
    }
  },
  "compilerOptions": {
    "declaration": true,
    "moduleResolution": "node",
    "module": "es2015"
  },
  "include": ["src/client/**/*"],
  "exclude": ["node_modules"]
}

In src/main.ts:

import fs from 'mz/fs';
import {
  Keypair,
  Connection,
  TransactionInstruction,
  Transaction,
  sendAndConfirmTransaction,
} from '@solana/web3.js';

const RPCURL = 'http://127.0.0.1:8899';

async function main() {
  const args = process.argv.slice(2);
  if (args.length != 2) {
    console.log("Please pass program and payer's keypair paths!");
    process.exit(1);
  }
  const program_keypair_path = args[0];
  const payer_keypair_path = args[1];

  console.log("Let's say hello to Solana...");

  // Establish connection to the cluster
  const connection = new Connection(RPCURL, 'confirmed');
  const version = await connection.getVersion();
  console.log('Connection to cluster established:', RPCURL, version);

  // Get the payer to pay this transaction
  var secretKeyString = await fs.readFile(payer_keypair_path, {encoding: 'utf8'});
  var secretKey = Uint8Array.from(JSON.parse(secretKeyString));
  // Keypair is an object that has 2 properties: publicKey, secretKey.
  // Both of them are bytes so if we want to print them as strings,
  // we can encode them into Base58 by calling toBase58() method. 
  const payerKeypair =  Keypair.fromSecretKey(secretKey);
  console.log("Payer: ", payerKeypair.publicKey.toBase58());

  // Get the program's pubkey (ID)
  secretKeyString = await fs.readFile(program_keypair_path, {encoding: 'utf8'});
  secretKey = Uint8Array.from(JSON.parse(secretKeyString));
  const programKeypair = Keypair.fromSecretKey(secretKey);
  const programId = programKeypair.publicKey
  console.log("Program ID: ", programId.toBase58());

  // Invoke our Hello Solana Program
  console.log('Sending the transaction...');
  const instruction = new TransactionInstruction({
    // This field accepts an array of accounts
    // However, our program doesn't use accounts' data in this example
    // so just pass an empty array.
    keys: [],
    programId,
    // Our program doesn't use instruction data in this example
    // so just pass an empty byte array
    data: Buffer.alloc(0),
  });
  await sendAndConfirmTransaction(
    connection,
    new Transaction().add(instruction),
    [payerKeypair],
  );

  console.log('Success');
}

main().then(
    () => process.exit(),
    err => {
      console.error(err);
      process.exit(-1);
    },
);

Usage

  1. Run local test validator.
    solana-test-validator
    
  2. Open another terminal tab to run Solana console log where "Hello Solana" will print at
    solana logs
    
  3. Open the third terminal tab and change directory to client_ts/ to install dependencies
    npm install
    
  4. Run this rust client
    ts-node src/main.ts ../program/target/deploy/helloSolana-keypair.json \
    ~/.config/solana/id.json
    

You should see this on the second terminal:

Streaming transaction logs. Confirmed commitment
Transaction executed in slot 44967:
  Signature: 8aq8hNW9iTEL5UVfE2Ttwi1v9C382yuvtoLX1h4vsvTtRzYgQxd5psSB7Cnj7qstTWqxHeyTASLA4Kf3x33QNwV
  Status: Ok
  Log Messages:
    Program 4kwL8iV4WuCJv41rxeLqhAVxnuKRrZ9PFUSeBkY78BiW invoke [1]
    Program log: Hello Solana
    Program 4kwL8iV4WuCJv41rxeLqhAVxnuKRrZ9PFUSeBkY78BiW consumed 197 of 1400000 compute units
    Program 4kwL8iV4WuCJv41rxeLqhAVxnuKRrZ9PFUSeBkY78BiW success

Congrat! we already finished our first progarm on Solana!

Explain

Client & Program

"Program" is "Smart Contract" in Solana. It just terminology difference.

If you are a web3 newbie, you may take programs (smart contract) as AWS Lambda functions.

Just like client-server model that we are familiar in web2, we develop and deploy prgrams online then use clients to interact with them. it can be any kind of client, such as a typescript client, or a Rust client.

Account

Accounts are just buffers that store serialized binary on chain. Every data on Solana are stored in accounts.

Program

Program are not special, they are also data, stored in accounts. These accounts are flagged as executable and ownership is transferred to an ebpf loader program.

Programs on Solana are stateless which means we need to pass states from other places which you may already guess: other accounts.

States

Oppisite to programs, states are stored in non-executable accounts which can be 2 types:

  • writable
  • read-only

You may think state accounts as rows in database. We will introduce how to modify and query them in a later example.

Resources

ok so what the fuck is the deal with solana anyway

Counter

In this example, we will implement a counter on Solana. When a user invokes the program the counter will add 1.

Concepts that we will learn about:

  • Program Derive Address (PDA)
  • Serialize & deserialize data

This example was originally from the program, example-helloworld, of Solana-labs. I rename it since I think its core function is a counter, which is a good example to demostrate how state data stored on Solana.

Program Derive Address (PDA)

There 2 properties of an account:

  • owner: who can modify the account
  • authority: who can sign the account

PDAs are just specific accounts that have no private keys their authorities are belongs to programs.

Here is a good image from Solana Cookbook to show the difference between ownership and authority:

In this example. We will use a PDA to store the counter value!

Why PDA

PDAs serve as the foundation for Cross-Program Invocation, which allows Solana apps to be composable with one another.

Rent

Accounts need to be PAID to live on the chain. The fee is depends on how much data an account holds, and how long it will live. We will see how to count the fee in this example.

More detail about rent from official doc

Program

Again, this example is originally from Solana-labs. I just renamed some parts of it so it makes more sence to me. Feel free to check out the origin repo.

First, let's declare a struct, which represents the account to hold the counter:

use borsh::{BorshDeserialize, BorshSerialize};

#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct State {
    counter: u32
}

Then we update our process_instruction in main.rs. The main difference from the Hello Solana Program is we will use program_id, and accounts this time.

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    _instruction_data: &[u8], // still won't be used in this example
) -> entrypoint::ProgramResult {

    // Iterating accounts is safer than indexing
    let accounts_iter = &mut accounts.iter();

    // Get the counter account
    let account = next_account_info(accounts_iter)?;

    // The account must be owned by the program in order to modify its data
    if account.owner != program_id {
        return Err(ProgramError::IncorrectProgramId);
    }

    // Deserialize the state infomation from the account
    let mut state = State::try_from_slice(&account.data.borrow())?;

    // Modify it
    state.counter += 1;

    // Then write it back
    state.serialize(&mut &mut account.data.borrow_mut()[..])?;

    Ok(())
}

The logic is simple:

  1. Get the account
  2. Check the account is owned by the program
  3. Modify it and write it back

Client (Typescript)

First, let's generate an address for our state account. This address is special compared to other anther accounts since it is a hash instead of a public key.

const accountSeed = 'solana-by-example'; // can be any string

const accountAddress = await PublicKey.createWithSeed(
    payerKeypair.publicKey,
    accountSeed,
    programId,
); // The result looks like a public key but is just a hash

Second, check if this accounts has already created on chain. If not, then send a transaction to System Program to create an account using the address we generated.

var account = await connection.getAccountInfo(accountAddress);
if (account === null) {

    // The account only holds an u32 number so the size is 4 bytes
    const constAccountSize = 4

    // Count how much rent should be paid for this account
    const rentFee = await connection.getMinimumBalanceForRentExemption(constAccountSize);

    const transaction = new Transaction().add(
        SystemProgram.createAccountWithSeed({
        fromPubkey: payerKeypair.publicKey,
        basePubkey: payerKeypair.publicKey,
        seed: accountSeed, // "solana-by-example"
        newAccountPubkey: accountAddress,
        lamports: rentFee,
        space: constAccountSize,
        programId,
        }),
    );
    await sendAndConfirmTransaction(connection, transaction, [payerKeypair]);
}

Third, send another transaction to our Counter Program by passing the account we get at step 2. The Counter Program then should modify the data of the account.

const stateAccount = {
    pubkey: accountAddress,
    isWritable: true,
    isSigner: false
}

const instruction = new TransactionInstruction({
    keys: [stateAccount],
    programId,
    data: Buffer.alloc(0), // instruction data won't be used by this example
});

await sendAndConfirmTransaction(
    connection,
    new Transaction().add(instruction),
    [payerKeypair],
);

Finally, query the account again to check if the data is updated.

account = await connection.getAccountInfo(accountAddress);

if (account === null) {
    throw 'Error: cannot find the greeted account';
}

const StateAccountSchema = new Map([
    [StateAccount, {kind: 'struct', fields: [['counter', 'u32']]}]
]);

const state = borsh.deserialize(
    StateAccountSchema, 
    StateAccount, // State class
    account.data
);

console.log(
    'Address:'
    accountAddress.toBase58(),
    'counter:',
    state.counter
);

add State class outside the main function. It is similar to the struct we did in program.rs:

class StateAccount {
    counter = 0;
    constructor(fields: {counter: number} | undefined = undefined) {
        if (fields) {
            this.counter = fields.counter;
        }
    }
}

Check out the full code

Usage

ts-node src/main.ts ../program/target/deploy/counter-keypair.json \
~/.config/solana/id.json

The counter should increases 1 every time this client runs.

This is the result that run the client 2 times:

$ ts-node src/main.ts ../program/target/deploy/counter-keypair.json \
~/.config/solana/id.json

Payer:  46hytJBhguswo6S8fCcVtR85HEnb9nd1hwMxFWYnSHXc
Program ID:  GuoMVjGXrxDvJaKuRfuKZsiwSHrfvfXg2YH7DTxGqdQe
Creating account: CzZRqZHR4ZEcoyJs61WRFJZP2iX2siPHwVVMGwu3iFdt
Sending the transaction to Counter Program...
Address: CzZRqZHR4ZEcoyJs61WRFJZP2iX2siPHwVVMGwu3iFdt counter: 1
Success
$ ts-node src/main.ts ../program/target/deploy/counter-keypair.json \
~/.config/solana/id.json

Payer:  46hytJBhguswo6S8fCcVtR85HEnb9nd1hwMxFWYnSHXc
Program ID:  GuoMVjGXrxDvJaKuRfuKZsiwSHrfvfXg2YH7DTxGqdQe
Sending the transaction to Counter Program...
Address: CzZRqZHR4ZEcoyJs61WRFJZP2iX2siPHwVVMGwu3iFdt counter: 2
Success

We can see at the first time, the client create a new account, and its counter is 1. At the second time, since the account is already existed, the client doesn't create an account again. It just take the existed account, and add its counter to 2.

Activity

What will happen if the seed changes every time? (now it is hard coded as "solana-by-example")

Set Counter

This is an improved version of Counter example. Besides the increament instruction, we will add 2 more instructions: decreament, and set value!

In this example, we will learn how to branch instructions after entering the endpoint of a program. Still, we will implement this in vanilla Rust.

This example was originally from Josh's tutorial from his channel. Please check out this amazing channel right now!

Instructions

First, let's declare our instructions by adding a new file, instructions.rs.

Scaffolding:

client_ts/
├── src/
│  ├── instructions.rs
│  └── main.rs
└── Cargo.toml

In instructions.rs:

#[derive(Debug)]
pub Enum Instruction {
    Increment,
    Decrement,
    SetValue(u32)
}

If you are not familar with Enum in Rust, please check this out.

Purpose: when an instruction data (&[u8]) be passed into our program, we are gonna to resolve (unpack) it into ethier above 3 instructions:

  • Instruction::Increment,
  • Instruction::Decrement,
  • Instruction::SetValue(val)

The program will then match the instruction to do different operations.

Unpack

Let's implement a method for unpacking (or say deserializing) the given isntruction data. In this example, we gonna to use the first byte to represent the instruction code:

  • 0: Increment
  • 1: Decrement
  • 2: set value

If the given code is 2 (set value), a user should also pass an u32 number in the other 4 bytes after the first bytes which means the total length of the array should be 5.

Moreover, there is no uniform rule about how to arrange the instruction data. You can design your own patterns for specific purpoeses.

use solana_program::{program_error::ProgramError};

impl Instruction {
    pub unpack(input: &[u8]) -> Result<Self, ProgramError> {
        let (&ix_code, rest) = input.split_first().ok_or(ProgramError::InvalidInstructionData)?;

        match ix {
            0 => Ok(Instruction::Increment),
            1 => Ok(Instruction::Decrement),
            2 => {
                // an u32 value should occupy 4 bytes
                if rest.len() != 4 {
                    return Err(ProgramError::InvalidInstructionData);
                }
                let val: Result<[u8; 4], _> = rest[..4].try_into();
                match val {
                    Ok(bytes) => Ok(Instruction::Set(u32::from_le_bytes(bytes))),
                    _ => Err(ProgramError::InvalidInstructionData)
                }
            },
            _ => Err(ProgramError::InvalidInstructionData)
        }
    }
}

ix often represents "instruction", and tx represents "transaction".

Program

Include instractions crate, and add the instruction branching after the account-owner-check:

pub mod instructions;
use crate::instructions::Instruction;

// ...

let mut state = State::try_from_slice(&account.data.borrow())?;
let ix = Instruction::unpack(instruction_data)?;
match ix {
    Instruction::Increment => state.counter += 1,
    Instruction::Decrement => state.counter -= 1,
    Instruction::SetValue(val) => state.counter = val
}
state.serialize(&mut &mut account.data.borrow_mut()[..])?;

Don't forget to rename the variable name of instruction data

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8], // _instruction_data -> instruction_data
) -> entrypoint::ProgramResult {
    // ...
}

Check out the full code

Client (Typescript)

The only difference from the last client example is that we will send a non-empty array of instruction data this time.

Recap our instruction codes:

  • 0: increment
  • 1: decrement
  • 2: set value

If the given code is 2 (set value), a user should also pass an u32 number in the other 4 bytes after the first bytes which means the total length of the array should be 5.

Here is an example how to make the array:

var instructionData = Buffer.alloc(5, 0); // [0, 0, 0, 0, 0]

// The first byte is instruction code
instructionData.writeUint8(instructionCode, 0);

// The last 4 bytes reoresents an u32 value we want to set
instructionData.writeUInt32LE(inputValue, 1);

Our program will ignore the last 4 bytes if instruction code is 0 or 1 so I just alloc 5 bytes for every cases. You can alloc only 1 byte on cases increment, and decrement of course. The program should works the same.

A user should pass the instruction and value if the instruction is "set value". Let's modify the arguments handling at the begining of the main.

const args = process.argv.slice(2);
if (args.length < 3) {
    console.log("Uasge: <instruction code> [value] <program keypair path> <payer keypair path>");
    process.exit(1);
}
const instruct = args[0];
var instructionCode = 0;
var inputValue = 0;
switch (instruct) {
    case "increment": 
        instructionCode = 0;
        break;
    case "decrement":
        instructionCode = 1;
        break;
    case "set":
        instructionCode = 2;
        inputValue = parseInt(args[1]);
        break;
}
const program_keypair_path = (instruct === "set") ? args[2] : args[1];
const payer_keypair_path = (instruct === "set") ? args[3] : args[2];

Just a simple example without invalid arguement handling. You can do better than this one.

Check out the full code

Usage

Let's set the counter to some value, then play with the increment, and decrement to check if they work properly. Finally, we set the counter again to check set value truely works.


$ ts-node src/main.ts set 100 ../program/target/deploy/setcounter-keypair.json ~/.config/solana/id.json
Payer:  46hytJBhguswo6S8fCcVtR85HEnb9nd1hwMxFWYnSHXc
Program ID:  5RKoJE1ZwEnvGjhMG9mLgz8ARFEqHV6JfectxedmM7Rg
Sending the transaction...
Address: H1JS1dZ4do8t71XUs5g3eVjcNYLdgZhzFqiHASSPC5CE counter: 100
Success
$ ts-node src/main.ts increment ../program/target/deploy/setcounter-keypair.json ~/.config/solana/id.json
Payer:  46hytJBhguswo6S8fCcVtR85HEnb9nd1hwMxFWYnSHXc
Program ID:  5RKoJE1ZwEnvGjhMG9mLgz8ARFEqHV6JfectxedmM7Rg
Sending the transaction...
Address: H1JS1dZ4do8t71XUs5g3eVjcNYLdgZhzFqiHASSPC5CE counter: 101
Success
$ ts-node src/main.ts decrement ../program/target/deploy/setcounter-keypair.json ~/.config/solana/id.json
Payer:  46hytJBhguswo6S8fCcVtR85HEnb9nd1hwMxFWYnSHXc
Program ID:  5RKoJE1ZwEnvGjhMG9mLgz8ARFEqHV6JfectxedmM7Rg
Sending the transaction...
Address: H1JS1dZ4do8t71XUs5g3eVjcNYLdgZhzFqiHASSPC5CE counter: 100
Success
$ ts-node src/main.ts set 1234 ../program/target/deploy/setcounter-keypair.json ~/.config/solana/id.json  
Payer:  46hytJBhguswo6S8fCcVtR85HEnb9nd1hwMxFWYnSHXc
Program ID:  5RKoJE1ZwEnvGjhMG9mLgz8ARFEqHV6JfectxedmM7Rg
Sending the transaction...
Address: H1JS1dZ4do8t71XUs5g3eVjcNYLdgZhzFqiHASSPC5CE counter: 1234
Success

Anchor

In this example, we will rewrite the Set Counter Program with Anchor.

What is Anchor

Anchor is a framework for Solana Program development like Laravel, or Rails.

Why Anchor

It makes things easier

  • It handles serialization and deserialization for you
  • It simplifies the develop flow, we can develop and test a program easily
  • It improves the security for you program by default

Installation

Follow the offical doc

Init Project

First, let's create a new Anchor project:

anchor init set_counter_anchor

cd the project then run the test:

cd set_counter_anchor
anchor test

This command will build, deploy the program, then run the test code! Pretty handy, right?

You should see something like below which means all works.

  set_counter_anchor
Your transaction signature 38P7FC6rNP95RGwQRmYrGZYpsS22KgA6KLk9tMzhuD6U3z324J2xSjiScLtTrSqkNCpt9D5MaCWKrJEBVdydcz2F
    ✔ Is initialized! (322ms)


  1 passing (330ms)

✨  Done in 7.38s.

Program

Let's take a look to programs/set_counter_anchor/src/lib.rs. Here is where the program logic lives.

use anchor_lang::prelude::*;

declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");

#[program]
pub mod new_anchor_project {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}

Context is mapped to the accounts we pass to a program.

initialize() is an instruction of this program. We can have several instructions in one program, just like we do in Set Counter example. Different instruction may require different accounts to be passed to it so we can define the schema of the accounts using a struct with #[derive(Accounts)].

Test

Another reason to use Anchor is it set up the test stuffs for you. In tests/set_counter_anchor.ts, we can see the example test.

Implement Set Counter Logic

Define State Account

In the previous example, we create the state account at the client side. However, in this implementation, we will move this process to the program so we add anothor instrucion called initialize. The responsibility of this instruction is to create a state account and set its counter to default, which is 0.

Let's define the state account schema.

// This is how we define an account with Anchor
#[account]
pub struct State {
    counter: u32
}

You can find the only difference from the vanilla Rust code is the attribute

// Original way in vanilla Rust
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct State {
    counter: u32
}

Define Instructions & Argument Accounts

Use #[derive(accounts)] attribute to define agrument accounts. By doing so, we define what accounts should we pass to an instruction. Then at each instruction, we define its argument accounts by wrapping the struct with Context.

There are two types of accounts in this example. For the instructions, setting value, increment, and decrement, we only need to pass the state account so they share one struct called Update. On the other hand, For initialization, we also need to pass the payer and the system program ID to out program so we define another struct called Initialize. Let's see the code.

#[program]
pub mod set_counter_anchor {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn decrement(ctx: Context<Update>) -> Result<()> {
        Ok(())
    }

    pub fn increment(ctx: Context<Update>) -> Result<()> {
        Ok(())
    }

    pub fn set(ctx: Context<Update>, value: u32) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = user, space = 8 + 4)]
    pub state: Account<'info, State>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>
}

#[derive(Accounts)]
pub struct Update<'info> {
    #[account(mut)]
    pub state: Account<'info, State>
}

Fill the logic

The struct is done. The rest task is to fill the logic:

#[program]
pub mod set_counter_anchor {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let state = &mut ctx.accounts.state;
        state.counter = 0; // init the counter
        Ok(())
    }

    pub fn decrement(ctx: Context<Update>) -> Result<()> {
        let state = &mut ctx.accounts.state;
        state.counter = state.counter.checked_sub(1).unwrap_or(0); // avoid overflow
        Ok(())
    }

    pub fn increment(ctx: Context<Update>) -> Result<()> {
        let state = &mut ctx.accounts.state;
        state.counter = state.counter.checked_add(1).unwrap_or(u32::MAX); // avoid overflow
        Ok(())
    }

    pub fn set(ctx: Context<Update>, value: u32) -> Result<()> {
        let state = &mut ctx.accounts.state;
        state.counter = value;
        Ok(())
    }
}

Test

It's pretty straightforward. Here is the full test code:

import * as anchor from "@project-serum/anchor";
import { Program } from "@project-serum/anchor";
import { assert } from "chai";
import { SetCounterAnchor } from "../target/types/set_counter_anchor";

describe("set_counter_anchor", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

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

  const stateAccount = anchor.web3.Keypair.generate();

  const setValue = 123; // can be any u32 number

  it("Is initialized!", async () => {
    const tx = await program.rpc.initialize({
      accounts: {
        state: stateAccount.publicKey,
        user: program.provider.wallet.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId
      },
      signers: [stateAccount],
    });

    const state = await program.account.stateAccount.fetch(stateAccount.publicKey);
    // check the state account is correctly be inited
    assert.equal(state.counter, 0);
  });

  it("set value", async () => {
    await program.rpc.set(new anchor.BN(setValue), {
      accounts: {
        state: stateAccount.publicKey
      }
    });

    const state = await program.account.stateAccount.fetch(stateAccount.publicKey);
    assert.equal(state.counter, setValue);
  });

  it("increment", async () => {
    await program.rpc.increment({
      accounts: {
        state: stateAccount.publicKey
      }
    });

    const state = await program.account.stateAccount.fetch(stateAccount.publicKey);
    assert.equal(state.counter, setValue + 1);
  });

  it("decrement", async () => {
    await program.rpc.decrement({
      accounts: {
        state: stateAccount.publicKey
      }
    });

    const state = await program.account.stateAccount.fetch(stateAccount.publicKey);
    assert.equal(state.counter, setValue);
  });
});

Run the test:

anchor test

You can memerize the syntax that how to send a transaction and fetch an account with Anchor.

Integrate With Wallet Adaptor

Create a web app with React to interact with out program.

Program side

  1. Get the program ID and paste it to the program source code.
    # get the program ID
    solana address -k <project_path/target/deploy/project_name-keypair.json>
    
    Edit lib.rs
    declare_id!("PROGRAM ID");
    
  2. Build & deploy our program.
    anchor build
    # remember to run local test validators before deploy
    anchor deploy
    

Frontend side

  1. Clone the frontend starter repo.

    Use this repo (react-ui-starter) as the starter

  2. Implement the wallet connection.

  3. Make buttons for each instruction.

The full code of App.tsx is like below. Just a quick demo so please ignore the poor design...

Also, you can check out the full project here.

import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { ConnectionProvider, useAnchorWallet, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletModalProvider, WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import {
    GlowWalletAdapter,
    PhantomWalletAdapter,
    SlopeWalletAdapter,
    SolflareWalletAdapter,
    TorusWalletAdapter,
} from '@solana/wallet-adapter-wallets';
import { clusterApiUrl, Connection } from '@solana/web3.js';
import React, { FC, ReactNode, useMemo, useState, useRef } from 'react';
import idl from '../../target/idl/set_counter_anchor.json';
import { Program, BN, Provider, web3 } from "@project-serum/anchor";

const stateAccount = web3.Keypair.generate();

export const App: FC = () => {
    return (
        <Context>
            <Content />
        </Context>
    );
};

const Context: FC<{ children: ReactNode }> = ({ children }) => {
    // The network can be set to 'devnet', 'testnet', or 'mainnet-beta'.
    const network = WalletAdapterNetwork.Devnet;

    // You can also provide a custom RPC endpoint.
    const endpoint = useMemo(() => clusterApiUrl(network), [network]);

    // @solana/wallet-adapter-wallets includes all the adapters but supports tree shaking and lazy loading --
    // Only the wallets you configure here will be compiled into your application, and only the dependencies
    // of wallets that your users connect to will be loaded.
    const wallets = useMemo(
        () => [
            new PhantomWalletAdapter(),
            new GlowWalletAdapter(),
            new SlopeWalletAdapter(),
            new SolflareWalletAdapter({ network }),
            new TorusWalletAdapter(),
        ],
        [network]
    );

    return (
        <ConnectionProvider endpoint={endpoint}>
            <WalletProvider wallets={wallets} autoConnect>
                <WalletModalProvider>{children}</WalletModalProvider>
            </WalletProvider>
        </ConnectionProvider>
    );
};

const Content: FC = () => {
    const [counter, setCounter] = useState(0);
    const inputRef = useRef<HTMLInputElement>(null);
    const wallet = useAnchorWallet();

    // Establish the connection
    if (!wallet) {
        return null;
    }
    const network = "http://127.0.0.1:8899";
    const connection = new Connection(network, "processed");

    const provider = new Provider(
        connection, wallet, {"preflightCommitment": "processed"},
    );

    if (!provider) {
        throw("Provider is null");
    }
    const a = JSON.stringify(idl);
    const b = JSON.parse(a);
    const program = new Program(b, idl.metadata.address, provider);
    
    // Functions to interact with the counter program
    async function initialize() {
        try {
            await program.rpc.initialize({
                accounts: {
                  state: stateAccount.publicKey,
                  user: program.provider.wallet.publicKey,
                  systemProgram: web3.SystemProgram.programId
                },
                signers: [stateAccount],
            });

            const state = await program.account.state.fetch(stateAccount.publicKey);
            console.log("counter: ", state.counter);
            setCounter(state.counter);
        } catch (err) {
            console.log("tx err: ", err);
        }
    }

    async function increment() {
        try {
            await program.rpc.increment({
                accounts: {
                  state: stateAccount.publicKey,
                },
            });

            const state = await program.account.state.fetch(stateAccount.publicKey);
            console.log("counter: ", state.counter);
            setCounter(state.counter);
        } catch (err) {
            console.log("tx err: ", err);
        }
    }

    async function decrement() {
        try {
            await program.rpc.decrement({
                accounts: {
                  state: stateAccount.publicKey,
                },
            });

            const state = await program.account.state.fetch(stateAccount.publicKey);
            console.log("counter: ", state.counter);
            setCounter(state.counter);
        } catch (err) {
            console.log("tx err: ", err);
        }
    }
    async function set() {
        try {
            if (inputRef.current == undefined) {
                throw("Invalid number");
            }
            const value = parseInt(inputRef.current.value);
            console.log("receive value:", value);
            await program.rpc.set(new BN(value), {
                accounts: {
                  state: stateAccount.publicKey,
                },
            });

            const state = await program.account.state.fetch(stateAccount.publicKey);
            console.log("counter: ", state.counter);
            setCounter(state.counter);
        } catch (err) {
            console.log("tx err: ", err);
        }
    }
    return (
        <>
            <WalletMultiButton />
            <button onClick={initialize}>Initialize</button>
            <button onClick={decrement}>Decrement</button>
            <button onClick={increment}>Increment</button>
            <button onClick={set}>Set</button>
            <input ref={inputRef} type="number"/>
            <div>Counter: {counter}</div>
        </>
    );
};