A comprehensive guide to Solana's architecture, account model, program execution, consensus, security, and optimization — written for Rust developers ready to build on-chain.
If you only take one thing from this guide, take this: Solana is a database of accounts. Everything — wallets, tokens, NFTs, smart contracts, on-chain state — is just an account. Programs read and write to accounts. That is the entire model.
Solana's global state is a gigantic key-value store. The key is a 32-byte public key (address). The value is an account — a struct holding lamports, a data byte array, an owner program ID, and some flags. There is nothing else. No storage slots, no contract state, no special objects. Just accounts.
Unlike Ethereum where a contract stores its own state, Solana programs store zero state. A program is just compiled code living in an executable account. All state lives in separate data accounts that the program owns. When a transaction invokes a program, it passes in the accounts the program needs to read or write. The program processes them and returns.
This separation of code and state is what enables Sealevel's parallel execution — the runtime knows exactly which memory each transaction touches because accounts are passed explicitly.
This distinction has profound implications. On Ethereum, you call a contract and it reads its own storage. On Solana, you tell the runtime which accounts to load, then the program processes them. This means the runtime can schedule non-overlapping transactions in parallel — if Transaction A writes to Account 1 and Transaction B writes to Account 2, they execute simultaneously.
As a Rust developer building on Solana, your entire job boils down to three things:
Define the data layout of your on-chain state. What fields? How many bytes? Which program owns them?
Write program logic that validates, reads, and writes account data in response to user instructions.
Ensure every account passed to your program is exactly what you expect. This is where security lives or dies.
An account is Solana's atomic unit of state. Every single byte of data that exists on-chain lives inside an account. Understanding their structure and rules is non-negotiable.
A record in Solana's global state indexed by a 32-byte public key. Contains six fields: lamports (u64 balance), data (byte array of arbitrary content), owner (program ID that controls it), executable (bool flag), rent_epoch (u64), and the implicit key (its address). Maximum data size: 10 MB. Minimum balance determined by rent exemption.
These rules are enforced by the Solana runtime. Violating them causes your transaction to fail. Understand them deeply — they define what is and is not possible on Solana.
If an account's owner field is set to Program X, then only Program X can change the data bytes. Any other program's attempt to write to that account will be rejected by the runtime. However, any program can read any account's data — there is no read protection on Solana. All on-chain data is public.
Transferring SOL (debiting lamports) from an account requires the System Program. However, any program can credit lamports to any account — you can always add money, but only the System Program (or the owning program for PDAs) can take it out.
Ownership transfer is one-way and permanent. Once you assign an account to a different program, only that new program can change it back. This is how account creation works: the System Program creates the account (owns it initially), then assigns ownership to your program.
Every account must hold enough lamports to cover approximately 2 years of rent (~6,960 lamports per byte). Accounts below this threshold can be garbage-collected. In practice, you always make accounts rent-exempt at creation. The rent deposit is recoverable — when you close an account, you get the lamports back.
Unlike Ethereum's dynamic storage, Solana accounts have a fixed size declared at creation. You cannot resize an account after creation (except via realloc, which has limitations). You must know your data layout upfront and allocate sufficient space, including padding for future fields.
Owned by the System Program (11111...1111). Has a lamport balance and empty data. This is what a keypair generates. The holder of the private key can sign transactions to transfer SOL.
Stores compiled BPF bytecode. Marked executable: true. Owned by the BPF Loader. Immutable once deployed (unless using an upgrade authority). A program's code and its data are always separate accounts.
Holds serialized state for a program. Owned by the program that created it. This is where your application's state lives — vault balances, user profiles, game state, anything. Anchor accounts always start with an 8-byte discriminator.
A special address derived deterministically from seeds + program ID. No private key exists. Only the deriving program can sign for it. The backbone of program-controlled vaults, escrows, and autonomous on-chain logic.
Solana accounts store raw bytes. Your program must serialize (write) and deserialize (read) structs to/from those bytes. The standard is Borsh (Binary Object Representation Serializer for Hashing) — a deterministic, schema-based binary format.
Rust // Your Anchor account struct #[account] #[derive(InitSpace)] pub struct Vault { pub owner: Pubkey, // 32 bytes pub balance: u64, // 8 bytes pub created_at: i64, // 8 bytes pub is_locked: bool, // 1 byte pub bump: u8, // 1 byte } // Total: 8 (discriminator) + 32 + 8 + 8 + 1 + 1 = 58 bytes // The Anchor discriminator (first 8 bytes) = SHA256("account:Vault")[..8] // Rent-exempt minimum ≈ 58 × 6,960 = ~403,680 lamports ≈ 0.0004 SOL
| Rust Type | Borsh Size | Solana Usage |
|---|---|---|
bool | 1 byte | Flags, initialization state |
u8 / i8 | 1 byte | Bumps, enum discriminators |
u32 / i32 | 4 bytes | Short counters |
u64 / i64 | 8 bytes | Lamports, timestamps, token amounts |
u128 | 16 bytes | Large math, price accumulators |
Pubkey | 32 bytes | Addresses, owner references |
String | 4 + len | Metadata (4-byte length prefix) |
Vec<T> | 4 + (n × size(T)) | Dynamic lists (pre-allocate max!) |
Option<T> | 1 + size(T) | Optional fields (1-byte tag) |
| Anchor discriminator | 8 bytes | First 8 bytes of every Anchor account |
A Solana program is a stateless function: it takes in a list of accounts and instruction data, executes logic, mutates account state, and returns success or failure. That's it.
Compiled BPF/SBF bytecode stored in an executable account. A program exposes a single entrypoint function that receives three arguments: program_id (its own address), accounts (array of account references), and instruction_data (opaque bytes). The program deserializes the instruction data, validates accounts, executes logic, and writes back to accounts.
You can write programs at two levels. The raw entrypoint gives you maximum control. Anchor gives you maximum productivity. Here's the same logic at both levels:
Raw Solana // Raw entrypoint — you handle EVERYTHING manually use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint, entrypoint::ProgramResult, msg, program_error::ProgramError, pubkey::Pubkey, }; entrypoint!(process_instruction); fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let vault = next_account_info(accounts_iter)?; let user = next_account_info(accounts_iter)?; // YOU must manually check: is the user a signer? if !user.is_signer { return Err(ProgramError::MissingRequiredSignature); } // YOU must check: does this program own the vault? if vault.owner != program_id { return Err(ProgramError::IncorrectProgramId); } // YOU must deserialize, validate, process, serialize... msg!("Processing instruction"); Ok(()) }
Anchor // Anchor — declarative constraints handle validation automatically use anchor_lang::prelude::*; #[program] pub mod vault { use super::*; pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> { // Signer check? Anchor did it (Signer type). // Owner check? Anchor did it (Account type). // PDA derivation? Anchor did it (seeds + bump). // Deserialization? Anchor did it. // You just write the business logic: ctx.accounts.vault.balance = ctx.accounts.vault.balance .checked_add(amount) .ok_or(VaultError::Overflow)?; Ok(()) } } #[derive(Accounts)] pub struct Deposit<'info> { #[account( mut, seeds = [b"vault", user.key().as_ref()], bump = vault.bump, )] pub vault: Account<'info, Vault>, #[account(mut)] pub user: Signer<'info>, pub system_program: Program<'info, System>, }
Solana ships with programs that handle core functionality. Your programs interact with them via CPI.
| Program | Address Prefix | Responsibility |
|---|---|---|
| System Program | 1111...1111 | Create accounts, transfer SOL, assign owners, allocate space |
| SPL Token | TokenkegQ... | Create/transfer/burn fungible and non-fungible tokens |
| Token-2022 | TokenzQdB... | Extended token program: transfer fees, metadata, confidential transfers |
| Associated Token | ATokenGPv... | Derive deterministic token account addresses per wallet per mint |
| BPF Loader v2 | BPFLoade... | Deploy and upgrade user programs |
| Compute Budget | ComputeB... | Request additional CU, set priority fees |
| Address Lookup Table | AddressL... | Create/manage ALTs for versioned transactions |
| Sysvars | Various | Clock (timestamp), Rent (min balance), EpochSchedule, SlotHashes |
A transaction is an atomic bundle of instructions. All succeed or all fail. Understanding their structure, limits, and lifecycle is critical for building production systems.
An atomic, ordered set of one or more instructions submitted to the network for execution. Contains: an array of Ed25519 signatures, and a message comprising a header, account address array, recent blockhash, and instruction array. Hard-capped at 1,232 bytes.
The atomic unit of execution. Each instruction invokes exactly one program and specifies: which program to call (by index into the account keys array), which accounts the program needs (each tagged as signer and/or writable), and opaque data bytes that the program interprets as its payload (e.g., instruction variant + arguments).
A 32-byte hash of a recent block, included in every transaction. It serves two purposes: replay protection (the network deduplicates transactions by signature, and the blockhash ensures the same logical transaction produces a different signature each time) and expiry (transactions are dropped if the blockhash is older than ~150 slots / ~60 seconds).
Every account referenced in a transaction is tagged with metadata. The runtime uses these tags to schedule parallel execution:
| Tag | Meaning | Runtime Impact |
|---|---|---|
is_signer: true | Transaction must include a valid Ed25519 signature from this key | Signature verification before execution |
is_writable: true | The instruction may modify this account's data or lamports | Write-locked — no other TX can write simultaneously |
| Read-only | Neither signer nor writable | Read-shared — multiple TXs can read in parallel |
Parallelism insight: Two transactions that only overlap on read-only accounts execute in parallel. Transactions that share a writable account must be serialized. This is why popular accounts (like a DEX pool) can become bottlenecks — every swap writes to the same account, forcing sequential execution.
An enhanced transaction format that supports Address Lookup Tables (ALTs). Legacy transactions include every account address inline (32 bytes each), limiting them to ~35 accounts. Versioned transactions reference ALT entries by 1-byte index, allowing up to 256 accounts per transaction while staying within the 1,232-byte limit.
An on-chain account storing a list of up to 256 public keys. Transactions reference these keys by index (1 byte) instead of inline (32 bytes). ALTs are essential for complex DeFi transactions that touch many accounts — swaps across multiple pools, liquidation bots, and batch operations.
Solana doesn't use a single consensus mechanism. It uses a combination of Proof of History (a verifiable clock), Tower BFT (a PBFT variant), and several propagation innovations to achieve sub-second block times.
A sequential chain of SHA-256 hashes where each hash includes the previous hash as input: hash_n = SHA256(hash_n-1). Because SHA-256 is inherently sequential (you cannot parallelize it), the number of hashes between two events proves that real wall-clock time has elapsed. Transactions are mixed into the hash chain, giving them a cryptographic timestamp before consensus.
PoH is NOT a consensus mechanism — it's a clock. It provides a globally-consistent ordering of events, allowing validators to agree on transaction order without communicating. This removes the most expensive part of traditional BFT consensus.
Solana's consensus algorithm — a PBFT (Practical Byzantine Fault Tolerance) variant optimized with PoH. Validators vote on the PoH-ordered ledger. Key innovation: exponential lockouts. Each consecutive vote on the same fork doubles the lockout period (the time the validator must wait to switch forks). After 32 votes, the lockout is ~2^32 slots — effectively permanent. This creates rapid economic finality without the O(n²) message complexity of traditional PBFT.
Parallel smart contract runtime. Transactions declare which accounts they read/write. Non-overlapping transactions execute simultaneously on separate cores. This is Solana's throughput multiplier.
Block propagation protocol inspired by BitTorrent. The leader breaks blocks into small packets called shreds and distributes them through a tree of validators. Each node only needs to forward a fraction of the data, eliminating the leader as a bottleneck.
Mempool-less transaction forwarding. Transactions are pushed directly to the expected next leaders, not held in a global mempool. This reduces confirmation latency and memory pressure across the network.
Horizontally-scaled accounts database using memory-mapped files. Supports concurrent reads and writes across SSDs, enabling the throughput needed for Sealevel's parallel execution.
A ~400ms window during which one leader produces a block. A leader gets 4 consecutive slots (~1.6s). If the leader fails to produce, the slot is skipped.
A period of ~432,000 slots (~2-3 days). Stake delegations, validator rewards, and the leader schedule are recalculated at epoch boundaries. Leader schedule is known 2 epochs ahead.
PDAs and CPIs are the composability primitives of Solana. PDAs let programs own accounts autonomously. CPIs let programs call other programs. Together, they enable the entire DeFi stack.
An address computed from SHA256(seeds + program_id + bump) that is guaranteed to not lie on the Ed25519 curve. Because it's off-curve, no private key exists — nobody can produce a valid signature for it externally. Only the deriving program can authorize operations on behalf of the PDA by providing the seeds and bump in a CPI signer context. The bump (also called nonce) is the highest value 0-255 that pushes the hash off the curve.
Rust — PDA in Anchor // Finding a PDA: deterministic from seeds + program_id let (vault_pda, bump) = Pubkey::find_program_address( &[b"vault", user.key().as_ref()], program_id, ); // Anchor constraint — validates PDA derivation automatically #[account( init, payer = user, space = 8 + Vault::INIT_SPACE, seeds = [b"vault", user.key().as_ref()], bump, )] pub vault: Account<'info, Vault>, // On subsequent calls, verify the PDA: #[account( mut, seeds = [b"vault", user.key().as_ref()], bump = vault.bump, // stored bump for efficiency )] pub vault: Account<'info, Vault>,
Best practice: Always store the bump in the account data. Re-deriving with find_program_address costs ~500 CU per call because it iterates bump values. Using a stored bump with create_program_address costs ~20 CU.
When program A calls program B during execution. For example, your vault program calling the SPL Token Program to transfer tokens. CPIs allow programs to compose like building blocks. Maximum nesting depth: 4 levels. The calling program can pass its PDA signer seeds so the target program sees the PDA as a signer — this is how programs authorize token transfers from their vaults.
Rust — CPI Token Transfer from PDA use anchor_spl::token::{self, Transfer, Token}; pub fn withdraw_tokens(ctx: Context<Withdraw>, amount: u64) -> Result<()> { // PDA signer seeds — the runtime verifies these derive the PDA let seeds = &[ b"vault", ctx.accounts.user.key.as_ref(), &[ctx.accounts.vault.bump], ]; let signer = &[&seeds[..]]; // CPI: call Token Program's transfer instruction let cpi_ctx = CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.vault_token_account.to_account_info(), to: ctx.accounts.user_token_account.to_account_info(), authority: ctx.accounts.vault.to_account_info(), // PDA authority }, signer, // PDA seeds prove the vault authorizes this ); token::transfer(cpi_ctx, amount)?; Ok(()) }
Unlike Ethereum, Solana uses a single shared Token Program for ALL tokens. This means token logic is not duplicated across thousands of contracts — it's one battle-tested program managing every fungible and non-fungible token through separate accounts.
Defines a token type. Stores: total supply, decimals (6 for USDC, 9 for SOL), mint authority (can create new tokens, or null for fixed supply), and freeze authority (can freeze token accounts). One mint per token type.
Holds a user's balance of one specific token. Stores: which mint it belongs to, the owner wallet that controls it, and the amount. Each user needs a separate token account for each token they hold. Owned by the Token Program.
A deterministically derived token account address from PDA(wallet_pubkey, token_program, mint_pubkey). Convention: every wallet has exactly one ATA per mint. You can compute anyone's ATA address client-side without querying the chain.
Next-gen token program with modular extensions: transfer fees, non-transferable (soulbound) tokens, interest-bearing tokens, confidential transfers, on-mint metadata, permanent delegate, default account state, and more.
An SPL token with supply = 1, decimals = 0, plus a Metaplex Metadata account. The metadata stores: name, symbol, URI (pointing to off-chain JSON with image, attributes), seller fee basis points (royalties), and verified creator list. Compressed NFTs (cNFTs) use Merkle trees to store millions of NFTs at ~$0.0005 each vs ~$2 for standard NFTs.
The majority of Solana exploits share one root cause: insufficient account validation. Your program receives arbitrary accounts from arbitrary callers. If you don't verify every single assumption about those accounts, attackers will pass in crafted accounts that subvert your logic.
Every account passed to your program is user-supplied. The runtime guarantees signatures and basic ownership — everything else is your responsibility. An attacker can pass any account address, any data, any program. Your program must verify that every account is exactly what it expects.
What happens: Your program modifies state without verifying that the caller actually signed the transaction. An attacker calls your withdraw function and passes someone else's vault — and since you never checked that the vault owner signed, the transaction succeeds.
Fix: Always use Signer<'info> in Anchor, or manually verify account.is_signer in raw programs. Never trust that an account is who it claims to be without a signature.
Vulnerable // BAD: Anyone can call this, no signer verification pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> { let vault = &mut ctx.accounts.vault; vault.balance -= amount; // ← No check that the caller owns this vault! Ok(()) }
Secure // GOOD: Anchor verifies signer + has_one constraint #[derive(Accounts)] pub struct Withdraw<'info> { #[account( mut, seeds = [b"vault", owner.key().as_ref()], bump = vault.bump, has_one = owner @ VaultError::Unauthorized, // ← verifies vault.owner == owner.key() )] pub vault: Account<'info, Vault>, #[account(mut)] pub owner: Signer<'info>, // ← must have signed the TX }
What happens: Your program deserializes an account without verifying that the expected program owns it. An attacker creates their own account with fake data that matches your struct layout, passes it in, and your program treats it as legitimate.
Fix: Anchor's Account<'info, T> automatically verifies the account owner matches the declaring program AND checks the 8-byte discriminator. In raw programs, always check account.owner == expected_program_id.
What happens: Using standard arithmetic (+, -, *) can silently wrap around. A balance of 1 minus 2 wraps to u64::MAX (18,446,744,073,709,551,615 lamports). An attacker turns 1 lamport into 18 billion SOL.
Fix: Use checked_add, checked_sub, checked_mul, checked_div for ALL arithmetic. Return an error on overflow instead of wrapping.
Safe Arithmetic // ALWAYS use checked math on-chain vault.balance = vault.balance .checked_sub(amount) .ok_or(VaultError::Overflow)?; user_balance = user_balance .checked_add(amount) .ok_or(VaultError::Overflow)?; // For complex math, use u128 intermediate values let fee = (amount as u128) .checked_mul(fee_bps as u128).unwrap() .checked_div(10_000).unwrap() as u64;
What happens: An attacker calls your initialize instruction on an account that's already been initialized, resetting its state. If your vault held 100 SOL and the balance gets reset to 0, those funds are effectively stolen.
Fix: Anchor's #[account(init)] uses a discriminator and will fail if the account already has one. In raw programs, always check an is_initialized flag and reject if already set.
What happens: An attacker passes a PDA derived with different seeds than expected. Your program assumes the PDA matches certain seeds (e.g., ["vault", user_key]) but never verifies the derivation. The attacker passes a PDA derived from ["vault", attacker_key] and accesses someone else's vault.
Fix: Always re-derive the PDA in your validation. Anchor's seeds = [...], bump constraint does this automatically.
What happens: When closing an account, if you only transfer lamports without zeroing the data, the account can be re-opened in the same transaction (by another instruction that sends it lamports before the runtime garbage-collects it). The stale data is now live again.
Fix: Use Anchor's #[account(close = recipient)] which automatically zeroes data, transfers lamports, and sets the discriminator to a closed state. In raw code, always memset data to zero before transferring lamports out.
What happens: Your program makes a CPI to a program address passed by the user without verifying it's the expected program. An attacker passes their own malicious program that mimics the interface but does something different — like approving unlimited token spending.
Fix: Use Anchor's Program<'info, Token> which verifies the program ID. In raw code, hardcode expected program IDs and compare: if token_program.key() != spl_token::ID.
Before every deployment, verify:
✓ Every signer is checked (Anchor: Signer type) • Every account owner is validated (Anchor: Account<T>) • Every PDA derivation is verified (seeds + bump) • All arithmetic uses checked operations • has_one constraints link accounts to their owners • Close instructions zero data before lamport transfer • CPI targets are hardcoded or validated • No reinitialization possible • Token account mints are verified • No unchecked account casting or raw deserialization
Solana programs run in a constrained environment: limited compute units, limited stack, limited transaction size. Optimized programs are cheaper, faster, and less likely to hit runtime limits.
Solana's measure of computational cost. Default budget: 200,000 CU per transaction. Maximum: 1,400,000 CU (requested via ComputeBudget instruction). Every BPF instruction, syscall, memory access, and CPI costs CU. Exceeding the budget fails the transaction. Priority fees are priced per CU: total_fee = base_fee + (CU_price × CU_consumed).
| Operation | Approx CU Cost | Optimization |
|---|---|---|
| SHA-256 hash (64 bytes) | ~100 CU | Minimize hashing in loops |
find_program_address | ~1,500 CU | Store bump, use create_program_address (~20 CU) |
| CPI call | ~1,000+ CU overhead | Batch operations, reduce CPI count |
| Borsh deserialize | ~200-1,000 CU | Use zero-copy for large accounts |
msg! logging | ~100 CU per call | Remove in production |
| Create account (CPI to System) | ~5,000 CU | Pre-create accounts off-chain when possible |
| Token transfer (CPI to Token) | ~4,500 CU | Batch transfers, use Transfer checked |
| Memory allocation (heap) | ~50 CU / allocation | Reuse buffers, prefer stack |
Rust // SLOW: re-derives bump every time (~1,500 CU) let (pda, bump) = Pubkey::find_program_address(&[b"vault", user.as_ref()], pid); // FAST: use stored bump (~20 CU) let pda = Pubkey::create_program_address( &[b"vault", user.as_ref(), &[stored_bump]], pid )?;
Rust // Normal: copies entire account data into a struct (~1,000 CU for big accounts) #[account] pub struct BigState { /* fields */ } // Zero-copy: maps directly onto the account data buffer (near-zero CU) #[account(zero_copy)] #[repr(C)] // Required: C memory layout, no padding surprises pub struct BigState { pub entries: [Entry; 256], // Fixed-size arrays, no Vec } // In account constraint: #[account(mut)] pub state: AccountLoader<'info, BigState>, // Not Account, but AccountLoader
Rust // Instead of separate bools (1 byte each): pub is_initialized: bool, // 1 byte pub is_locked: bool, // 1 byte pub is_frozen: bool, // 1 byte = 3 bytes total // Pack into a single u8 bitflag (1 byte total, saves rent): pub flags: u8, // bit 0 = initialized, bit 1 = locked, bit 2 = frozen // Helpers: const INITIALIZED: u8 = 1 << 0; const LOCKED: u8 = 1 << 1; fn is_initialized(&self) -> bool { self.flags & INITIALIZED != 0 }
Transaction size is 1,232 bytes — every byte counts. Use Address Lookup Tables for transactions referencing many accounts. Use short instruction data (prefer enums over strings). Use PDAs instead of passing extra accounts. Combine multiple operations into single instructions where safe.
Rust // BAD: Two separate CPIs (~9,000 CU) token::transfer(ctx1, amount_a)?; // ~4,500 CU token::transfer(ctx2, amount_b)?; // ~4,500 CU // BETTER: Single CPI if your architecture allows it // Or use direct lamport manipulation for SOL transfers: **source.try_borrow_mut_lamports()? -= amount; **dest.try_borrow_mut_lamports()? += amount; // This costs ~200 CU vs ~4,500 for CPI
Here is a complete, production-style vault program demonstrating every concept from this guide: accounts, PDAs, CPIs, security validation, and optimization.
Shell # Install Rust, Solana CLI, and Anchor curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)" cargo install --git https://github.com/coral-xyz/anchor avm --force avm install latest && avm use latest # Configure for devnet solana config set --url devnet solana-keygen new solana airdrop 5 # Create project anchor init sol-vault && cd sol-vault
Rust — programs/sol-vault/src/lib.rs use anchor_lang::prelude::*; use anchor_lang::system_program; declare_id!("YOUR_PROGRAM_ID"); #[program] pub mod sol_vault { use super::*; pub fn initialize(ctx: Context<Initialize>) -> Result<()> { let vault = &mut ctx.accounts.vault; vault.owner = ctx.accounts.owner.key(); vault.balance = 0; vault.created_at = Clock::get()?.unix_timestamp; vault.bump = ctx.bumps.vault; Ok(()) } pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> { require!(amount > 0, VaultError::ZeroAmount); // CPI to System Program to transfer SOL system_program::transfer( CpiContext::new( ctx.accounts.system_program.to_account_info(), system_program::Transfer { from: ctx.accounts.owner.to_account_info(), to: ctx.accounts.vault.to_account_info(), }, ), amount, )?; let vault = &mut ctx.accounts.vault; vault.balance = vault.balance .checked_add(amount) .ok_or(VaultError::Overflow)?; Ok(()) } pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> { require!(amount > 0, VaultError::ZeroAmount); let vault = &mut ctx.accounts.vault; require!(vault.balance >= amount, VaultError::InsufficientBalance); vault.balance = vault.balance .checked_sub(amount) .ok_or(VaultError::Overflow)?; // Direct lamport transfer from PDA — cheaper than CPI **vault.to_account_info().try_borrow_mut_lamports()? -= amount; **ctx.accounts.owner.to_account_info().try_borrow_mut_lamports()? += amount; Ok(()) } pub fn close_vault(ctx: Context<CloseVault>) -> Result<()> { // Anchor's close constraint handles: // 1. Zeroing the account data // 2. Transferring all lamports to owner // 3. Setting discriminator to closed state Ok(()) } } // ═══════════ ACCOUNT VALIDATION STRUCTS ═══════════ #[derive(Accounts)] pub struct Initialize<'info> { #[account( init, payer = owner, space = 8 + Vault::INIT_SPACE, seeds = [b"vault", owner.key().as_ref()], bump, )] pub vault: Account<'info, Vault>, #[account(mut)] pub owner: Signer<'info>, pub system_program: Program<'info, System>, } #[derive(Accounts)] pub struct Deposit<'info> { #[account( mut, seeds = [b"vault", owner.key().as_ref()], bump = vault.bump, has_one = owner @ VaultError::Unauthorized, )] pub vault: Account<'info, Vault>, #[account(mut)] pub owner: Signer<'info>, pub system_program: Program<'info, System>, } #[derive(Accounts)] pub struct Withdraw<'info> { #[account( mut, seeds = [b"vault", owner.key().as_ref()], bump = vault.bump, has_one = owner @ VaultError::Unauthorized, )] pub vault: Account<'info, Vault>, #[account(mut)] pub owner: Signer<'info>, } #[derive(Accounts)] pub struct CloseVault<'info> { #[account( mut, seeds = [b"vault", owner.key().as_ref()], bump = vault.bump, has_one = owner @ VaultError::Unauthorized, close = owner, // ← zeros data + sends lamports to owner )] pub vault: Account<'info, Vault>, #[account(mut)] pub owner: Signer<'info>, } // ═══════════ STATE ═══════════ #[account] #[derive(InitSpace)] pub struct Vault { pub owner: Pubkey, // 32 bytes pub balance: u64, // 8 bytes pub created_at: i64, // 8 bytes pub bump: u8, // 1 byte = 49 + 8 discriminator = 57 bytes } // ═══════════ ERRORS ═══════════ #[error_code] pub enum VaultError { #[msg("Insufficient vault balance")] InsufficientBalance, #[msg("Arithmetic overflow")] Overflow, #[msg("Unauthorized: you do not own this vault")] Unauthorized, #[msg("Amount must be greater than zero")] ZeroAmount, }
TypeScript import * as anchor from "@coral-xyz/anchor"; import { PublicKey, SystemProgram } from "@solana/web3.js"; const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const program = anchor.workspace.SolVault; const owner = provider.wallet.publicKey; // Derive PDA client-side — deterministic, no RPC call needed const [vaultPda, bump] = PublicKey.findProgramAddressSync( [Buffer.from("vault"), owner.toBuffer()], program.programId ); // Initialize await program.methods.initialize() .accounts({ vault: vaultPda, owner, systemProgram: SystemProgram.programId }) .rpc(); // Deposit 1 SOL await program.methods.deposit(new anchor.BN(1_000_000_000)) .accounts({ vault: vaultPda, owner, systemProgram: SystemProgram.programId }) .rpc(); // Withdraw 0.5 SOL await program.methods.withdraw(new anchor.BN(500_000_000)) .accounts({ vault: vaultPda, owner }) .rpc(); // Read state const vault = await program.account.vault.fetch(vaultPda); console.log(`Balance: ${vault.balance.toNumber() / 1e9} SOL`);
| Resource | Purpose |
|---|---|
| solana.com/docs | Official documentation — accounts, runtime, RPC API |
| book.anchor-lang.com | Anchor framework reference and tutorials |
| solanacookbook.com | Practical recipes for common patterns |
| beta.solpg.io | Browser-based Solana IDE — deploy without local setup |
| docs.helius.dev | RPC, webhooks, DAS API for NFTs and tokens |
| soldev.app | Curated tutorials, courses, and project ideas |
| github.com/coral-xyz/anchor | Anchor source — read the examples folder |
| sec3.dev | Security auditing tools and vulnerability database |
Everything in Solana reduces to: design your accounts, write instructions that validate and mutate them, secure every assumption, and optimize for the compute budget. The account model is the foundation. PDAs give programs autonomy. CPIs give them composability. Security is non-negotiable. Now go deploy something to devnet.