Solana Smart Contract Development with Rust & Anchor: Programs, Games & Trading

·5 min read

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:

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:

  1. Round account per round, PDA-derived from a monotonically increasing counter.
  2. Bet accounts per player, with close = player so lamports return on claim.
  3. VRF request account bridging to the randomness oracle (Switchboard On-Demand or your own VRF).
  4. 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:

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

Deployment: the boring-but-critical bits

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 posts