🧑‍💻

#139 Generating IDL with Codama-RS in a Solana Program

に公開

Introduction

In this post, I will walk through how to generate an Interface Definition Language (IDL) file using the codama-rs crate for a Solana program. IDL files are essential for interacting with Solana smart contracts in a structured way, especially when building SDKs or front-end integrations.

We will use my whitelist project as an example. I’ll demonstrate how I successfully generated an IDL from the existing Rust-based Solana program.

What is codama-rs?

Codama-RS is a crate that helps generate IDL files from Solana programs. It automates the process of defining the structure of accounts, instructions, and errors within the program.

Step 1: Setting Up the Project

First, create the project:

cargo new --lib sample
cd sample

We are going to create 4 folders:

cargo new --lib core
cargo new --lib program
cargo new --lib sdk
cargo new --lib codama-cli

Each module has a distinct role:

  • core: Contains the foundational account structures
  • program: Implements the Solana smart contract logic
  • sdk: Provides an interface to interact with the program (e.g., client-side tools).
  • codama-cli: A utility for generating the IDL file.

Setting Up Dependencies

To install necessary dependencies, we are going to define like this in Cargo.toml

toml
[workspace]
members = [
        "codama-cli",
        "core",
        "program",
        "sdk"
]

resolver = "2"

[workspace.dependencies]
anchor-lang = { git = "https://github.com/coral-xyz/anchor", rev = "96ed3b791c6fed9ab64cb138397795fe55991280", features = ["idl-build"] }
borsh = { version = "0.10.3" }
bytemuck = { version = "1.16.3", features = ["min_const_generics"] }
codama = "0.3.0"
jito-account-traits-derive = { git = "https://github.com/jito-foundation/restaking.git", rev = "9efa55c30b3427d94975b22dcd27a82181c776ba" }
jito-bytemuck = { git = "https://github.com/jito-foundation/restaking.git", rev = "9efa55c30b3427d94975b22dcd27a82181c776ba" }
jito-jsm-core = { git = "https://github.com/jito-foundation/restaking.git", rev = "9efa55c30b3427d94975b22dcd27a82181c776ba" }
num-derive = "0.4.2"
num-traits = "0.2.19"
serde = { version = "1.0.216", features = ["derive"] }
serde-big-array = "0.5.1"
serde_json = "1.0.133"
serde_with = "3.9.0"
solana-pubkey = "~2.1"
solana-program = "~2.1"
solana-sdk = "~2.1"
thiserror = "1.0.57"
vault-whitelist-core = { path = "./core" }
vault-whitelist-sdk = { path = "./sdk" }

This setup ensures that our workspace is properly structured and dependencies are shared across modules.

Step 2: Core Module

The core module defines the foundational account structures used in our Solana program.

Defining the Config Account

The Config account stores important state data for our program:

#[derive(Debug, Clone, Copy, Zeroable, CodamaAccount, Pod, AccountDeserialize)]
#[repr(C)]
pub struct Config {
    pub vault: Pubkey,
}

This struct represents a persistent on-chain account.

Implementing Helper Methods

To make Config easier to work with, we define for initialization, deriving addresses, and loading accounts.

impl Config {
    /// Initiallize Config
    pub fn new(vault: Pubkey) -> Self {
        Self { vault }
    }

    /// Seeds of Config Account
    pub fn seeds(ncn: &Pubkey) -> Vec<Vec<u8>> {
        vec![b"config".to_vec(), ncn.to_bytes().to_vec()]
    }

    /// Find the program address of Config Account
    pub fn find_program_address(program_id: &Pubkey, ncn: &Pubkey) -> (Pubkey, u8, Vec<Vec<u8>>) {
        let seeds = Self::seeds(ncn);
        let seeds_iter: Vec<_> = seeds.iter().map(|s| s.as_slice()).collect();
        let (pda, bump) = Pubkey::find_program_address(&seeds_iter, program_id);
        (pda, bump, seeds)
    }

    /// Load Config Account
    pub fn load(
        program_id: &Pubkey,
        account: &AccountInfo,
        ncn: &Pubkey,
        expect_writable: bool,
    ) -> Result<(), ProgramError> {
        if account.owner.ne(program_id) {
            msg!("Config account has an invalid owner");
            return Err(ProgramError::InvalidAccountOwner);
        }
        if account.data_is_empty() {
            msg!("Config account data is empty");
            return Err(ProgramError::InvalidAccountData);
        }
        if expect_writable && !account.is_writable {
            msg!("Config account is not writable");
            return Err(ProgramError::InvalidAccountData);
        }
        if account.data.borrow()[0].ne(&Self::DISCRIMINATOR) {
            msg!("Config account discriminator is invalid");
            return Err(ProgramError::InvalidAccountData);
        }
        if account
            .key
            .ne(&Self::find_program_address(program_id, ncn).0)
        {
            msg!("Config account is not at the correct PDA");
            return Err(ProgramError::InvalidAccountData);
        }
        Ok(())
    }
}

These methods ensure that the Config account is corerctly derived and loaded.

Step 3: SDK Module

The sdk module defines the program's instructions and errors.

Defining Instructions

The VaultWhiteListInstruction enum represents the program's callable functions.

instruction.rs

use borsh::{BorshDeserialize, BorshSerialize};
use codama::{codama, CodamaInstruction, CodamaInstructions};

