Solana Smart Contract Development with Rust & Anchor
Solana programs reward a specific kind of thinking: the account model comes first, execution comes second, and compute budget is a real constraint you budget for. Get those three right and writing Solana smart contracts stops feeling alien.
This guide is what I wish I had read before writing my first production program. It walks the whole journey - account model, Anchor patterns, compute budget, CPIs, then two concrete case studies: a Web3 casino game engine and a Solana trading / routing program.
The three mental models you need
1. Accounts hold state, programs are stateless
Every piece of durable state lives in an account. Programs are pure code, owned
by a ProgramId, that operate on accounts passed in by the caller.
You do not "store" anything in a Solana program. You write code that mutates accounts whose ownership it controls.
That reframing changes how you design. Instead of "where do I put this state?" you ask "what PDA owns this? what seeds? who can sign for it?".
2. Everything in an instruction is explicit
Every account that will be read or written must be listed in the instruction.
There is no dynamic lookup mid-execution (except via remaining_accounts, which
is power-user territory). That is a feature: it makes execution parallelizable
and makes the surface area of each call visible.
3. Compute units are a bounded resource
You get ~200k CU per instruction, 1.4M per transaction. Every CPI, every large deserialization, every copy costs budget. Exceed it and the tx fails.
Anchor: the patterns that matter
Anchor is the default framework and worth using. The core patterns:
State declaration
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod prediction_market {
use super::*;
pub fn initialize_market(
ctx: Context<InitializeMarket>,
question: String,
close_ts: i64,
) -> Result<()> {
let m = &mut ctx.accounts.market;
m.authority = ctx.accounts.authority.key();
m.question = question;
m.close_ts = close_ts;
m.status = MarketStatus::Open;
m.bump = ctx.bumps.market;
Ok(())
}
}
#[account]
pub struct Market {
pub authority: Pubkey,
pub question: String,
pub close_ts: i64,
pub status: MarketStatus,
pub bump: u8,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq)]
pub enum MarketStatus {
Open,
Closed,
Resolved,
Invalid,
}Account validation
This is where Anchor earns its keep:
#[derive(Accounts)]
#[instruction(question: String)]
pub struct InitializeMarket<'info> {
#[account(
init,
payer = authority,
space = 8 + Market::MAX_SIZE,
seeds = [b"market", authority.key().as_ref(), question.as_bytes()],
bump,
)]
pub market: Account<'info, Market>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}init + seeds + bump together mean: "this is a brand-new PDA, derived from
these seeds, and I want Anchor to reject any call that doesn't match." That one
line of validation replaces a dozen lines of manual checks.
Errors with meaning
#[error_code]
pub enum Err {
#[msg("Market is not open")]
NotOpen,
#[msg("Market already resolved")]
AlreadyResolved,
#[msg("Unauthorized")]
Unauthorized,
#[msg("Arithmetic overflow")]
Overflow,
}Then gate instructions with constraints that reference these:
#[account(
mut,
has_one = authority @ Err::Unauthorized,
constraint = market.status == MarketStatus::Open @ Err::NotOpen,
)]
pub market: Account<'info, Market>,Compute-budget hygiene
Practical rules I follow:
- Don't
.clone()or.to_owned()on hot paths. Serialization/deserialization dominates CU on anything touching a large account. - Use
zero_copyfor orderbooks, game tables, anything > ~1 KB. Zero-copy accounts deserialize in O(1) and let you mutate fields without round-trips. - Budget CPIs. Each CPI costs ~1–2k CU of overhead before the called program does anything. Batch where possible.
- Request extra CU explicitly when you know the tx needs it:
const ix = ComputeBudgetProgram.setComputeUnitLimit({ units: 600_000 });
tx.add(ix);Cross-program invocations done right
CPIs are how you talk to SPL-Token, Metaplex, or another of your own programs. The two shapes:
Signed by a user
token::transfer(
CpiContext::new(
token_program.to_account_info(),
Transfer {
from: user_ata.to_account_info(),
to: market_vault.to_account_info(),
authority: user.to_account_info(),
},
),
amount,
)?;Signed by a PDA (the program itself signs)
let seeds = &[b"vault", market.key().as_ref(), &[vault_bump]];
let signer = &[&seeds[..]];
token::transfer(
CpiContext::new_with_signer(
token_program.to_account_info(),
Transfer {
from: market_vault.to_account_info(),
to: winner_ata.to_account_info(),
authority: vault_authority.to_account_info(),
},
signer,
),
payout,
)?;If you catch yourself reaching for invoke_signed directly, there is almost
always an Anchor helper that does the same thing with fewer footguns.
Case study 1: a Web3 casino game engine
The shape of a casino game (coinflip, dice, crash) on Solana:
- Round account per round, PDA-derived from a monotonically increasing counter.
- Bet accounts per player, with
close = playerso lamports return on claim. - VRF request account bridging to the randomness oracle (Switchboard On-Demand or your own VRF).
- State machine per round:
Open → Locked → Rolled(result) → Claimable.
Key pattern - the outcome commit:
pub fn lock_round(ctx: Context<LockRound>) -> Result<()> {
let r = &mut ctx.accounts.round;
require!(r.status == RoundStatus::Open, Err::WrongStatus);
r.status = RoundStatus::Locked;
// Fire VRF request. On callback, we'll write the result and move to Rolled.
Ok(())
}
pub fn reveal_round(ctx: Context<RevealRound>, vrf_result: [u8; 32]) -> Result<()> {
let r = &mut ctx.accounts.round;
require!(r.status == RoundStatus::Locked, Err::WrongStatus);
r.result = derive_outcome(vrf_result);
r.status = RoundStatus::Rolled;
Ok(())
}Bets accepted after Locked or after the VRF is requested must be rejected.
That single check is what makes the game provably fair - a player cannot bet
knowing the outcome, and the operator cannot retro-edit a bet knowing the
outcome either.
For a deeper treatment see my Web3 casino game engine pillar page.
Case study 2: a Solana trading / routing program
Most trading logic lives off-chain (see my trading infrastructure guide), but there are still cases for a small on-chain program:
- Custodying user funds for a managed strategy.
- Atomically combining a swap with a deposit/stake in a single tx.
- Enforcing strategy-level risk limits that you don't want to rely on an operator to respect.
A minimal on-chain "vault" that wraps a Jupiter swap:
pub fn swap_and_deposit(
ctx: Context<SwapAndDeposit>,
amount_in: u64,
min_amount_out: u64,
) -> Result<()> {
let before = ctx.accounts.vault_token_out.amount;
// CPI Jupiter (simplified - real routes need remaining_accounts plumbing)
jupiter_cpi::swap(/* ... */)?;
ctx.accounts.vault_token_out.reload()?;
let after = ctx.accounts.vault_token_out.amount;
let received = after.checked_sub(before).ok_or(Err::Overflow)?;
require!(received >= min_amount_out, Err::SlippageExceeded);
// Update positions, emit event, done.
Ok(())
}The reason this is on-chain: min_amount_out is now enforced by the program,
not by a front-end that could be bypassed. That's the whole point of doing it
on-chain.
Testing: what actually pays off
anchor testwith TypeScript for integration. Cover every instruction's happy path and its main revert paths.solana-program-testwith Rust for unit-style tests on individual instructions with a lightweight runtime.- Invariants. For anything with a vault: assert
sum(positions) == vault.amountafter random action sequences. This has caught more bugs for me than every other test combined. - Localnet with real programs - SPL-Token, Jupiter, Switchboard - not mocks. Mainnet behaviour differs from your mental model; find out on localnet, not in production.
Deployment: the boring-but-critical bits
- Pin the program ID in
declare_id!to the real mainnet key before you compile for production. - Keep the upgrade authority on a multisig. Solo-wallet upgrade authority is a single-key risk to every user of the program.
- Set up observability from day one: a bot that watches for specific instructions and alerts on rate, failures, and unusual inputs.
Conclusion
Solana smart contract development is its own discipline - the account model and compute budget are real constraints, not academic trivia. The good news is that once those models click, Anchor gives you a surprisingly clean DX: declarative account validation, first-class PDAs, and a test harness that actually works.
Build for the account model, stay inside the CU budget, validate every account explicitly, and keep CPIs boring. That is 80% of shipping a program you're not afraid of.
Related reading
- Trading infrastructure on Solana - how to use these programs at line-rate.
- DeFi & prediction market contracts - security patterns for on-chain money.
- Rust for smart contracts & performance-sensitive systems - the shared Rust patterns behind both.