"The fastest way to find the holes in your understanding is to try to teach someone else."
Today's milestone (Day 62 of #100DaysOfSolana) is about turning invisible working code into an articulated, battle-tested piece of knowledge. Below is the published post draft ready for DEV.to.
How I Built My First Anchor Program and Learned to Trust My Tests
Subhead: Understanding Solana Programs through Account Constraints, LiteSVM Testing, and Mutation Testing
Tags: #100DaysOfSolana #solana #anchor #rust #testing
When I first started learning Solana, writing an on-chain program felt daunting. Coming from a traditional Web2 backend background, the shift in mental model is jarring: there are no servers, no centralized databases, and no hidden state. Everything in Solana revolves around stateless program logic, explicit account structures, and transaction signatures.
Over the last week, I built a custom counter program using the Anchor framework, wrote a test suite covering happy and failure paths with LiteSVM, and performed mutation testing by intentionally breaking my program to prove my tests were load-bearing. Here is how it works, what the tests proved, and what I learned along the way.
1. The Accounts Struct: Where Anchor Validates Security
The most visible difference between Web2 and Solana/Anchor development is that business logic is separated from security validation. In Anchor, security checks live declaratively inside the Accounts struct.
Here is the Initialize accounts struct for the counter program:
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = authority,
space = 8 + Counter::INIT_SPACE,
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
In this struct, each field enforces a specific runtime constraint. The counter field represents the on-chain account holding our state. The init constraint tells Anchor to invoke Solana's system_program to allocate a new account on-chain. payer = authority specifies that the transaction signer (authority) provides the lamports required for rent exemption. Finally, space = 8 + Counter::INIT_SPACE allocates exactly enough byte storage for Anchor’s 8-byte discriminator plus the struct data (authority Pubkey and count: u64). Instead of writing manual validation checks inside our handler, Anchor handles all of this automatically before our logic ever runs.
2. Handlers and Constraints: Keeping Logic Lean
Because Anchor validates all account constraints beforehand, the instruction handler itself is surprisingly minimal:
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.authority = ctx.accounts.authority.key();
counter.count = 0;
Ok(())
}
ctx.accounts provides strongly typed, pre-validated access to every account passed into the transaction. The handler simply records the owner's public key (authority) and initializes count to 0.
When it comes to modifying account state, account constraints protect the program from unauthorized access. Here is the Increment accounts struct and handler:
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(
mut,
has_one = authority,
)]
pub counter: Account<'info, Counter>,
pub authority: Signer<'info>,
}
pub fn increment(ctx: Context<Increment>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = counter.count
.checked_add(1)
.ok_or(ProgramError::ArithmeticOverflow)?;
Ok(())
}
The key security guarantee here is the has_one = authority constraint. Before increment() executes, Anchor automatically checks that counter.authority == authority.key(). If an unauthorized wallet attempts to sign the transaction, Anchor rejects it immediately during deserialization, before a single line of handler code executes.
3. Testing with LiteSVM: Happy Path & Failure Path
To verify our program without slow network round-trips, we used LiteSVM, an in-process Solana Virtual Machine test harness written in Rust.
The Happy Path Test
#[test]
fn initialize_then_increment() {
let mut svm = LiteSVM::new();
let program_id = day_60::ID;
let so_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/deploy/day_60.so");
svm.add_program_from_file(program_id, so_path).unwrap();
let authority = Keypair::new();
svm.airdrop(&authority.pubkey(), 1_000_000_000).unwrap();
let counter_kp = Keypair::new();
// 1. Initialize counter
let init_ix = Instruction {
program_id,
accounts: counter_accounts::Initialize {
counter: counter_kp.pubkey(),
authority: authority.pubkey(),
system_program: system_program::ID,
}.to_account_metas(None),
data: counter_instruction::Initialize {}.data(),
};
let tx = Transaction::new_signed_with_payer(
&[init_ix],
Some(&authority.pubkey()),
&[&authority, &counter_kp],
svm.latest_blockhash(),
);
svm.send_transaction(tx).unwrap();
// 2. Increment counter
let inc_ix = Instruction {
program_id,
accounts: counter_accounts::Increment {
counter: counter_kp.pubkey(),
authority: authority.pubkey(),
}.to_account_metas(None),
data: counter_instruction::Increment {}.data(),
};
let tx = Transaction::new_signed_with_payer(
&[inc_ix],
Some(&authority.pubkey()),
&[&authority],
svm.latest_blockhash(),
);
svm.send_transaction(tx).unwrap();
// 3. Read and assert state
let account = svm.get_account(&counter_kp.pubkey()).unwrap();
let parsed = Counter::try_deserialize(&mut account.data.as_slice()).unwrap();
assert_eq!(parsed.count, 1);
assert_eq!(parsed.authority, authority.pubkey());
}
Reason for existing: This test fails if the program fails to record the authority on initialization or miscalculates the counter state during increment.
The Failure Path Test
#[test]
fn increment_fails_when_wrong_authority_signs() {
let mut svm = LiteSVM::new();
let program_id = day_60::ID;
let so_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/deploy/day_60.so");
svm.add_program_from_file(program_id, so_path).unwrap();
let authority_a = Keypair::new();
let authority_b = Keypair::new();
svm.airdrop(&authority_a.pubkey(), 1_000_000_000).unwrap();
svm.airdrop(&authority_b.pubkey(), 1_000_000_000).unwrap();
let counter_kp = Keypair::new();
// 1. Initialize counter with authority_a
let init_ix = Instruction {
program_id,
accounts: counter_accounts::Initialize {
counter: counter_kp.pubkey(),
authority: authority_a.pubkey(),
system_program: system_program::ID,
}.to_account_metas(None),
data: counter_instruction::Initialize {}.data(),
};
let tx = Transaction::new_signed_with_payer(
&[init_ix],
Some(&authority_a.pubkey()),
&[&authority_a, &counter_kp],
svm.latest_blockhash(),
);
svm.send_transaction(tx).unwrap();
// 2. Attempt increment with authority_b (should fail)
let inc_ix = Instruction {
program_id,
accounts: counter_accounts::Increment {
counter: counter_kp.pubkey(),
authority: authority_b.pubkey(),
}.to_account_metas(None),
data: counter_instruction::Increment {}.data(),
};
let tx = Transaction::new_signed_with_payer(
&[inc_ix],
Some(&authority_b.pubkey()),
&[&authority_b],
svm.latest_blockhash(),
);
let result = svm.send_transaction(tx);
assert!(
result.is_err(),
"increment should fail when signed by the wrong authority"
);
}
Reason for existing: This test fails if Anchor stops enforcing account ownership constraints, allowing arbitrary wallets to modify counters they do not own.
4. Mutation Testing: Breaking Code to Trust Tests
A suite of passing tests only proves code works for known inputs. On Day 61, I performed mutation testing by intentionally planting bugs in the program code.
I removed the security constraint has_one = authority from the Increment struct:
// BEFORE (SECURE)
#[account(mut, has_one = authority)]
pub counter: Account<'info, Counter>,
// AFTER (MUTATED BUG)
#[account(mut)]
pub counter: Account<'info, Counter>,
When I ran cargo test, the test suite caught the authorization bug immediately:
running 3 tests
test increment_fails_when_wrong_authority_signs ... FAILED
failures:
---- increment_fails_when_wrong_authority_signs stdout ----
thread 'increment_fails_when_wrong_authority_signs' panicked at tests/counter.rs:123:5:
increment should fail when signed by the wrong authority
Seeing the test turn red proved that the security assertion was load-bearing. A passing test suite isn't valuable just because it's green—it's valuable because it turns red the moment critical invariants break.
5. What I Learned & What's Next
Writing this post revealed how much Anchor simplifies Solana development by turning imperative checks into declarative macro constraints.
Core Takeaways:
- Solana is Stateless: Application state lives strictly in accounts, while programs contain pure instruction execution logic.
-
Validation First: Anchor macro attributes (
init,has_one,payer,space) enforce access control before instruction logic executes. - LiteSVM Fast Feedback: In-process testing allows testing full transaction flows in milliseconds without spin-up overhead.
- Mutation Testing: Breaking program constraints proves your tests actually protect your application invariants.
What I Want to Learn Next:
If I had another week with this program, my reading and building list would be:
- Program Derived Addresses (PDAs): Replacing random keypairs with deterministic seeds for program-controlled PDA accounts.
-
Account Resizing (
realloc): Dynamically allocating additional space on account state updates. - Cross-Program Invocations (CPIs): Calling System or Token programs programmatically from inside handlers.
- Anchor Events & Indexing: Emitting custom events to allow frontend dApps to subscribe to state changes.
Published as part of #100DaysOfSolana.
Top comments (0)