DEV Community

Gopichand
Gopichand

Posted on

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

I spent Days 57–61 building the same counter program over and over.
Not because I kept breaking it (well, I did), but because each day
revealed something about Anchor that the previous day's green tests
had been quietly hiding.

This is that story.


The Accounts Struct — Where Anchor Earns Its Name

Every Anchor instruction starts with an accounts struct. Here's mine
for Initialize:

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

Three fields, three jobs:

  • counter — the new on-chain account being created. Anchor's init constraint handles the create_account CPI to the System Program automatically. space = 8 + 40 is the discriminator (8 bytes) plus the actual struct data (40 bytes).
  • authority — the wallet paying for the account and signing the transaction. mut is required because its lamport balance will decrease.
  • system_program — Solana requires you to pass the System Program explicitly whenever you're creating accounts. Nothing happens if it's missing — the transaction just fails.

Coming from backend development, this was the first thing that
genuinely surprised me. In Web2 you pass data to a function. In
Anchor you first declare every account your instruction will touch,
with its permissions, and Anchor verifies all of it before your
handler code runs even one line.


The Handlers — Short by Design

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    let counter = &mut ctx.accounts.counter;
    counter.count = 0;
    counter.authority = ctx.accounts.authority.key();
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

ctx.accounts gives you typed, validated access to every account
declared in the struct above. The handler is three lines because
Anchor already did the hard work — account creation, lamport
deduction, ownership checks — before this function was called.

Now increment:

pub fn increment(ctx: Context<Increment>) -> Result<()> {
    let counter = &mut ctx.accounts.counter;
    counter.count = counter.count.checked_add(1)
        .ok_or(ErrorCode::Overflow)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

And its accounts struct:

#[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

has_one = authority is the constraint that does the real work.
Before increment runs, Anchor checks that counter.authority ==
authority.key()
. If a different wallet signs, the transaction
reverts with ConstraintHasOne (Error 2001) before a single line
of my handler executes. The constraint is a pre-condition, not a
runtime check I have to write myself.


The Tests — Two Paths That Matter

Happy path:

#[test]
fn initialize_then_increment() {
    // ... setup ...
    let res = svm.send_transaction(increment_tx);
    assert!(res.is_ok());
    let counter: Counter = svm.get_account_data(&counter_kp.pubkey());
    assert_eq!(counter.count, 1);
}
Enter fullscreen mode Exit fullscreen mode

What would have to go wrong for this to fail? The increment
handler would have to not add 1 — either wrong arithmetic, a
missing account write-back, or a serialization error.

Failure path — wrong authority:

#[test]
fn increment_fails_when_wrong_authority_signs() {
    // wrong_wallet is a fresh keypair, not the counter's authority
    let res = svm.send_transaction(bad_tx);
    assert!(res.is_err(), "increment should fail when signed by wrong authority");
}
Enter fullscreen mode Exit fullscreen mode

What would have to go wrong for this to fail? The has_one
constraint would have to be missing or wrong. Without it, any
wallet could increment anyone's counter — which is exactly the
exploit this test exists to prevent.


Day 61 — Mutation Testing: Proving the Tests Are Real

Green tests don't prove your code is correct. They prove your code
satisfies your tests. Those are different things if your tests are
wrong.

So on Day 61 I intentionally broke the program three ways and
watched the test suite respond.

Bug 2: I changed checked_add(1) to checked_add(2)

// Before
counter.count = counter.count.checked_add(1).ok_or(ErrorCode::Overflow)?;

// After (intentional bug)
counter.count = counter.count.checked_add(2).ok_or(ErrorCode::Overflow)?;
Enter fullscreen mode Exit fullscreen mode

Test output:
thread 'initialize_then_increment' panicked at programs/counter/tests/counter.rs:97:5:
assertion left == right failed
left: 2
right: 1

The test caught it immediately. The assertion assert_eq!(counter.count, 1) exists precisely because of off-by-one errors like this. Before this experiment I had pattern-matched that assertion from a tutorial. After the experiment I understood why it has to be exactly 1 and not "nonzero" or "greater than 0."

I ran git restore and all three tests went green again.


What Writing This Revealed

The has_one constraint took me three drafts to explain cleanly.
I had been thinking of it as "check the authority" — vague enough
that I couldn't have told you exactly when it runs or what it
compares. Writing forced precision: it compares counter.authority
(stored on-chain at init time) against the authority account
passed in at increment time, and it runs before the handler, not
inside it.

That distinction — constraint vs. runtime check — is the thing
I would have gotten wrong in a code review before this week.


What's Next

Week 10 will push past single-account programs. I want to build
something with multiple accounts interacting — a vault, an escrow,
or a simple DEX instruction — where the account constraints do
more than guard a single field. That's where I expect the next
gap to show up.

Full code on GitHub

Day 62 of 100. Building daily.

Top comments (0)