Builder· Lesson 1 of 8· 5 min
Your first Anchor program
A piggy bank in seventy lines: a PDA that holds SOL and refuses to give it back before a date. Write it, test it, deploy it to devnet, and read it on an explorer.
Anchor is the framework most Solana programs are written with. It turns annotated Rust into a program plus an IDL, generates the account checks from declarations, and gives you a TypeScript client and a test runner. This lesson builds one small program end to end; the next one reads a production program the same way.
Setup
# Rust, the Solana CLI, then Anchor through its version manager
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"
cargo install --git https://github.com/coral-xyz/anchor avm --force
avm install latest && avm use latest
solana-keygen new # a local keypair, used to pay for deploys
anchor init piggy_bank && cd piggy_bankpiggy_bank/
├── Anchor.toml cluster, wallet, program ids
├── programs/piggy_bank/
│ ├── Cargo.toml
│ └── src/lib.rs the program
├── tests/piggy_bank.ts mocha tests, run by `anchor test`
└── target/ after a build: deploy/*.so, idl/*.json, types/*.tsThe program
Three instructions. initialize creates a bank account at a PDA derived from the owner's key and records when it unlocks. deposit moves SOL into it through a CPI to the System Program. withdraw checks the clock and moves SOL back by editing lamports directly — allowed, because the program owns the bank account.
use anchor_lang::prelude::*;
use anchor_lang::system_program::{self, Transfer};
declare_id!("Piggy1111111111111111111111111111111111111");
#[program]
pub mod piggy_bank {
use super::*;
pub fn initialize(ctx: Context<Initialize>, unlock_ts: i64) -> Result<()> {
let bank = &mut ctx.accounts.bank;
bank.owner = ctx.accounts.owner.key();
bank.unlock_ts = unlock_ts;
bank.bump = ctx.bumps.bank;
Ok(())
}
pub fn deposit(ctx: Context<Deposit>, lamports: u64) -> Result<()> {
system_program::transfer(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
Transfer {
from: ctx.accounts.owner.to_account_info(),
to: ctx.accounts.bank.to_account_info(),
},
),
lamports,
)
}
pub fn withdraw(ctx: Context<Withdraw>, lamports: u64) -> Result<()> {
let now = Clock::get()?.unix_timestamp;
require!(now >= ctx.accounts.bank.unlock_ts, BankError::StillLocked);
let bank = ctx.accounts.bank.to_account_info();
let rent_min = Rent::get()?.minimum_balance(bank.data_len());
let left = bank.lamports().checked_sub(lamports).ok_or(BankError::Insufficient)?;
require!(left >= rent_min, BankError::Insufficient);
**bank.try_borrow_mut_lamports()? -= lamports;
**ctx.accounts.owner.to_account_info().try_borrow_mut_lamports()? += lamports;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub owner: Signer<'info>,
#[account(
init,
payer = owner,
space = 8 + Bank::INIT_SPACE,
seeds = [b"bank", owner.key().as_ref()],
bump,
)]
pub bank: Account<'info, Bank>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(mut)]
pub owner: Signer<'info>,
#[account(mut, seeds = [b"bank", owner.key().as_ref()], bump = bank.bump, has_one = owner)]
pub bank: Account<'info, Bank>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub owner: Signer<'info>,
#[account(mut, seeds = [b"bank", owner.key().as_ref()], bump = bank.bump, has_one = owner)]
pub bank: Account<'info, Bank>,
}
#[account]
#[derive(InitSpace)]
pub struct Bank {
pub owner: Pubkey,
pub unlock_ts: i64,
pub bump: u8,
}
#[error_code]
pub enum BankError {
#[msg("The bank is still locked")]
StillLocked,
#[msg("Not enough lamports above the rent minimum")]
Insufficient,
}declare_id! | The program's address, baked into the binary. anchor keys sync rewrites it to match your deploy keypair. |
|---|---|
#[program] | The module whose functions become instructions. Each takes a Context of checked accounts plus arguments. |
#[derive(Accounts)] | The accounts an instruction needs and the constraints on each. Every check runs before the function body. |
#[account] + InitSpace | A stored struct with an 8-byte discriminator; INIT_SPACE is its size, computed for you. |
#[error_code] | Your errors, numbered from 6000 in order. The message is what explorers show. |
The test
Anchor generates a typed client from the IDL. Because the bank's seeds are in the IDL, the client derives the PDA itself; you only pass the owner. The interesting assertion is the failure: withdrawing early must fail with your error, by name.
import * as anchor from "@coral-xyz/anchor";
import { Program, AnchorError, BN } from "@coral-xyz/anchor";
import { assert } from "chai";
import { PiggyBank } from "../target/types/piggy_bank";
describe("piggy_bank", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.PiggyBank as Program<PiggyBank>;
const owner = provider.wallet.publicKey;
const [bank] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("bank"), owner.toBuffer()],
program.programId
);
it("holds a deposit and refuses an early withdrawal", async () => {
const unlock = Math.floor(Date.now() / 1000) + 60;
await program.methods.initialize(new BN(unlock)).accounts({ owner }).rpc();
await program.methods.deposit(new BN(100_000_000)).accounts({ owner }).rpc();
const state = await program.account.bank.fetch(bank);
assert.equal(state.unlockTs.toNumber(), unlock);
try {
await program.methods.withdraw(new BN(100_000_000)).accounts({ owner }).rpc();
assert.fail("withdraw should have failed");
} catch (e) {
assert.equal((e as AnchorError).error.errorCode.code, "StillLocked");
}
});
});Build, test, deploy
anchor build # target/deploy/piggy_bank.so and target/idl/piggy_bank.json
anchor keys sync # write the deploy keypair's address into declare_id! and Anchor.toml
anchor test # starts a local validator, deploys, runs tests/, stops it
solana config set --url devnet
solana airdrop 2 # deploying costs rent for the bytecode: about 1 SOL per 200 KB
anchor deploy --provider.cluster devnet
anchor idl init --provider.cluster devnet -f target/idl/piggy_bank.json <PROGRAM_ID>The last command publishes the IDL on-chain, which is what lets Solana Explorer show your instructions by name and Account Inspector decode your bank accounts. Deploying creates two accounts: the program (a small pointer, executable) and a program-data account holding the bytecode with you as upgrade authority. Both hold rent you get back with solana program close if you retire the program.
Read it back
Paste the program id into an explorer set to devnet. The program page shows it upgradeable with your wallet as authority; the IDL tab lists initialize, deposit and withdraw with their accounts. Paste a bank PDA and the account is decoded as a Bank with an owner, an unlock time and a bump. Everything Reading an Anchor program described from the outside, you have now produced from the inside.
What to remember
- anchor init, build, test, deploy is the whole loop. The build produces the binary and the IDL together.
- Accounts and their checks are declared in a struct; the instruction body only runs if every check passed.
- A program can move lamports out of an account it owns by editing them directly, and must leave the rent minimum.
- The typed client derives PDAs from the IDL; tests assert failures by error name.
- Publish the IDL on-chain so explorers and inspectors can decode your program.