Skip to content

The IDL, Testing, and the Localnet Loop

The last five pages built a program from the inside out: the native entrypoint that receives raw accounts, the checks you must do by hand, Anchor’s constraints that turn those checks into declarations, and the PDAs and CPI that let a program own state and call other programs. All of that is the inside of the box. This page is about the outside: how a client, a test, or a wallet actually talks to your program, and how you prove — before you ship — that the program does what you claim and refuses what it must refuse.

Two things make that possible, and they are the subject here. The first is the IDL: a machine-readable JSON contract Anchor generates from your program, describing every instruction, account, and argument so a client can build a correct transaction without ever reading your Rust. The second is the anchor test loop: spin up a private validator on your laptop, deploy the program to it, generate a typed client from the IDL, run a suite that exercises both the happy path and every failure the earlier pages warned you about, then tear it all down. On Solana the exploits are account-confusion and missing-check bugs, so the tests that matter most are the ones that try to break your constraints — turning page 3’s manual checks into regression tests that fail loudly the day someone deletes one.

The IDL: a machine-readable contract for your program

Section titled “The IDL: a machine-readable contract for your program”

A deployed Solana program is SBF bytecode in an executable account. It has exactly one entrypoint, and everything it accepts arrives as two opaque byte blobs: an ordered list of accounts and an instruction_data slice. Nothing about that bytecode tells a client how to build those bytes — which 8-byte discriminator selects “increment,” which order the accounts go in, whether an argument is a u64 or a Pubkey. The wire format is honest but silent.

The IDL — Interface Description Language — is the missing description. When Anchor builds your program it also emits a JSON file that names, in structured form, every piece of the interface:

your Rust program anchor build counter.json (the IDL)
───────────────── ─────────── ──────────────────────
#[program] fn increment(...) ─► compiler + macro expansion ─► instructions[]: name, discriminator,
#[derive(Accounts)] struct (SBF bytecode + accounts[], args[]
#[account] struct Counter the IDL as a side output) accounts[]: Counter { count: u64 }
types[], errors[], address

Read the IDL as the type signature of your whole program, serialized so a machine can consume it. A trimmed counter IDL looks like this:

{
"address": "Counter111111111111111111111111111111111111",
"instructions": [
{
"name": "increment",
"discriminator": [11, 18, 104, 9, 104, 174, 59, 33],
"accounts": [
{ "name": "counter", "writable": true },
{ "name": "user", "signer": true }
],
"args": [ { "name": "step", "type": "u64" } ]
}
],
"accounts": [ { "name": "Counter", "discriminator": [255, 176, 4, 245, 188, 253, 124, 25] } ],
"types": [
{ "name": "Counter", "type": { "kind": "struct", "fields": [ { "name": "count", "type": "u64" } ] } }
]
}

Every field earns its place, and each maps back to something you wrote:

  • instructions[].name + discriminator — the name of a #[program] handler and the 8-byte tag Anchor prepends to instruction_data to select it. This is the “which action?” byte we met when parsing accounts by hand; Anchor computes it as a hash of the instruction name so it never collides.
  • instructions[].accounts[] — the #[derive(Accounts)] struct, in order, with each account’s writable and signer flags. This is the declared footprint a client must supply, in exactly the order the program expects it.
  • instructions[].args[] — the typed arguments (here step: u64). The client serializes these with Borsh and appends them after the discriminator to form instruction_data.
  • accounts[] + types[] — the layout of each #[account] struct, including its own 8-byte discriminator, so a client can deserialize an account it reads back (turn a raw buffer into { count: 5 }) and know which type a buffer claims to be.
  • errors[] (omitted above) — your custom error codes and messages, so a client can map a failure number back to a human-readable reason.

The crucial property: the IDL is generated, not hand-written. It cannot drift from the program the way a separate spec document would, because it is the program’s interface, extracted by the same build that produced the bytecode. Change an argument type in Rust and the next anchor build changes the IDL to match.

Under the hood — the discriminator is why the IDL can be trusted

Section titled “Under the hood — the discriminator is why the IDL can be trusted”

The IDL leans on a mechanism from the account-model part: the 8-byte discriminator. Anchor prepends one to every instruction (the first 8 bytes of SHA256("global:increment")) and every account type (the first 8 bytes of SHA256("account:Counter")). That single design choice is what makes the JSON safe to act on:

  • On an instruction, the discriminator is how the program’s generated dispatcher decides which handler to run — a client that copies the wrong 8 bytes from the IDL simply invokes the wrong function or none at all, a clean failure rather than a silent misfire.
  • On an account, the discriminator is a type tag. When Anchor deserializes an account into Account<'info, Counter>, it first checks that the account’s leading 8 bytes equal Counter’s discriminator. If a caller substitutes a different account of the same size, the tag mismatches and Anchor rejects it — the framework’s structural answer to the account-confusion attacks from page 3.

So the IDL is not just documentation a client hopes is current. Each entry carries a discriminator the running program will re-check, which means a transaction built from a stale or lying IDL fails at the door rather than corrupting state. The description and the enforcement share the same tag.

