Soldev

Anchor vs Plain Rust

Last updated:

Anchor is recommended for beginners in all of the docs and guides.

The purpose of this guide is to elucidate how Rust development on Solana works without Anchor, so you can understand what's going on under the hood, as well as appreciate the conveniences Anchor provides.

This article goes over what's obscured from you when using Anchor, and what really happens underneath. We'll go over some docs examples of a simple counter program between raw Solana and Anchor.

Transactions and Instructions

The basic operating unit in Solana is an instruction, which are held in transactions which are their atomic, all or nothing counterpart.

Instructions hold three things:

  1. an array of accounts,
  2. the program_id that the transaction is targeting,
  3. as well as instruction_data which is stored as an array of u8 integers.

Why is instruction_data a bunch of u8?

Each of these numbers is one byte (8 bits) of the data stored in the instruction. These bytes will be decoded by your program to get the actual data. This data represents the actions the client wants to take with the program.

The client will create data structures (often Structs in Rust) and serialize them into this array of bytes, which the receiving program will then deserialize and make sense of.

Without Anchor, there's no real convention to this. It's up to the developer which serialization/deserialization strategy to use. This gives unlimited flexibility but makes it harder to read/write instructions when no convention is followed.

Let's look at the example Solana gives for their basic Rust program:

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

entrypoint!(process_instruction);

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

All Solana programs have a single entrypoint defined by the entrypoint! macro.

Here's what a client to this program would look like in a test:

#[cfg(test)]
mod test {
    use solana_program_test::*;
    use solana_sdk::{
        instruction::Instruction,
        pubkey::Pubkey,
        signature::Signer,
        transaction::Transaction,
    };

    #[tokio::test]
    async fn test_hello_world() {
        let program_id = Pubkey::new_unique();
        let mut program_test = ProgramTest::default();

        program_test.add_program("hello_world", program_id, None);

        let (mut banks_client, payer, recent_blockhash) = program_test.start().await;

        // Create instruction
        let instruction = Instruction {
            program_id,
            accounts: vec![],
            data: vec![],
        };

        // Create transaction with instruction
        let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey()));

        // Sign transaction
        transaction.sign(&[&payer], recent_blockhash);

        let transaction_result = banks_client.process_transaction(transaction).await;
        assert!(transaction_result.is_ok());
    }
}

Here we manually create the Instruction and Transaction, and run that transaction through the banks_client which simulates the Solana runtime and runs it in-memory.

The banks client is a special type of client used only for testing. It's not as powerful as simulating with a real client but is useful for quickly simulating a certain state on the blockchain before your test runs.

For a deeper look at how a real program would deserialize and parse the transaction data, see their example.

Instructions in anchor

Now let's take a look at similar Anchor examples and break down what's happening.

use anchor_lang::prelude::*;

declare_id!("6khKp4BeJpCjBY1Eh39ybiqbfRnrn2UzWeUARjQLXYRC");

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

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let counter = &ctx.accounts.counter;
        msg!("Counter account created! Current count: {}", counter.count);
        Ok(())
    }

    pub fn increment(ctx: Context<Increment>) -> Result<()> {
        let counter = &mut ctx.accounts.counter;
        msg!("Previous counter: {}", counter.count);

        counter.count += 1;
        msg!("Counter incremented! Current count: {}", counter.count);
        Ok(())
    }
}

What is this Context that seems to have super powers?

In Anchor, a lot of the lower down concepts like Transaction and Instruction get abstracted away. Instead we get a very declarative experience powered by macros, which do a ton of heavy lifting.

For example, what does a line like the one below do?

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

We're telling Anchor that we want to use a set of account constraints via the Initialize struct. We want it to auto-validate the constraints before running any logic.

Every Solana program is stateless, all state is passed in via accounts which act as buffers of data. They hold the state your program is acting on.

Context wraps our structs and provides the additional metadata and checks for things like access control (can this person send this SOL?) and validations.

The Initialize struct itself will #[derive(Accounts)] which will implement important traits for handling serialization, deserialization and validations on the types of accounts we can pass the program

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,

    #[account(
        init,
        payer = payer,
        space = 8 + 8
    )]
    pub counter: Account<'info, Counter>,
    pub system_program: Program<'info, System>,
}

In this struct we're telling Anchor to apply constraints to these accounts.

The #[account(init, payer = payer, space = 8 + 8)] line tells Anchor a few things:

  1. First the init argument tells Anchor that it will be creating this account.
  2. The next line says to use the payer account to fund it. Whereas the space argument tells Anchor to reserve 8 bytes for the discriminator (bytes that reflect the type of the following set of bytes), and 8 additional bytes for tracking the state of the u64 counter used in the example.
  3. Finally, the line pub system_program: Program<'info, System> is necessary to call system_instruction::create_account later in the code, as well as acting as a validator that the program is actually the correct program (by checking the ID).

So, Context is a wrapper around the accounts that are required to run a given instruction, say for initialize or increment.

We're also telling it to automatically deserialize the account data (ex. Account<'info, Counter>) so we can avoid manually translating from the bytes to a struct using borsh, serde or similar like we have to do in plain old Solana development.

#[derive(Accounts)]
pub struct Increment<'info> {
    #[account(mut)]
    pub counter: Account<'info, Counter>,
}

Here we are telling anchor that the account we pass in the counter slot is going to deserialize into a Counter struct.

#[account]
pub struct Counter {
    pub count: u64,
}

So to sum it up, you can think of the #derive(Accounts) and #[account(...)] macros as defining the structure and validation logic that Anchor uses to build the Context<T> at runtime.

In essence they provide:

By providing this information up-front on the program, Anchor is able to construct IDLs, which provide a schema to work with as a client.

Okay that's great that Context provides so much utility, but where are my Instructions?

This is where the IDL ties into everything, and is where we take a look at a client in Anchor.

Inside the client code we would find a line like this:

declare_program!(something);

This macro does a lot of heavy lifting, and will generate the client for us automatically.

Take for an example this snippet which initializes an account:

  let initialize_ix = program
      .request()
      .accounts(accounts::Initialize {
          counter: counter.pubkey(),
          payer: program.payer(),
          system_program: system_program::ID,
      })
      .args(args::Initialize)
      .instructions()?
      .remove(0);

It's the IDL that gives us the accounts::Initialize {} struct.

So while we don't see any explicit/manual handling of Instruction and Transaction like we do in plain old Solana, Anchor instead is providing us the structs we need to use to interact with the program, and the parsing into Instructions and the greater Transaction is done automatically and without boilerplate.