Learning Solana from Outdated Tutorials: What Changed in Modern Anchor
$ cat /writing/solana/2026-05-29-learning-solana-outdated-tutorials.md

Learning Solana from Outdated Tutorials: What Changed in Modern Anchor

5/29/2026 · 10 min · solana
SolanaRustAnchorLiteSVMPDA

TL;DR — I switched OS, went from Anchor 0.29 to 1.0.2, and half the tutorials I was following broke. This post documents my observations of what actually changed, what didn't, and why I think the migration is worth understanding even if you're just learning.

Disclosure: This post was written by me. I used an LLM for editorial feedback on structure and phrasing, but the code, migration process, and technical reasoning are my own.

I'm working through RareSkills' 60-day Solana course, which was written against Anchor 0.29.0, Solana 1.16.25, and a 2024-era nightly Rust. I recently switched from Ubuntu to CachyOS, and the fresh install pulled the current toolchain: Anchor 1.0.2, Solana 3.1.15, Rust 1.95. When I ran anchor init, the generated project looked very different to what the tutorials expected.

Rather than downgrading, I decided to migrate forward and document the differences. This turned out subjectively to be more useful than the tutorials themselves for understanding what Anchor actually does under the hood.

For reference, the jump in question:

RareSkills tutorials:  Anchor 0.29.0  ·  Solana 1.16.25
My machine (CachyOS):  Anchor 1.0.2   ·  Solana 3.1.15

Anchor's path from 0.29 to 1.0 went through 0.30 (rewritten IDL format), 0.31 (the last release before 1.0), and finally the 1.0 stable release — which is where the defaults I hit below actually landed.

Contents

  1. Project Structure: From Monolith to Modular
  2. Bump Access
  3. Account Types Tightened
  4. Token Program Split
  5. Instruction Serialization Changed
  6. What Stayed Invariant: PDAs
  7. Testing: Three Terminals vs One Command
  8. Takeaways

1. Project Structure: From Monolith to Modular

This was the first thing I noticed. Old anchor init gave you a single lib.rs where everything lived — program logic, account structs, constraints, all in one file:

// Old Anchor — everything in lib.rs
#[program]
pub mod example {
    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        // logic here
    }
    pub fn transfer(ctx: Context<Transfer>, amount: u64) -> Result<()> {
        // logic here
    }
}
 
#[derive(Accounts)]
pub struct Initialize<'info> { /* ... */ }
 
#[derive(Accounts)]
pub struct Transfer<'info> { /* ... */ }

Modern anchor init generates something different entirely:

programs/example_map/src/
├── lib.rs            // router only
├── instructions/
│   └── initialize.rs // handler + accounts struct
├── instructions.rs   // mod re-exports
├── state.rs          // account data structs
├── constants.rs
└── error.rs          // custom errors

And lib.rs becomes a thin dispatcher — it delegates immediately:

pub mod constants;
pub mod error;
pub mod instructions;
pub mod state;
 
use anchor_lang::prelude::*;
 
pub use constants::*;
pub use instructions::*;
pub use state::*;
 
declare_id!("BrVvXYcjvwwN6qkVP9rjmkTZQ8JxFygPLFGP5DhDqKHG");
 
#[program]
pub mod example_map {
    use super::*;
 
    pub fn initialize(ctx: Context<Initialize>, key: u64) -> Result<()> {
        initialize::handler(ctx, key)
    }
}

Every instruction goes into its own file under instructions/, with the accounts struct living alongside its handler:

// instructions/initialize.rs
use crate::state::Val;
use anchor_lang::prelude::*;
 
#[derive(Accounts)]
#[instruction(key: u64)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = signer,
        space = 8 + size_of::<Val>(),
        seeds=[&key.to_le_bytes().as_ref()],
        bump
    )]
    pub val: Account<'info, Val>,
 
    #[account(mut)]
    pub signer: Signer<'info>,
 
    pub system_program: Program<'info, System>,
}
 
pub fn handler(_ctx: Context<Initialize>, _key: u64) -> Result<()> {
    Ok(())
}

And account data lives in state.rs:

use anchor_lang::prelude::*;
 
#[account]
pub struct Val {
    pub value: u64,
}

This isn't just cosmetic. With the monolith approach, I had lib.rs files in the tutorials that were already 200+ lines at chapter 4. The modular layout is what real programs use in production, and now Anchor scaffolds it by default. If you're following a tutorial that puts everything in lib.rs, you're probably learning an organizational pattern that nobody ships.

2. Bump Access

Small change, but you will encounter it every time you copy-paste from an old tutorial:

// Old: runtime lookup, requires unwrap
let bump = *ctx.bumps.get("my_pda").unwrap();
 
// Modern: generated field, no unwrap
let bump = ctx.bumps.my_pda;

Anchor now generates typed bump fields from your #[derive(Accounts)] struct. The old .get() call was a BTreeMap lookup that returned Option — a runtime failure waiting to happen if you misspelled the account name. The new version is a compile-time field access. Cleaner, and one less .unwrap() in your code.

3. Account Types Tightened

