Smart Contract Development in Rust (Solana)
Solana attracts developers with sub-second finality and transaction cost of $0.00025. But behind this speed stands a fundamentally different execution model: accounts model instead of contract storage, stateless programs, PDA derivatives instead of mapping. A developer coming from EVM spends the first two weeks thinking Solana is broken — because familiar Solidity patterns either don't work here or lead to different classes of vulnerabilities.
Why Solana Programs Break EVM Developers
In EVM a contract stores its state internally. In Solana a program is stateless executable, and data lives in separate accounts that the program doesn't own — it only authorizes operations on them. This means each instruction call requires explicitly passing all accounts that will be involved.
First pitfall: missing signer check. The program receives an account via AccountInfo, but doesn't verify that the passed authority actually signed the transaction. In Anchor this is caught with the #[account(signer)] attribute or Signer<'info> type. In native Rust — through explicit check if !authority.is_signer { return Err(...) }. Easy to miss when writing first programs.
Second classic vulnerability — account substitution. The program takes token_account and authority, but doesn't check that token_account.owner matches the passed authority. An attacker passes their token_account and someone else's authority — the program executes without error and drains someone else's tokens.
Third pain — PDA derivation mismatch. Program Derived Address is calculated from seeds + program_id. If seeds aren't explicitly verified via find_program_address with constraint in Anchor (seeds = [b"vault", user.key().as_ref()]), an attacker can pass an arbitrary account that coincidentally matches the PDA by address but isn't legitimate.
How Anchor Changes the Security Equation
Work primarily through Anchor framework (current version 0.30.x). Anchor generates discriminator for each account type and verifies it during deserialization — this automatically closes an entire class of type confusion attacks where an attacker passes an account of the wrong type.
Typical instruction structure in our codebase:
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(
mut,
seeds = [b"vault", user.key().as_ref()],
bump,
constraint = vault.authority == user.key() @ ErrorCode::Unauthorized
)]
pub vault: Account<'info, VaultState>,
#[account(
mut,
associated_token::mint = mint,
associated_token::authority = user
)]
pub user_token_account: Account<'info, TokenAccount>,
pub user: Signer<'info>,
pub mint: Account<'info, Mint>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}
Constraints in #[account(...)] aren't just syntax sugar. They compile into explicit checks before instruction execution. If a constraint is violated — the transaction reverts before instruction logic runs.
Testing: Bankrun vs Localnet
For most unit tests use solana-bankrun — it runs a synthetic runtime in memory without starting a validator. A test taking 3 seconds on localnet (400ms per slot) executes in 50ms. Critical for fuzzing.
Integration tests — on localnet via anchor test. Add --skip-local-validator where the test scenario requires real programs (Associated Token Program, Metaplex) — clone their state from mainnet via --clone.
For fuzzing SPL programs use Trident (Ackee Blockchain's fuzzer). It generates random instruction sequences and finds panics, unexpected account state, integer overflow. On one project Trident found in 4 hours a scenario where init → close → reinit led to reinitialization with someone else's data — Anchor discriminator passed because reinit used the same type.
Optimization: Compute Units
Solana limits each transaction to 1.4M compute units by default (can request up to 1.4M via SetComputeUnitLimit). Serialization/deserialization via Borsh is expensive for large structures.
Practice: split large state structures into multiple accounts. Instead of one ProgramState with 50 fields — several specialized accounts. Less data deserialized per call — less CU spent.
Second technique: zero_copy accounts via #[account(zero_copy)] in Anchor. Data reads directly from memory without Borsh deserialization. On structures >1KB saves 30-50% CU.
| Approach | CU on 1KB deserialization | Mutability |
|---|---|---|
| Standard Borsh | ~8000 CU | Full |
| zero_copy (bytemuck) | ~500 CU | Limited (repr(C)) |
Development Process
Analytics and design (2-5 days). Break down the accounts model for the task: which PDAs you need, which seeds, where you need CPI to Token Program or Associated Token Program. Design state before writing code — reworking accounts structure during testing is expensive.
Development (3-10 days depending on complexity). Anchor + Rust stable. Cover each instruction with tests via Bankrun. Complex scenarios (PDA lifecycle, CPI chains) — on localnet.
Security review. Run through Soteria (Solana program static analysis) and manual review by checklist: missing signer, ownership checks, PDA validation, integer arithmetic (use checked_add, checked_mul everywhere).
Deployment. anchor deploy with multisig upgrade authority (Squads Protocol). Upgrade authority shouldn't be EOA — if the private key leaks, the program can be rewritten.
Timelines: 3-5 days for a standard SPL-compatible contract, up to 3 weeks for complex protocols with multiple programs and CPI chains.







