I've spent the last few weeks using Solana programs. This week, I finally wrote one.
The program itself is simple: a counter that stores a number and the wallet authorized to update it. The interesting part wasn't the counter. It was seeing how Anchor uses account definitions to describe state, permissions, and validation before the instruction handler executes.
Here's what I learned.
The accounts struct
In a normal backend, your function gets a request and your database is just... available. You call it when you need it.
Solana doesn't work like that. Every piece of state your instruction touches has to be declared upfront, before your code runs. This is the struct that creates a new counter:
#[derive(Accounts)]
#[account(init, payer = authority, space = 8 + Counter::INIT_SPACE)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub authority: Signer<'info>,
counter is the account being created. init allocates it on-chain, payer = authority says who pays the rent, and space is how many bytes to reserve.
authority is the wallet calling the instruction. It has to sign the transaction and it has to be mutable because SOL is leaving it.
system_program is Solana's built-in program that actually creates accounts. You list it so Anchor can call it on your behalf.
When I first saw this I thought it was boilerplate. It's not. This is you telling the runtime: here is exactly what this instruction is allowed to touch, and here are the rules. The runtime validates all of it before your code runs.
The instruction handlers
Once the accounts struct validates, the handler itself is almost boring:
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 gives you typed access to every account you declared in the struct. You write to the fields directly. Anchor writes those changes back to the account automatically when the instruction finishes. The handler only describes what should change.
The increment handler is where things get interesting:
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(())
}
Two things worth noting here. First, checked_add instead of+= 1: if count ever hit the max value of a u64 and you used plain addition, it would silently wrap back to zero and corrupt your data. checked_add returns an error instead.
Second, and more important: notice there is no authorization check in this function body. That check lives in the accounts struct:
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(mut, has_one = authority)]
pub counter: Account<'info, Counter>,
pub authority: Signer<'info>,
}
has_one = authority tells Anchor: before this handler runs, confirm that counter.authority (the field stored inside the on-chain account) matches the authority wallet being passed into this transaction. If they don't match, the transaction fails with a constraint violation before your code ever executes.
This is the Solana equivalent of the line in your backend route that says if(req.user.id !== resource.ownerId) return res.status(403). The difference is you declared it on the struct and Anchor enforces it for you. Because the constraint lives on the account definition, it's much harder to accidentally ship a handler without authorization.
Testing It
I tested everything locally with LiteSVM; an in-process Solana VM that runs your compiled program against a fresh ledger without touching devnet. Each test gets a clean slate in milliseconds.
A passing test suite only means something if it turns red when something real breaks. Here are two tests that I know are load-bearing, because I deliberately broke the program and watched them catch it.
Happy path:
#[test]
fn initialize_then_increment() {
let mut svm = LiteSVM::new();
// ... setup omitted for brevity
svm.send_transaction(init_tx).unwrap();
svm.send_transaction(inc_tx).unwrap();
let account = svm.get_account(&counter_kp.pubkey()).unwrap();
let parsed = counter::Counter::try_deserialize(&mut account.data.as_slice()).unwrap();
assert_eq!(parsed.count, 1);
assert_eq!(parsed.authority, authority.pubkey());
}
This test proves the happy path: initialization stores the correct authority, increment updates the count, and both changes persist on-chain.
Failure path:
#[test]
fn increment_fails_when_wrong_authority_signs() {
// authority_a creates the counter
svm.send_transaction(init_tx).expect("initialize should succeed");
// authority_b tries to increment it
let result = svm.send_transaction(bad_tx);
assert!(result.is_err(), "increment should fail when signed by the wrong authority");
}
This test would fail if the has_one = authority constraint was removed. Without this test, a program that silently allows any wallet to increment anyone's counter would still look green.
Breaking things on purpose
On Day 61 I planted three bugs to prove my tests were actually doing their job. The one that surprised me most was this: I commented out the line that sets authority during initialization:
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
// counter.authority = ctx.accounts.authority.key(); <-- removed
counter.count = 0;
Ok(())
}
Because the authority field was never initialized, the has_one constraint failed immediately, and the test caught it before the program could proceed.
Error Code: ConstraintHasOne. Error Number: 2001. Error Message: A has one constraint was violated.
Program log: Left."
"Program log: 11111111111111111111111111111111",
"Program log: Right:"
"Program log: HeKXcf775y8MYdTbSSYiTSL64fHiEAoM7h6c86WV4isT",
Without the assertion on parsed.authority, this bug would have silently deployed and broken every increment call for every user whose counter got initialized after that change.
What I'd write next
The counter works, but it still has one limitation: its address is random. The next thing I want to learn is Program Derived Addresses (PDAs), which let a program deterministically derive an account address from known seeds. That means a client can always find a user's counter without storing an extra keypair.
Top comments (0)