Old tutorials use AccountInfo<'info> everywhere. Modern Anchor pushes you toward stricter typed wrappers:

Account<'info, MyState>                  // deserialized + owner-checked
Signer<'info>                            // must be a signer
UncheckedAccount<'info>                  // explicit opt-out of checks
InterfaceAccount<'info, TokenAccount>    // token program interface

UncheckedAccount still exists but the name is doing work since you're explicitly acknowledging you're skipping checks. Old tutorials using raw AccountInfo won't compile anymore because Anchor's defaults are stricter. This is clearly a good thing, since insufficient account validation is one of the most common Solana vulnerabilities: the type system now helps you avoid it.

4. Token Program Split

If you're following a tutorial that imports anchor_spl::token:

// Old
use anchor_spl::token::{Token, TokenAccount, Mint};
 
// Modern — interface-based, supports Token-2022
use anchor_spl::token_interface::{TokenInterface, TokenAccount, Mint};

This is the ecosystem's move toward Token Extensions (Token-2022). It's worth noting this isn't strictly an Anchor version change: anchor_spl::token still exists and compiles. But the token_interface abstraction lets your program work with both the legacy SPL Token program and the new Token-2022 one without changing your code, and it's increasingly the convention in modern tutorials and templates. If you're starting fresh, better use token_interface.

5. Instruction Serialization Changed

This is the change I found most interesting from a "what is Anchor actually doing" perspective.

Old tutorials showed instruction functions as standalone handlers:

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {}

Modern Anchor generates real instruction serializers from the function signature. When I wrote:

pub fn initialize(ctx: Context<Initialize>, key: u64) -> Result<()> {
    initialize::handler(ctx, key)
}

Anchor generates IDL entries, serialization code, and — this is the important part — client-side interfaces from it. In my LiteSVM test, I'm building the instruction like this:

let instruction = Instruction::new_with_bytes(
    program_id,
    &example_map::instruction::Initialize { key }.data(),
    example_map::accounts::Initialize {
        val: val_pda,
        signer: payer.pubkey(),
        system_program: system_program::ID,
    }
    .to_account_metas(None),
);

Look at what's happening:

  • example_map::instruction::Initialize { key } — Anchor generated a struct that serializes the instruction data. The key field comes directly from the function signature.
  • example_map::accounts::Initialize { ... }.to_account_metas(None) — Anchor generated a client-side account builder from the #[derive(Accounts)] struct. The #[derive(Accounts)] struct isn't just "backend macro magic." It's a contract with two faces: one for the on-chain program to validate accounts, the other for any client to know how to build a valid instruction. Old tutorials treated it as a server-side concern, but modern Anchor makes that duality explicit.

6. What Stayed Invariant: PDAs

One of the most important realizations from this whole migration is that PDA derivation logic is unchanged across Anchor versions.

On-chain:

#[account(
    seeds = [&key.to_le_bytes().as_ref()],
    bump
)]
pub val: Account<'info, Val>,

Client-side:

let (val_pda, _bump) = Pubkey::find_program_address(
    &[&key.to_le_bytes()],
    &program_id,
);

We have the same seeds, the same derivation and the same deterministic address. Anchor's scaffolding changed, its macro syntax changed, its test infrastructure changed, but the Solana runtime didn't. PDAs are a runtime concept, not an Anchor concept. When we understand how find_program_address works at the runtime level (SHA-256 hash of seeds + program ID, iterated until it falls off the ed25519 curve) we can adapt to any version of any framework.

This is worth internalizing: framework syntax changes; mental models don't.

7. Testing: Three Terminals vs One Command

This is where the migration changes the most, but also where I think the payoff is the biggest.

The Old Way

The RareSkills course (and most Anchor 0.29-era tutorials) has you run tests by opening three separate terminals:

Terminal 1 — start a local validator:

solana-test-validator

Terminal 2 — tail the logs:

solana logs

Terminal 3 — run the tests:

anchor test --skip-local-validator

The tests themselves are TypeScript, living in an app/ or tests/ directory, using @coral-xyz/anchor as a client:

import * as anchor from "@coral-xyz/anchor";
 
describe("example_map", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);
 
  it("Initializes a PDA", async () => {
    const program = anchor.workspace.ExampleMap;
    // ... build and send transaction
  });
});

This works, but the feedback loop is a bit painful. The validator takes seconds to start, the TS compilation adds latency, and the three-terminal dance is just friction. Then, every time I restarted the validator I had to redeploy, and every time I changed the program I had to rebuild, redeploy, re-run; adding up developing time.

The New Way: LiteSVM

LiteSVM is an in-process Solana VM, with no validator, no network and no TypeScript. You write Rust tests next to your Rust program and they run very fast. As of Anchor 1.0, it's the default test template — anchor init generates LiteSVM Rust tests unless you pass --test-template mocha (or jest). Node and Yarn aren't even required for day-to-day development anymore.

Here's my actual test for the mapping example:

