I spent 3 days building a counter program on Solana. It does one thing — count. But the real value wasn't the program. It was the tests that proved it worked.
The accounts struct:
#[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>,
}
This struct defines what accounts the initialize instruction needs. The counter account is being created (init), the authority pays for it (payer) and becomes its owner, and the system_program is required because creating accounts is a System Program operation. The 8 + Counter::INIT_SPACE is 8 bytes for Anchor's discriminator plus the size of the Counter data.
The initialize handler:
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.authority = ctx.accounts.authority.key();
counter.count = 0;
Ok(())
}
The handler is short. ctx.accounts gives you typed access to the accounts passed in. You set the authority to the wallet that signed the transaction, set count to 0 and return success. Anchor handles the rest.
The increment handler:
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 increment handler adds 1 to the count. The checked_add prevents overflow — if the count hits u64 max, the transaction fails cleanly instead of panicking. The constraint in the accounts struct guarantees only the authority can call it.
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(mut, has_one = authority)]
pub counter: Account<'info, Counter>,
pub authority: Signer<'info>,
}
The has_one = authority constraint is where the security lives. Before the handler runs, Anchor checks that counter.authority matches the authority signing the transaction. If it doesn't, the transaction fails before your code executes. Declarative authorization — checked at the protocol level.
The happy-path test:
#[test]
fn initialize_then_increment() {
let mut svm = LiteSVM::new();
let program_id = counter::ID;
let so_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/deploy/counter.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();
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();
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();
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 program works: initialize creates a counter with count 0, increment changes it to 1, and the final state is exactly what we expect.
The failure test:
#[test]
fn increment_fails_when_wrong_authority_signs() {
let (mut svm, program_id) = setup_svm_with_program();
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 = Keypair::new();
let init_tx = build_initialize_tx(&svm, program_id, &authority_a, &counter);
svm.send_transaction(init_tx).expect("initialize should succeed");
let bad_tx = build_increment_tx(&svm, program_id, &authority_b, counter.pubkey());
let result = svm.send_transaction(bad_tx);
assert!(result.is_err(), "increment should fail when signed by the wrong authority");
}
This test proves the authorization works: authority_a creates the counter, authority_b tries to increment it, and the transaction fails.
Breaking it on purpose:
I planted a bug on purpose. I changed checked_add(1) to checked_add(2) in the increment handler. The happy-path test failed with:
assertion `left == right` failed
left: 2
right: 1
That's the test catching a real regression. Without that assertion, the program would silently store the wrong number and the suite would still be green. That's why the tests matter.
Next, I'd add more instructions — decrement, reset, maybe a way to transfer ownership. The pattern is the same: define the instruction, define the accounts, write the test, break it, fix it. The counter is just the first step.
Top comments (0)