how to write a contract in anchor
Preface
In this sort of tutorial style blog we will try to learn how to write a solana contract or a solana program as they like to say it.
for this we will chose the infamous Anchor framework, as it is the most beginnner friendly well not so much, is it?.
this blog is mainly for people who are already trying to learn anchor and thinking and searching of a guide on how to think about writing it.
pre-requisite
- you must know the basics of solana
- that includes:
- you know the solana accounts model,
- solana transactions and gas fee.
If you are not ready with these pre-requisites i’d suggest look into the official solana docs, they have done a really good job on documentation .
What are Stablecoins Stablecoins like USDC, USDT, and DAI are cryptocurrencies designed to maintain a stable value relative to a reference asset (usually USD). Unlike volatile cryptocurrencies, stablecoins provide the stability needed for practical use in DeFi applications. Our stablecoin will follow the over-collateralized model:
- Deposit Collateral: Users deposit SOL as collateral
- Mint Stablecoins: Users can mint stablecoins worth less than their collateral value
- Over-collateralization: Require 150% collateral ratio (deposit $150 SOL to mint $100 stablecoin)
- Liquidation Mechanism: If collateral value drops too low, positions can be liquidated to maintain system health
- Oracle Integration: Use Pyth oracles for real-time price feeds
- Administrative Controls: Config management and emergency functions
Now let’s dive into the actual implementation!
Setting Up The Project Structure
When you start an Anchor project, you’ll notice it creates a specific structure. For our stablecoin, here’s how I organized the code:
└── programs/
└── stablecoin/
├── src/
│ ├── constants.rs // All our magic numbers
│ ├── lib.rs // Main program entry point
│ ├── state.rs // Account structures
│ └── instructions/ // All our instruction logic
│ ├── admin/ // Admin functions
│ └── user/ // User functions
This structure helps keep things organized. Trust me, you don’t want all your code in one giant file!
The Anchor Mental Model
Before diving into code, understand that every Anchor program follows this pattern:
- lib.rs - Your program’s public API (the instructions users can call)
- state.rs - Your data structures (what gets stored on-chain)
- instructions/ - The business logic for each operation
- Account Validation - Ensuring security through the type system
Think of it like a web API: lib.rs defines your endpoints, state.rs defines your database schema, and instructions/ contains your route handlers.
Understanding lib.rs: Your Program’s Interface
declare_id!("YourProgramIdHere111111111111111111111111111");
#[program]
pub mod stablecoin {
use super::*;
pub fn initialize_config(
ctx: Context<InitializeConfig>,
liquidation_threshold: u64,
liquidation_bonus: u64,
min_health_factor: u64,
) -> Result<()> {
process_initialize_config(ctx, liquidation_threshold, liquidation_bonus, min_health_factor)
}
pub fn deposit_collateral(ctx: Context<DepositCollateral>, amount: u64) -> Result<()> {
process_deposit_collateral(ctx, amount)
}
pub fn mint_stablecoin(ctx: Context<MintStablecoin>, amount: u64) -> Result<()> {
process_mint_stablecoin(ctx, amount)
}
pub fn liquidate(
ctx: Context<Liquidate>,
amount_to_repay: u64,
) -> Result<()> {
process_liquidate(ctx, amount_to_repay)
}
}
Key Pattern: Every function takes a Context<T> as the first parameter. This Context contains all the accounts needed for that instruction. Additional parameters are the instruction data.
Delegation Pattern: Keep lib.rs clean by delegating to process_* functions. This makes your code maintainable and testable.
State Management: The #[account] Macro
#[account]
#[derive(InitSpace, Debug)]
pub struct Config {
pub authority: Pubkey, // 32 bytes
pub mint: Pubkey, // 32 bytes
pub liquidation_threshold: u64, // 8 bytes
pub liquidation_bonus: u64, // 8 bytes
pub min_health_factor: u64, // 8 bytes
pub emergency_mode: bool, // 1 byte
pub bump: u8, // 1 byte
pub bump_mint_account: u8, // 1 byte
}
What #[account] Does:
- Adds 8-byte discriminator - Identifies the account type
- Implements serialization - Handles data storage/retrieval
- Adds owner validation - Ensures your program owns the account
- Space calculation - Works with
InitSpaceto calculate required space
When to Use Each Derive:
InitSpace- Always use this for automatic space calculationDebug- For development and testingPartialEq- When you need to compare accountsDefault- When you want default values
Account Types: Choosing the Right Tool
Account<‘info, T> - Go-To Choice
#[account(mut)]
pub config: Account<'info, Config>,
Working with custom program accounts
InterfaceAccount<‘info, T> - For SPL Tokens
#[account(mut)]
pub mint_account: InterfaceAccount<'info, Mint>,
#[account(
mut,
token::mint = mint_account,
token::authority = user
)]
pub token_account: InterfaceAccount<'info, TokenAccount>,
when to use: Working with SPL tokens (mints, token accounts). Supports both Token and Token2022 programs.
SystemAccount<‘info> - For Native SOL
#[account(mut)]
pub sol_vault: SystemAccount<'info>,
when to use: Storing or transferring native SOL
Signer<‘info> - For Transaction Signers
#[account(mut)]
pub authority: Signer<'info>,
when to use: Account must have signed the transaction
UncheckedAccount<‘info> - Use Sparingly
/// CHECK: Validated by Pyth SDK
pub pyth_price_feed: UncheckedAccount<'info>,
when to use: Working with external programs where you’ll validate manually. Always add a comment explaining why it’s safe (it the best practise).
The #[account] Constraint System: Your Security Layer
This is where Anchor really shines. Instead of manual validation, you declare your requirements:
Initialization Constraints
#[account(
init, // Create new account
payer = authority, // Who pays rent
space = 8 + Config::INIT_SPACE, // How much space
seeds = [SEED_ACCOUNT], // PDA seeds
bump, // Canonical bump
)]
pub config: Account<'info, Config>,
Relationship Validation
#[account(
mut,
has_one = authority @ ErrorCode::Unauthorized, // config.authority == authority
has_one = depositor_addr @ ErrorCode::WrongUser, // collateral.depositor_addr == depositor_addr
)]
pub collateral: Account<'info, Collateral>,
Custom Business Logic
#[account(
mut,
constraint = collateral.is_initialized @ ErrorCode::NotInitialized,
constraint = amount > MIN_DEPOSIT @ ErrorCode::DepositTooSmall,
constraint = !config.emergency_mode @ ErrorCode::EmergencyActive,
)]
pub collateral: Account<'info, Collateral>,
Token-Specific Constraints
#[account(
mut,
token::mint = mint_account, // Must be associated with this mint
token::authority = user, // User must own this token account
)]
pub user_token_account: InterfaceAccount<'info, TokenAccount>,
#[account(
init,
payer = authority,
mint::decimals = 9, // 9 decimal places
mint::authority = config, // Config account can mint
mint::freeze_authority = config, // Config account can freeze
)]
pub mint: InterfaceAccount<'info, Mint>,
PDA (Program Derived Address) Constraints
#[account(
mut,
seeds = [SEED_COLLATERAL, user.key().as_ref()], // Deterministic address
bump = collateral.bump, // Stored bump value
)]
pub collateral: Account<'info, Collateral>,
Practical Example: Deposit Collateral Instruction
Let’s see how all these concepts work together:
#[derive(Accounts)]
pub struct DepositCollateral<'info> {
#[account(mut)]
pub depositor: Signer<'info>,
#[account(
mut,
seeds = [SEED_COLLATERAL, depositor.key().as_ref()],
bump = collateral_account.bump,
has_one = depositor_addr @ ErrorCode::DepositorMismatch,
constraint = collateral_account.is_initialized @ ErrorCode::NotInitialized
)]
pub collateral_account: Account<'info, Collateral>,
#[account(
mut,
seeds = [SEED_SOL_VAULT, depositor.key().as_ref()],
bump = collateral_account.bump_sol_account,
)]
pub sol_account: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
pub fn process_deposit_collateral(ctx: Context<DepositCollateral>, amount: u64) -> Result<()> {
// Input validation
require!(amount >= MIN_DEPOSIT_AMOUNT, ErrorCode::DepositTooSmall);
// Safe math - always use checked operations
let new_balance = ctx.accounts.collateral_account.lamport_balance
.checked_add(amount)
.ok_or(ErrorCode::Overflow)?;
// Cross-program invocation (CPI) to transfer SOL
let cpi_context = CpiContext::new(
ctx.accounts.system_program.to_account_info(),
anchor_lang::system_program::Transfer {
from: ctx.accounts.depositor.to_account_info(),
to: ctx.accounts.sol_account.to_account_info(),
},
);
anchor_lang::system_program::transfer(cpi_context, amount)?;
// Update state only after successful transfer
ctx.accounts.collateral_account.lamport_balance = new_balance;
Ok(())
}
Breaking Down the Security:
- depositor: Must be a signer (they initiated the transaction)
- collateral_account: Must be the correct PDA for this user, must be initialized
- sol_account: Must be the correct vault PDA for storing SOL
- Validation: Amount must meet minimum, math must not overflow
- State update: Only happens after successful transfer
Essential Patterns Every Contract Uses
1. The PDA Pattern
// Constants
pub const SEED_COLLATERAL: &[u8] = b"collateral";
// In your account structure
#[account(
seeds = [SEED_COLLATERAL, user.key().as_ref()],
bump = collateral.bump, // Store the bump!
)]
pub collateral: Account<'info, Collateral>,
// In your state
pub struct Collateral {
pub bump: u8, // Always store bumps
// ... other fields
}
Why: PDAs give you deterministic addresses that your program controls
2. The CPI (Cross-Program Invocation) Pattern
// For system program calls
let cpi_context = CpiContext::new(
ctx.accounts.system_program.to_account_info(),
anchor_lang::system_program::Transfer {
from: user.to_account_info(),
to: vault.to_account_info(),
},
);
anchor_lang::system_program::transfer(cpi_context, amount)?;
// For token program calls
let cpi_context = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
anchor_spl::token_interface::MintTo {
mint: mint.to_account_info(),
to: token_account.to_account_info(),
authority: config.to_account_info(),
},
);
anchor_spl::token_interface::mint_to(cpi_context, amount)?;
3. The PDA Signer Pattern
// When your program needs to sign (e.g., as mint authority)
let seeds = &[SEED_CONFIG, &[config.bump]];
let signer = &[&seeds[..]];
let cpi_context = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
MintTo { /* accounts */ },
signer, // Program signs with PDA
);
4. The Safe Math Pattern
// NEVER do this
let result = a + b; // Can overflow!
// ALWAYS do this
let result = a.checked_add(b).ok_or(ErrorCode::Overflow)?;
// For percentage calculations (avoid floating point)
let collateral_ratio = collateral_value
.checked_mul(100)
.ok_or(ErrorCode::Overflow)?;
require!(
collateral_ratio >= debt_value.checked_mul(REQUIRED_RATIO)?,
ErrorCode::InsufficientCollateral
);
Common Mistakes and How to Avoid Them
Don’t: Skip input validation
pub fn bad_function(ctx: Context<Example>, amount: u64) -> Result<()> {
// Directly using amount without validation
ctx.accounts.balance += amount; // What if amount is 0? Or MAX_U64?
Ok(())
}
Do: Validate everything
pub fn good_function(ctx: Context<Example>, amount: u64) -> Result<()> {
require!(amount > 0, ErrorCode::InvalidAmount);
require!(amount <= MAX_DEPOSIT, ErrorCode::ExceedsLimit);
let new_balance = ctx.accounts.balance
.checked_add(amount)
.ok_or(ErrorCode::Overflow)?;
ctx.accounts.balance = new_balance;
Ok(())
}
Don’t: Forget to store bump seeds
#[account]
pub struct BadConfig {
pub authority: Pubkey,
// Missing bump! How will you recreate the PDA later?
}
Do: Always store bumps
#[account]
pub struct GoodConfig {
pub authority: Pubkey,
pub bump: u8, // Essential for PDA recreation
}
Don’t: Use floating point math
let ratio = (collateral as f64) / (debt as f64); // Precision issues!
Do: Use cross multiplication
let collateral_ratio = collateral.checked_mul(100)?;
require!(collateral_ratio >= debt.checked_mul(150)?, ErrorCode::InsufficientCollateral);
Testing Your Understanding
Here’s a quick mental checklist when reading any Anchor contract:
- lib.rs: What instructions can users call?
- State structs: What data is stored? Are bumps stored?
- Account constraints: How is security enforced?
- Account types: Are the right types used for each use case?
- Validation: Is input validated? Math checked for overflow?
- CPIs: Are external program calls handled correctly?
Beyond the Basics
Once you master these fundamentals, you’ll want to explore:
- Events and logging for off-chain indexing
- Upgradeability patterns for contract evolution
- Oracle integration for external data
- Advanced PDA patterns for complex relationships
- Optimization techniques for transaction costs
Complete Code Reference
The full stablecoin implementation with all advanced features (liquidation, oracles, emergency controls) is available on GitHub: [https://github.com/rajiknows/stablecoin]
This tutorial covered the essential 80% of concepts you’ll use in 95% of contracts. Master these patterns, and you’ll be able to read, write, and understand any Anchor codebase with confidence.
Final Thoughts
Anchor’s power lies in its constraint system - you declare your security requirements, and the framework enforces them. This shifts your thinking from “How do I validate this?” to “What should be true for this operation to be safe?”
Once you internalize this mindset, writing Solana programs becomes about modeling your business logic with the right account structures and constraints. The framework handles the rest.
Now go build something amazing!