use {
    anchor_lang::{
        prelude::Pubkey, solana_program::instruction::Instruction,
        system_program, InstructionData, ToAccountMetas,
    },
    litesvm::LiteSVM,
    solana_keypair::Keypair,
    solana_message::{Message, VersionedMessage},
    solana_signer::Signer,
    solana_transaction::versioned::VersionedTransaction,
};
 
#[test]
fn test_initialize() {
    let program_id = example_map::id();
    let payer = Keypair::new();
    let mut svm = LiteSVM::new();
    let bytes = include_bytes!("../../../target/deploy/example_map.so");
    svm.add_program(program_id, bytes).unwrap();
    svm.airdrop(&payer.pubkey(), 1_000_000_000).unwrap();
 
    let key: u64 = 42;
    let (val_pda, _bump) = Pubkey::find_program_address(
        &[&key.to_le_bytes()],
        &program_id
    );
 
    let instruction = Instruction::new_with_bytes(
        program_id,
        &example_map::instruction::Initialize { key }.data(),
        example_map::accounts::Initialize {
            val: val_pda,
            signer: payer.pubkey(),
            system_program: system_program::ID,
        }
        .to_account_metas(None),
    );
 
    let blockhash = svm.latest_blockhash();
    let msg = Message::new_with_blockhash(
        &[instruction],
        Some(&payer.pubkey()),
        &blockhash,
    );
    let tx = VersionedTransaction::try_new(
        VersionedMessage::Legacy(msg),
        &[payer],
    ).unwrap();
 
    let res = svm.send_transaction(tx);
    assert!(res.is_ok());
}

The entire flow: build program → cargo test → done. All in one terminal with sub-second feedback.

There are a few things I appreciate about this beyond speed:

Determinism. There's no timing issues, no "test passed locally but fails in CI because the validator was slow." The VM is in-process and synchronous.

Time travel. LiteSVM lets you overwrite the Clock sysvar directly:

let mut clock = svm.get_sysvar::<Clock>();
clock.unix_timestamp = 1735689600; // jump to Jan 2025
svm.set_sysvar::<Clock>(&clock);

If you're writing a program with time-dependent logic — vesting schedules, auction deadlines, epoch-based mechanics — this is important: with the old solana-test-validator approach you'd have to either mock the clock or wait real time.

Arbitrary account injection. You can write any account data you want, regardless of whether that state would be possible on-chain:

svm.set_account(
    ata,
    Account {
        lamports: 1_000_000_000,
        data: token_acc_bytes.to_vec(),
        owner: TOKEN_PROGRAM_ID,
        executable: false,
        rent_epoch: 0,
    },
).unwrap();

For example, if you need to test against a wallet holding 1M USDC without having the mint authority, you can just inject the account. This is very useful for testing edge cases that would be impossible to set up through normal transaction flows.

When to Still Use the Validator

LiteSVM doesn't support every RPC method, and some behaviors only show up in a real validator (gossip, slot timing, block production). For integration testing against mainnet-forked state or testing RPC-dependent client code, solana-test-validator is still the best tool. But for program logic LiteSVM is just the right tool.

The broader trend seems clear: Solana tooling is moving toward Rust-native workflows. The JS-heavy, multi-terminal patterns that early tutorials were built around are being replaced by faster, more deterministic alternatives. If you're starting now, better start with LiteSVM.


Takeaways

If you're learning Solana from tutorials written for Anchor 0.29 or earlier:

  1. The scaffold looks different. Don't fight the modular layout — it's what production programs use.
  2. ctx.bumps changed. Field access instead of map lookup. You'll notice when old code won't compile.
  3. Account types are stricter. Less AccountInfo, more typed wrappers. This is a security improvement.
  4. Token imports split. Use token_interface for forward compatibility with Token-2022.
  5. Instruction structs are client-side interfaces now. The #[derive(Accounts)] struct generates both on-chain validation and client-side builders.
  6. Test with LiteSVM. One terminal, sub-second feedback, time travel, account injection.
  7. The runtime didn't change. PDAs, ownership, rent, account model — all the same. Framework syntax changes; mental models don't.

The fact that PDA derivation survived unchanged while everything else around it evolved is in my eyes a lesson about Solana's architecture: the runtime is stable. The tooling is still catching up.


I'm working through RareSkills' Solana course and building a multihop arbitrage bot on Solana. More posts on Anchor patterns and Solana internals coming. Follow @0xReymon for updates.

# related

DeFi Bots Series — Part 1: A Practical Meteora DLMM Scanner (From TXs to Pool Intents)

We build a robust scanner that reads a leader’s recent transactions and extracts DLMM position inits as (poolAddress, positionPda) intents — no brittle bin-array decoding, just clean signals my scheduler can act on.

Bytes, Bits, and Breaking XOR — Notes from Cryptopals in Rust

Notes from implementing the first Cryptopals challenges in Rust without libraries: hex, base64, XOR, frequency analysis, Hamming distance, and breaking a repeating-key cipher.

dag_exec: a std-only DAG executor for CPU-heavy pipelines (pruning + bounded parallelism)

A tiny std-only DAG executor that computes only the requested outputs (partial evaluation) and runs heavy nodes in parallel with explicit bounds.