DEV Community

Cover image for How I Built a Counter Program in Anchor and Learned to Trust My Tests -Hala Kabir
Hala Kabir
Hala Kabir

Posted on

How I Built a Counter Program in Anchor and Learned to Trust My Tests -Hala Kabir

Nothing exposes fuzzy understanding faster than trying to explain your own code. Over the last few days of the #100DaysOfSolana challenge, I’ve been building, testing, and intentionally breaking a decentralized counter program using the Anchor framework.

When you're writing smart contracts on Solana, a green test suite feels great—but it can also hide silent assumptions. Today, I want to pull back the curtain on how my first Anchor program works and show exactly how I broke it on purpose to prove my test suite actually does real work.

The Entry Point: Solana's Guard Rails

The biggest paradigm shift when moving from Web2 to Solana development is how state is handled. In Solana, programs (smart contracts) are stateless; all data lives inside external accounts. To create an account, Anchor uses an accounts validation struct. Here is how my Initialize struct is laid out:

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = authority, space = 8 + 32 + 8)]
    pub counter: Account<'info, Counter>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}
Enter fullscreen mode Exit fullscreen mode

This struct functions as our security guard before the main logic ever runs. The #[account(...)] macro tells Anchor to initialize a brand-new counter account, forces the transaction authority to pay the SOL storage rent, and allocates exactly enough memory space (8 bytes for the Anchor discriminator, 32 bytes for the public key, and 8 bytes for our data number).

Short Handlers and Iron-Clad Access Control

Because the accounts struct handles the heavy lifting of security and allocation, our actual instruction handlers inside the pub mod can be beautifully short and concise.

The ctx.accounts object lets us confidently unpack the validated accounts. But initialization is only half the battle. What stops a malicious actor from modifying someone else's state? This is where access control shines in our Increment instruction:

#[derive(Accounts)]
pub struct Increment<'info> {
    #[account(mut, has_one = authority)]
    pub counter: Account<'info, Counter>,
    pub authority: Signer<'info>,
}
Enter fullscreen mode Exit fullscreen mode

By adding has_one = authority, Anchor automatically enforces a strict on-chain check: the key signing this transaction must match the public key permanently stored inside the counter.authority field. If it doesn't, execution completely halts.

Proving the Tests Earn Their Keep

To make sure this logic holds up, I wrote a test suite tracking both the happy path and critical failure states using an integrated test runner:

it("Initializes then increments successfully", async () => {
  await program.methods.initialize().accounts({...}).signers([...]).rpc();
  await program.methods.increment().accounts({...}).signers([...]).rpc();

  const account = await program.account.counter.fetch(counterKeypair.publicKey);
  if (account.count.toNumber() !== 1) {
    throw new Error("Assertion Failed!");
  }
});
Enter fullscreen mode Exit fullscreen mode
  • Why this test exists: If the runtime environment introduces a regression where state transitions fail silently or data math corrupts, this test will instantly trip.
it("Increment fails when the wrong authority signs", async () => {
  try {
    await program.methods.increment().accounts({ authority: authorityB.publicKey }).signers([authorityB]).rpc();
    throw new Error("The transaction should have failed!");
  } catch (err) {
    console.log("Caught expected authority error!");
  }
});
Enter fullscreen mode Exit fullscreen mode
  • Why this test exists: This isolates our access control constraint, ensuring an unauthorized user cannot maliciously modify accounts they do not own.

The Turning Point: Mutation Testing

The real magic happened when I ran a manual mutation test pass—planting bugs on purpose to see if my test suite was genuinely "load-bearing."

I intentionally went into src/lib.rs and broke the core arithmetic logic by updating the step increment from 1 to 2:

counter.count = counter.count.checked_add(2).ok_or(ProgramError::Custom(0))?;
Enter fullscreen mode Exit fullscreen mode

When I compiled and ran the suite, the happy path test caught it red-handed. The test framework threw a glaring red flag because it expected a value of 1 but received a 2 directly from the account state.

This taught me a massive lesson: you can't just trust a green checkmark because your tutorial said it's correct. You only truly trust your tests when you have physically watched them break for the exact right reasons.

If I had another week to build on this, my next step would be implementing dynamic PDA (Program Derived Address) counters so users could spin up unique, deterministically mapped counter states linked exclusively to their wallets.

Top comments (0)