#[derive(Debug, BorshSerialize, BorshDeserialize, CodamaInstructions)]
pub enum VaultWhitelistInstruction {
    #[codama(account(name = "config_info", writable))]
    #[codama(account(name = "vault_info"))]
    #[codama(account(name = "vault_admin_info", writable, signer))]
    #[codama(account(name = "system_program"))]
    InitializeConfig,
}

The codama macro helps define which accounts the instruction interacts with.

Defining Errors

error.rs

use codama::CodamaErrors;
use solana_program::program_error::ProgramError;
use thiserror::Error;

#[derive(Debug, Error, PartialEq, Eq, CodamaErrors)]
pub enum VaultWhitelistError {
    #[error("ArithmeticOverflow")]
    ArithmeticOverflow = 3000,

    #[error("ArithmeticUnderflow")]
    ArithmeticUnderflow,

    #[error("DivisionByZero")]
    DivisionByZero,
}

impl From<VaultWhitelistError> for ProgramError {
    fn from(e: VaultWhitelistError) -> Self {
        Self::Custom(e as u32)
    }
}

impl From<VaultWhitelistError> for u64 {
    fn from(e: VaultWhitelistError) -> Self {
        e as Self
    }
}

impl From<VaultWhitelistError> for u32 {
    fn from(e: VaultWhitelistError) -> Self {
        e as Self
    }
}

This ensures structured error handling in our program.

Step 4: Program Module

The program module contains the actual Solana smart contract logic.

Processing the InitializeConfig Instruction

initialize_config.rs

use jito_bytemuck::{AccountDeserialize, Discriminator};
use jito_jsm_core::{
    create_account,
    loader::{load_signer, load_system_account, load_system_program},
};
use solana_program::{
    account_info::AccountInfo, entrypoint::ProgramResult, msg, program_error::ProgramError,
    pubkey::Pubkey, rent::Rent, sysvar::Sysvar,
};
use vault_whitelist_core::config::Config;
use vault_whitelist_sdk::error::VaultWhitelistError;

pub fn process_initialize_config(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
    let [config_info, vault_info, vault_admin_info, system_program_info] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    load_system_account(config_info, true)?;
    load_signer(vault_admin_info, true)?;
    load_system_program(system_program_info)?;

    // The Config account shall be at the canonical PDA
    let (config_pubkey, config_bump, mut config_seeds) =
        Config::find_program_address(program_id, vault_info.key);
    config_seeds.push(vec![config_bump]);
    if config_pubkey.ne(config_info.key) {
        msg!("Config account is not at the correct PDA");
        return Err(ProgramError::InvalidAccountData);
    }

    msg!(
        "Initializing Vault Whitelist at address {}",
        config_info.key
    );
    create_account(
        vault_admin_info,
        config_info,
        system_program_info,
        program_id,
        &Rent::get()?,
        8_u64
            .checked_add(std::mem::size_of::<Config>() as u64)
            .ok_or(VaultWhitelistError::ArithmeticOverflow)?,
        &config_seeds,
    )?;

    let mut config_data = config_info.try_borrow_mut_data()?;
    config_data[0] = Config::DISCRIMINATOR;
    let config_acc = Config::try_from_slice_unchecked_mut(&mut config_data)?;
    *config_acc = Config::new(*vault_info.key);

    Ok(())
}

This ensures the Config account is correctly initialized.

lib.rs

use borsh::BorshDeserialize;
use initialize_config::process_initialize_config;
use solana_program::{
    account_info::AccountInfo, declare_id, entrypoint::ProgramResult, msg,
    program_error::ProgramError, pubkey::Pubkey,
};
use vault_whitelist_sdk::instruction::VaultWhitelistInstruction;

pub mod initialize_config;

declare_id!(env!("VAULT_WHITELIST_PROGRAM_ID"));

#[cfg(not(feature = "no-entrypoint"))]
solana_program::entrypoint!(process_instruction);

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    if *program_id != id() {
        return Err(ProgramError::IncorrectProgramId);
    }

    let instruction = VaultWhitelistInstruction::try_from_slice(instruction_data)?;

    match instruction {
        VaultWhitelistInstruction::InitializeConfig => {
            msg!("Instruction: InitializeConfig");
            process_initialize_config(program_id, accounts)
        }
    }
}

Step 5: Codama Command Line

Finally, we use codama-rs to generate the IDL.

use std::{fs::File, io::Write, path::Path};

use codama::{Codama, NodeTrait};

fn main() {
    let program_path = Path::new("./program");
    let core_path = Path::new("./core");
    let sdk_path = Path::new("./sdk");

    let codama = Codama::load_all(&[program_path, core_path, sdk_path]).unwrap();
    let idl = codama.get_idl().unwrap().to_json_pretty().unwrap();

    let crate_root = std::env::current_dir().unwrap();
    let out_dir = crate_root.join("idl");
    let mut idl_path = out_dir.join("vault_whitelist");
    idl_path.set_extension("json");

    let mut idl_json_file = File::create(idl_path).unwrap();
    idl_json_file.write_all(idl.as_bytes()).unwrap();
}

This script scans the project, generates the IDL, and saves it as vault_whitelist.json.

Conclusion

Using codama-rs, we can effortlessly generate IDL files for our Solana programs. This simplifies integration with front-end clients and SDKs, improving developer experience.

Discussion