Knowing the interface, you can now test the program against it. Anchor’s anchor test runs a fixed loop, and understanding the loop is worth more than memorizing the command:

1. BUILD anchor build Rust ─► SBF .so + the IDL (JSON)
2. VALIDATOR spin up localnet a fresh solana-test-validator on your laptop, empty ledger
3. DEPLOY deploy the .so write the bytecode into an executable account on localnet
4. CLIENT load the IDL generate a typed client so tests call increment(step) directly
5. RUN execute the tests build transactions from the IDL, submit, assert on the results
6. TEARDOWN kill the validator the whole ledger is thrown away; next run starts clean

Each step exists for a first-principles reason:

  • A local validator (localnet) is a complete Solana runtime running on your machine — the same SBF loader, the same Sealevel scheduler, the same sysvars — but with a ledger you control and can reset in seconds. You get real runtime semantics (real rent, real compute limits, real account checks) with none of the cost or latency of a shared network.
  • Deploy is the step that writes your .so into an on-chain executable account. Until this happens the program does not exist to the runtime; after it, transactions can name its address. On localnet this costs nothing and takes a moment; the mechanism is identical to a mainnet deploy.
  • The generated client reads the IDL and hands your test suite typed methods — program.methods.increment(5).accounts({...}).rpc() — instead of hand-packed byte buffers. The IDL is doing exactly its job: it is the bridge from your test code to a correct transaction.
  • Teardown guarantees isolation. Every run starts from an empty ledger, so a test can never pass because of state a previous run left behind. This is what makes the suite a regression suite rather than a diary of accidents.
Terminal window
# The whole loop behind one command.
anchor test
# Under the hood it does roughly:
solana-test-validator # step 2: start localnet (fresh ledger)
anchor build # step 1: compile to SBF + emit target/idl/counter.json
anchor deploy # step 3: write the .so into an executable account
# # step 4: the test runner loads the IDL, builds a typed client
# # step 5: your tests run against localnet
# # step 6: the validator is torn down on exit

Distinguishing the layers you are standing on

Section titled “Distinguishing the layers you are standing on”

Three tools and three networks get conflated constantly. Keep them straight:

TOOLS
solana CLI the base client — keypairs, airdrops, balances, low-level deploy, RPC calls
anchor CLI a layer above it — build (Rust→SBF+IDL), deploy, test, generate the client
NETWORKS (same runtime, different ledger + stakes + cost)
localnet a validator on YOUR machine; free, resettable, instant; where tests live
devnet a shared public test network; free "airdrop" SOL, real latency, others' programs
mainnet-beta the real chain; real SOL, real value, real adversaries; deploy costs real money

The runtime is the same on all three — that is the point of localnet. A program that passes on localnet can still surprise you on devnet (real network timing, other people’s accounts) and again on mainnet (real adversaries probing your constraints), which is why the test suite’s job is to close the gap before devnet, by attacking the program the way an adversary would.

Test the failures, not just the happy path

Section titled “Test the failures, not just the happy path”

Here is the page’s load-bearing claim, and it is specific to Solana’s model. On a chain where state lives in external accounts that the caller supplies, the dangerous bug is never “the arithmetic is wrong.” It is “the program trusted an account it should have checked.” Every incident in this part traces back to a missing check: a wrong owner, an unverified signer, a substituted PDA, an account of the right size but the wrong type. Those bugs do not show up on the happy path — the happy path supplies the right accounts. They show up only when someone supplies the wrong ones on purpose.

So a Solana test suite that only exercises success is testing the wrong thing. The suite must try to break the constraints and assert that each break is rejected. The companion runtime, rust/solmini, makes the shape of such a test concrete — a success assertion paired with a failure assertion that checks both the error and that no state changed:

#[test]
fn transfer_moves_lamports() {
let prog = TransferProgram;
let mut accounts = vec![Account::wallet(100), Account::wallet(0)];
prog.process(&mut accounts, &50u64.to_le_bytes()).unwrap(); // happy path
assert_eq!(accounts[0].lamports, 50);
assert_eq!(accounts[1].lamports, 50);
}
#[test]
fn transfer_rejects_overdraft_without_mutating() {
let prog = TransferProgram;
let mut accounts = vec![Account::wallet(10), Account::wallet(0)];
let err = prog.process(&mut accounts, &50u64.to_le_bytes()); // the ATTACK: overdraw
assert!(matches!(err, Err(SolError::InsufficientFunds { have: 10, need: 50 })));
// The debit happens only AFTER the check, so a rejected transfer left state intact.
assert_eq!(accounts[0].lamports, 10);
assert_eq!(accounts[1].lamports, 0);
}

The second test is worth more than the first. It encodes the invariant “a failed transfer changes nothing” as an executable check — the same guarantee the runtime gives by discarding a failed transaction’s working set, now pinned down so a refactor cannot quietly break it.

Against a real Anchor program, the failure tests you must write map one-to-one onto the checks from page 3 and the constraints from page 4:

