Anatomy of Solana Program Invocations using Anchor

·

5 min read

Anatomy of Solana Program Invocations using Anchor

This article has been written with Anchor v0.24.x in mind. We will endeavour to keep this information updated as things evolve.

Anchor framework is quickly becoming the de facto approach to building Solana on-chain programs. Its abstractions offer security and ergonomics that a typical Solana developer may miss while chewing glass.

Programs are called either by clients off-chain or by other programs on-chain, i.e. Cross-Program Invocations (CPIs). This unlocks composability, the key to building sophisticated solutions from small battle-tested building blocks.

Anchor simplifies how programs receive invocations or invoke others. One can simply read through the book and learn how to use these features; however, it is less obvious how it all hangs together.

Abstractions are great for productivity but to reach an expert level in any framework, it is essential to understand what happens under the hood. This post will serve as the proverbial popping up the hood and having a poke around.


Invoking a Program

Each instruction, in a transaction submitted to Solana Runtime, is made up of a program_id, an accounts array and an instruction_data byte array. The Runtime invokes the given program, passing in the accounts and instruction data as input.

Anatomy of a Solana transaction

This makes sense if we look at what a standard Solana program's entry point looks like:

entrypoint!(process_instruction);

fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    // TODO check program_id
    // TODO determine which method to call
    // TODO deserialise instruction_data
    // TODO deserialise accounts
    // TODO validate accounts
    // TODO map accounts into an ergonomic struct
    // TODO perform business function
    // TODO map errors if needed
}

Things get more complicated once we have to implement the body of this function. I have suggested some actions that a typical program may take with the help of some TODOs. A lot to needs to be built and it has to be done for each and every program you write.

This is where Anchor comes in, picking up all that slack. Check out this Anchor program, which we will use as an example for the rest of this post:

declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");

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

    pub fn do_something<'info>(
        ctx: Context<DoSomething<'info>>, 
        x: u8
    ) -> Result<()> {
        ...
    }

    pub fn do_another_thing<'info>(
        ctx: Context<DoAnotherThing<'info>>,
    ) -> Result<()> {
        ...
    }
}

#[derive(Accounts)]
pub struct DoSomething<'info> {
    account: Account<'info, TokenAccount>,
    token_program: Program<'info, Token>,
    rent: Sysvar<'info, Rent>,
}

#[derive(Accounts)]
pub struct DoAnotherThing<'info> {
    ...
}

This program defines two different entry points to the program. There are corresponding structs for all the input accounts and do_something() even receives an argument x.

It may not look like it but this Anchor program is implementing all those TODOs I mentioned earlier.

Solana program vs Anchor program

The Anchor program specifies the program's ID to help to ensure that the program binary is only run at the given address. This is checked as soon as the instruction arrives at the program. I would have preferred if the ID was specified in the config, rather than the code, but here we are.

Another interesting difference with the Solana program is how Anchor allows you to define multiple methods within the program. Anchor achieves this by encoding each method's signature into a unique SHA256 hash and storing it at the first 8 bytes of instruction_data.

So when a new instruction arrives at the entry point, the program compares the hash to each method signature and dispatches the instruction to the relevant method.

ℹ️ Anchor also supports a fallback method, for cases where the incoming instruction does not match any of the defined methods. Simply create a new method without the Context argument and it will be designated as the fallback one.

Once the instruction is routed to the correct method, the provided accounts and instruction_data arrays are deserialised and passed as the Context and the arguments accordingly.

Again, the first 8 bytes of each provided account are used to distinguish it from others and map correctly into the struct.

Anchor serialisation

Helpfully, Anchor also performs further checks based on the provided account types and constraints to protect against incorrect and/or malicious input. Some of these include whether an account is a token account or is associated with an expected mint.

Cross-Program Invocation

Now that we understand how a program receives instructions, let's see what it takes to invoke from another program.

If we were to invoke the above Anchor program in plain Solana, we would do something similar to below:

// prepare instruction data
struct DoSomething {
    x: u8,
}
let instruction_data: Vec<u8> = sighash_of_the_method();
instruction_data.append(BorshSerialize.try_to_vec(DoSomething { x: 2 }));

let accounts = vec![
    AccountMeta::new(*token_account, false),
    AccountMeta::new_readonly(*signer, true),
    AccountMeta::new_readonly(*rent, false),
];

// prepare account infos
let account_infos = vec![
    token_account.info.clone(),
    signer.info.clone(),
    rent.info.clone(),
];

// prepare the instruction
let instruction = solana_program::instruction::Instruction {
    program_id: *program_id,
    accounts,
    instruction_data,
};

// invoke the program, providing the instruction
solana_program::program::invoke_signed(
   &instruction, 
   &account_infos, 
   signer_seeds
)?

Predictably, we create an instruction, made up of the program ID, an array of accounts and an instruction data byte array. This instruction is then signed and submitted to the Runtime, along with the list of accounts.

You may note that we also need to pack the instructions data as a byte array, remembering to include the corresponding method hash at the beginning.

Over in Anchor land, things seem much simpler:

let accounts = hello_world::cpi::accounts::DoSomething {
    account: ..., 
    token_program: ..., 
    rent: ...,
}

let context = CpiContext::new(program_id, accounts);
hello_world::cpi::do_something(context, x);

This is because Anchor macros generate a lot of the boiler-plate for us.

Solana CPI vs Anchor CPI

Among those are:

  • hello_world::cpi::accounts::DoSomething that normalises our Account structs into AccountInfo types so they can be plugged into the CPI call
  • hello_world::cpi:do_something(), which essentially performs the same steps as seen in our plain Solana CPI call
  • CpiContext that knows how to convert our accounts to AccountMeta or AccountInfo, as required

That concludes this quick deep-dive into Solana and Anchor program invocation. It goes without saying that this is a small part of what Anchor can do for us. To achieve that next level of proficiency and self-sufficiency, it is worthwhile to dig into each of these features.

Watch this space for more posts about that!