Most Anchor tutorials show you how to write #[program] handlers and call them from TypeScript. Few explain what actually happens in between. That gap matters — when something breaks on-chain, understanding the machinery is the difference between a five-minute fix and a three-hour debug session.
Anchor is just macros and a serialization contract
At its core, Anchor is three things: a Rust macro system that generates boilerplate, a CLI that wires up builds and tests, and a shared serialization contract between your TypeScript client and your on-chain program. Everything else follows from that.
When you run anchor init, you get a workspace with two entry points that have to stay in sync: programs/my_project/src/lib.rs (your on-chain logic) and tests/my_project.ts (your TypeScript client). The IDL file generated at build time is what keeps them in sync.
The discriminator is how Anchor routes instructions
Every Anchor program can handle multiple instructions. When a transaction arrives, the runtime doesn’t know which function to call — it just hands the program a raw byte buffer. Anchor’s solution is a discriminator: an 8-byte prefix computed as:
sha256("global:<instruction_name>")[0..8]
So initialize becomes the first 8 bytes of sha256("global:initialize"). The TypeScript client computes this automatically when you call .methods.initialize(), prepends it to the serialized arguments, and that’s what lands on-chain as instruction data:
[ 8-byte discriminator | Borsh-serialized args ]
The generated entry function in your program reads those first 8 bytes, matches against the known discriminators, and dispatches to the right handler. If the discriminator doesn’t match anything, the instruction fails.
Worth knowing: Anchor 0.31+ lets you override the default discriminator if you need a custom value.
What .rpc() actually sends
When you write this in TypeScript:
await program.methods
.initialize()
.accounts({ myAccount: pda, payer: wallet.publicKey, systemProgram: SystemProgram.programId })
.signers([wallet])
.rpc();
The Anchor client looks up initialize in the IDL, builds the instruction data (discriminator + Borsh-serialized args), assembles a full transaction with the account list you provided, and sends it over RPC. The Solana runtime then routes the transaction to your program by program ID.
Your program’s compiled .so bytecode receives three things from the runtime:
- The program’s own public key
- An array of
AccountInfostructs for every account passed in - The raw instruction data bytes
From there, Anchor takes over again on the Rust side.
How account validation runs before your code
The part that confused me longest: your actual handler function doesn’t run first. Before initialize(ctx: Context<Initialize>) gets called, Anchor runs through every field in your #[derive(Accounts)] struct and validates them.
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = payer, space = 8 + MyData::INIT_SPACE)]
pub my_account: Account<'info, MyData>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
Each constraint — init, mut, seeds, has_one, constraint — is checked before your handler body runs. If any check fails, the instruction errors out with no side effects. Your handler only runs if every account passes validation.
This is the right mental model: #[derive(Accounts)] is your guard layer, and pub fn initialize(ctx: ...) -> Result<()> is just the logic that runs after the guards pass.
Avoid magic numbers with InitSpace
When you write space = 8 + 64, that 64 will rot. Use #[derive(InitSpace)] on your account struct instead:
#[account]
#[derive(InitSpace)]
pub struct MyData {
pub owner: Pubkey,
pub amount: u64,
#[max_len(32)]
pub label: String,
}
Then in your Initialize context:
#[account(init, payer = payer, space = 8 + MyData::INIT_SPACE)]
pub my_account: Account<'info, MyData>,
The 8 + is for Anchor’s own account discriminator — don’t skip it, or you’ll hit AccountDidNotDeserialize and wonder why.
Debugging when things go wrong on-chain
A few things I reach for in order:
Check .anchor/program-logs/ first. The validator writes logs here during anchor test runs. Most errors leave a trace.
Add .rpc({ skipPreflight: true }) when the error message is unhelpful. Preflight simulates the transaction locally and often swallows the real error. Skipping it forces the transaction to hit the validator, which gives better logs.
Decode custom errors yourself. If you see Program failed: custom program error: 0x6001, that hex breaks down as 0x6000 (Anchor’s error offset) + 0x1 (your enum variant at index 1). Find that variant in your #[error_code] enum and you have your error.
PDAs can’t sign. Error: unknown signer almost always means you’re trying to pass a PDA as a signer directly. PDAs sign implicitly through CPI via invoke_signed — the program vouches for the PDA using its seeds, not a private key.
Expand the macros when confused. When Anchor’s behavior doesn’t make sense, run cargo expand > expanded.rs from your program directory. Everything the macros generate is right there in plain Rust.
The security constraints you can’t skip
Anchor handles some checks automatically, but not all of them. A few that matter:
Verify signers explicitly. If an instruction should only be callable by an account’s authority, add constraint = account.authority == signer.key() @ MyError::Unauthorized rather than checking it inside the handler. Putting it in the constraint means it runs before anything mutates.
Verify account ownership. Anchor’s Account<'info, T> wrapper checks that the account is owned by your program, but raw AccountInfo does not. If you’re using AccountInfo for anything sensitive, check .owner yourself.
Never use init_if_needed carelessly. It’s a footgun — an attacker can call an instruction that uses init_if_needed to reinitialize an existing account and reset its state. Anchor won’t stop them. Use init when you know the account shouldn’t exist yet, and handle the already-exists case client-side.
The macro system handles a lot. It doesn’t handle all of it.