the check (page 3/4) the failure test (assert it REJECTS)
────────────────────── ──────────────────────────────────────
#[account(mut)] / owner pass an account owned by a DIFFERENT program → expect ConstraintOwner
Signer<'info> submit without the required signature → expect a signature error
has_one = authority pass a mismatched authority account → expect ConstraintHasOne
seeds = [...] , bump pass a PDA derived from the WRONG seeds → expect ConstraintSeeds
checked arithmetic drive an add to overflow → expect the program's error

Each row is a manual check turned into a permanent tripwire. The day a teammate deletes has_one = authority “to simplify the struct,” the matching failure test flips from green to red, because the mismatched-authority account it feeds is suddenly accepted. That is the whole value of the discipline: the security checks stop being something you remember to do and become something the suite refuses to let you undo.

  • Why does it exist? Because a deployed program is opaque bytecode with one byte-blob entrypoint — the IDL restores the type signature a client needs to build a correct transaction, and the test loop restores the confidence that the program does what the IDL claims and refuses what it must.
  • What problem does it solve? Two: clients would otherwise hand-pack discriminators and account orders from a spec that drifts (the IDL is generated, so it can’t), and on-chain security bugs would otherwise ship undetected (the failure tests turn each manual check into a regression tripwire).
  • What are the trade-offs? The IDL is only as honest as the build that emits it — deploy a program whose on-chain bytecode no longer matches the IDL you handed clients and every transaction they build is wrong. The test loop adds a validator, a deploy, and a client-generation step to every run, buying real runtime fidelity at the cost of seconds per run and toolchain setup.
  • When should I avoid it? You don’t skip the IDL — it’s how clients reach your program. You can skip localnet for pure Rust unit tests (like solmini’s) that exercise handler logic without a runtime; those are faster and belong in the suite too. Reserve the full localnet loop for tests that need real accounts, rent, PDAs, and CPI.
  • What breaks if I remove it? Without the IDL, every client re-derives your discriminators and account layout by hand and silently breaks on any change. Without the failure tests, the account-confusion and missing-check bugs that drain Solana programs ship straight to mainnet — where, as Wormhole shows, they are found by adversaries instead of by CI.
  1. A deployed program is SBF bytecode with a single byte-blob entrypoint. Name three things the IDL tells a client that the bytecode alone does not, and why the client needs each to build a valid transaction.
  2. The IDL is generated by anchor build rather than hand-written. Why does that property matter, and what goes wrong if the IDL a client holds is stale relative to the deployed bytecode?
  3. Walk the six steps of the anchor test loop and say what each accomplishes. Which step guarantees that a test can’t pass because of state left behind by a previous run?
  4. Distinguish localnet, devnet, and mainnet-beta. Given that the runtime is the same on all three, why does a program that passes on localnet still need to be promoted through devnet before mainnet?
  5. The page argues the failure tests matter more than the happy-path tests on Solana. Explain why, using the Wormhole incident, and describe the one test that would have caught it.
Show answers
  1. The IDL supplies: the instruction discriminators + names (which 8-byte tag selects each handler), the ordered account list with writable/signer flags (the declared footprint the client must supply in the right order), and the argument and account-struct types (so the client can Borsh-serialize args and deserialize accounts it reads). The bytecode encodes none of this legibly; without it the client cannot know which bytes to send or how to read a result.
  2. Because a generated IDL cannot drift from the program’s real interface — it is extracted by the same build that produced the bytecode, so changing an argument type in Rust changes the IDL on the next build. If a client holds a stale IDL, the transactions it builds carry the wrong discriminator, account order, or arg layout and fail against the deployed program (or, worse, target the wrong handler) — the interface it thinks it’s calling no longer exists.
  3. Build (Rust → SBF + IDL), validator (start a fresh localnet), deploy (write the .so into an executable account), client (load the IDL, generate typed methods), run (build transactions from the IDL, submit, assert), teardown (kill the validator, discard the ledger). Teardown — plus the fresh validator at the start — guarantees isolation: every run begins from an empty ledger, so no test can pass on leftover state.
  4. localnet is a validator on your machine (free, resettable, instant, isolated); devnet is a shared public test network (free faucet SOL, real latency, other people’s programs); mainnet-beta is the real chain (real SOL and real adversaries). Even with identical runtime, localnet cannot surface network-level behavior (timing, contention, other accounts) or adversarial probing, so devnet is where you validate real-network behavior before risking real value on mainnet.
  5. On Solana, state lives in caller-supplied external accounts, so the dangerous bugs are missing account checks (wrong owner, unverified signer, substituted PDA, wrong type), not arithmetic — and those only manifest when someone supplies the wrong accounts on purpose, which the happy path never does. Wormhole (2 February 2022, ~120,000 wETH / ~$320M) was drained because an instruction trusted a substituted signature-verification account it failed to confirm was the canonical one. A single failure test that fed that instruction a counterfeit verification account and asserted rejection would have turned red in CI and caught it.