#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
[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