DEV Community

Cover image for Anchor Constraints as an Entire Auth Layer
Russell Oje
Russell Oje

Posted on

Anchor Constraints as an Entire Auth Layer

This week I made the jump from being a consumer of Solana programs to being a builder of them.

Let me walk you through what I built, what I tested, and the one experiment that made everything click.


The Program: A Counter with an Owner

The running example for the week was a counter program. Simple on the surface — one Pubkey (the owner) and one u64 (the count) — but surprisingly rich in what it teaches.

#[account]
#[derive(InitSpace)]
pub struct Counter {
    pub authority: Pubkey,
    pub count: u64,
}
Enter fullscreen mode Exit fullscreen mode

#[account] stamps an 8-byte discriminator onto the front of every serialised Counter. That discriminator is how the program can later verify "yes, this account was created by me and nothing else." #[derive(InitSpace)] auto-calculates the byte size so you don't have to count fields by hand. One macro, zero arithmetic.


Constraints Are Like Middleware

In a Web2 backend you might write a route guard like this:

if request.user.id != resource.authority_id:
    return 403
Enter fullscreen mode Exit fullscreen mode

In Anchor, you write this instead:

#[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 tells Anchor: before the increment handler runs, verify that the authority field stored inside the counter account on-chain matches the authority key passed in this transaction. If they don't match, the transaction fails before your Rust code ever executes. You didn't write an if statement. You declared a rule.

Similarly, the init constraint on Initialize does the heavy lifting of creating the account:

#[account(
    init,
    payer = authority,
    space = 8 + Counter::INIT_SPACE,
)]
pub counter: Account<'info, Counter>,
Enter fullscreen mode Exit fullscreen mode

One attribute makes a CPI to the System Program, allocates the right number of bytes, funds the rent from authority's wallet, assigns the account to your program, and refuses to run if the account already exists. You write one line. Anchor writes the boilerplate.


Testing with LiteSVM

Once the program writes state, you need a way to prove it works. In the earlier epochs, I observed that waiting for devnet confirmations was slow and flaky, which brings LiteSVM into the picture, as it is an in-process Solana virtual machine that can run your compiled .so binary against a fresh ledger in the same Rust test process.

The happy-path test looks like a normal Rust unit test, but it executes a real Solana transaction:

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());
Enter fullscreen mode Exit fullscreen mode

State even persists across transactions within one LiteSVM instance — just like a real cluster — so a test that calls initialize and then increment is a genuine end-to-end simulation.


Including Failure Tests

A happy-path test proves the program works. A failure test proves the program refuses. Both are necessary.

I wrote two:

1. Wrong authority is rejected

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 for a different signer");
Enter fullscreen mode Exit fullscreen mode

2. Double-initialization is rejected

svm.send_transaction(first_tx).expect("first initialize should succeed");
svm.expire_blockhash(); // make the second tx genuinely new
let result = svm.send_transaction(second_tx);
assert!(result.is_err(), "initializing the same counter twice should fail");
Enter fullscreen mode Exit fullscreen mode

The expire_blockhash() call is subtle but important. Without it, both transactions are byte-for-byte identical and LiteSVM rejects the second as a duplicate signature — the wrong error. Expiring the blockhash ensures the failure comes from the init constraint, not the duplicate check.


The Mutation Testing Experiment

On friday I deliberately broke the program in three ways, one at a time, and watched the tests catch each regression:

Bug introduced Test that failed Why
Removed has_one = authority increment_fails_when_wrong_authority_signs Constraint gone, unauthorized call now succeeds
Changed checked_add(1) to checked_add(2) initialize_then_increment Count was 2, expected 1
Commented out counter.authority = ... initialize_then_increment On-chain authority was all-zeros; has_one failed at increment time

The third bug was the most instructive. The initialize transaction itself succeeded, i.e. the account was created and rent was paid. The bug only surfaced at increment, when the runtime compared the stored (all-zero) authority to the real signer and found a mismatch. The error pointed downstream of the cause. The tests caught it; the logs explained it.


Take Home

Anchor constraints are not just convenience sugar. They are a declarative authorization layer that runs before your handler, cannot be bypassed by a careless refactor, and produces clear error messages when violated. Combined with LiteSVM's in-process test runner and a suite that includes both happy-path and failure tests, you get a feedback loop that's fast enough to run on every save and trustworthy enough to catch real bugs.

The mutation experiments this week weren't busywork. They were the proof. Every assertion that lit up red when I broke something is an assertion I now trust when it stays green.

Top comments (0)