How to Build PDAs on Solana: Anchor Guide.

Okay, before you touch your local machine, hop into Solana Playground. It's browser based, no installs, deploys in seconds. Why? You'll screw up Rust/Anchor setup otherwise, and it's frustrating as hell. I always start there for PDA stuff.

Hit "Create a new project", name it "pda escrow test", pick Anchor (Rust). Boom. You're in. Now, delete the starter code in lib.rs. We're building from scratch.

What the Hell is a PDA Anyway?

PDAs are like magic wallets your program controls. No private keys. Deterministic - same seeds, same address every time. Perfect for escrow, vaults, game states. Think: sender + receiver + "escrow" string = unique PDA that holds funds till conditions met.

The thing is, Solana's ed25519 curve has this "line" - if your seed hash lands on it, it's a valid keypair (bad). Off curve? PDA. Anchor finds the bump (magic number 255 down to 0) to push it off curve.

Why does this matter? Your program signs for the PDA via seeds + bump. No one else can touch it unless they know the seeds. Sound familiar? Like a hashmap value, but on chain.

Seeds 101

  • Fixed string: "escrow"
  • User pubkey: sender
  • User pubkey: receiver
  • Anchor adds bump automatically

Caveat: Same seeds = same PDA. Can't reuse for multiple escrows between same pair. Add nonce or timestamp seed if needed.

Build Your First PDA: Escrow Example

Let's make an escrow PDA. Sender creates it, funds it with SOL. Later, you can add release logic. Start in lib.rs.

First, imports and declare_id:

rust use anchor_lang::prelude::*; use anchorlang::systemprogram; declare_id!("11111111111111111111111111111111");

Now, define your Escrow account struct. Simple: store sender, receiver, amount.

rust #[account] pub struct Escrow { pub from: Pubkey, pub to: Pubkey, pub amount: u64, }

Okay, the instruction context. This is where Anchor magic happens. Use #[account(init, payer = from, space = 8 + 32 + 32 + 8)] - init creates PDA, payer funds rent, space is discriminator (8 bytes) + two Pubkeys (32 each) + u64 (8).

But smarter: use anchorlang::solanaprogram::accountinfo::nextaccount_info no, Anchor's sizeof!

rust #[derive(Accounts)] pub struct CreateEscrow<'info> { #[account( init, payer = from, space = 8 + Escrow::INITSPACE, // We'll define INITSPACE seeds = [b"escrow", from.().asref(), to.().asref()], bump )] pub escrow: Account<'info, Escrow>, #[account(mut)] pub from: Signer<'info>, /// CHECK: This is the receiver pub to: AccountInfo<'info>, pub system_program: Program<'info, System>, }

impl on Escrow:

rust impl Escrow { pub const INIT_SPACE: usize = 8 + 32 + 32 + 8; }

Now the instruction function:

rust #[program] pub mod pdaescrow { use super::*; pub fn createescrow(ctx: Context, amount: u64) -> Result<()> { let escrow = &mut ctx.accounts.escrow; escrow.from = ctx.accounts.from.(); escrow.to = ctx.accounts.to.(); escrow.amount = amount; Ok(()) } }

That's it for the program. Hit Build in Playground. If errors, check space - rent is ~0.002 SOL for this, paid by payer.

Test It Right Now

Switch to tests/anchor.test.ts. Replace everything. We're funding the PDA post creation, but first create.

  1. Airdrop: Terminal > solana airdrop 5
  2. Deploy: Click Deploy or anchor deploy
  3. Write test:
typescript import * as anchor from "@coral xyz/anchor"; import { Program } from "@coral xyz/anchor"; import { PdaEscrow } from "./target/types/pda_escrow"; import { PublicKey, LAMPORTSPERSOL } from "@solana/web3.js"; describe("pda escrow", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const program = anchor.workspace.PdaEscrow as Program; let from = provider.wallet.publicKey; let to = anchor.web3.Keypair.generate().publicKey; it("Creates PDA", async () => { const [escrowPda] = PublicKey.findProgramAddressSync( [Buffer.from("escrow"), from.toBuffer(), to.toBuffer()], program.programId ); await program.methods .createEscrow(new anchor.BN(1 * LAMPORTSPERSOL)) .accounts({ escrow: escrowPda, from: from, to: to, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); const escrow = await program.account.escrow.fetch(escrowPda); console.log("Escrow amount:", escrow.amount.toString()); }); });

Run anchor test. Green? PDA created. Check Solana Explorer (localnet) - escrow account owned by your program, funded with rent + whatever.

Common Screw Ups and Fixes

Build fails on space? Calc: 8 (disc) + 32 (pubkey) + 32 (pubkey) + 8 (u64) = 80. But pad to 88 or use sizeof<Escrow>(). Rent calc: minimum_balance(80) ~0.0015 SOL.

PDA collision? Seeds too simple. Add [u8 nonce]. Frontend generates bump too: findProgramAddressSync(seeds, programId).

Tests flop? Wrong bump. Always derive PDA in TS same as Rust seeds. Bump mismatches kill it.

In my experience, 80% issues are seeds/bump. Print 'em: msg!("PDA: {:?}, bump: {:?}", pda.(), bump);

Upgrading: Fund the PDA with SOL

Creation doesn't transfer SOL yet. Add transfer instruction? Nah, use SystemProgram transfer in create, but PDA needs to be mut.

Separate fund instr. New context:

rust #[derive(Accounts)] pub struct FundEscrow<'info> { #[account( mut, seeds = [b"escrow", escrow.from.asref(), escrow.to.asref()], bump, hasone = from @ ErrorCode::InvalidOwner )] pub escrow: Account<'info, Escrow>, #[account(mut)] pub from: Signer<'info>, pub systemprogram: Program<'info, System>, }

Function:

rust pub fn fundescrow(ctx: Context, amount: u64) -> Result<()> { let ix = anchorlang::systemprogram::transfer( CpiContext::new( ctx.accounts.systemprogram.toaccountinfo(), anchorlang::systemprogram::Transfer { from: ctx.accounts.from.toaccountinfo(), to: ctx.accounts.escrow.toaccountinfo(), }, ), amount, )?; anchorlang::solanaprogram::program::invoke(&ix, &[ ctx.accounts.from.toaccountinfo(), ctx.accounts.escrow.toaccountinfo(), ctx.accounts.systemprogram.toaccount_info(), ])?; ctx.accounts.escrow.amount += amount; Ok(()) }

Wait, better: Use Anchor's transfer CPI.

rust use anchorlang::systemprogram::{transfer, Transfer}; systemprogram::transfer( CpiContext::new( ctx.accounts.systemprogram.toaccountinfo(), Transfer { from: ctx.accounts.from.toaccountinfo(), to: ctx.accounts.escrow.toaccountinfo(), }, ), amount, )?; ctx.accounts.escrow.amount += amount;

Test it: After create, call fund_escrow with escrowPda. Balance check: escrow.lamports increases by amount (1 lamport min, but use 0.001 SOL).

Local Setup If Playground Annoys You

Fine, you want CLI. Install Rust, Solana CLI, Anchor.

  1. sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
  2. cargo install --git https://github.com/coral xyz/anchor anchor cli --locked
  3. anchor init pda local
  4. cd pda local; solana config set --url localhost; solana test validator &
  5. Paste code, anchor build; anchor deploy; anchor test

Pro tip: WSL2 on Windows, or macOS. VSCode + rust analyzer extension. Fees? Localnet free. Devnet: ~0.000005 SOL/tx.

Real World Twist: Withdraw from PDA

Escrow done? Release to receiver. New instr, sign by sender or authority.

ActionAccountsSeeds/BumpLamports Moved
Createescrow (init), from (payer), to"escrow" + from + toRent only (~0.0018 SOL)
Fundescrow (mut), from (mut signer)SameUser amount (e.g. 0.1 SOL)
Withdrawescrow (mut, close?), from (signer), to (mut)Sameescrow.lamports - rent

Withdraw func sketch:

rust pub fn withdraw(ctx: Context) -> Result<()> { let escrow = &mut ctx.accounts.escrow; // CPI transfer escrow.lamports - rent back? Or full close. // For close: ctx.accounts.escrow.toaccountinfo().tryborrowmut_lamports()? -= rent; // Then transfer to to. // Logic: if from signs, send to 'to'. Ok(()) }

Full close PDA: Return rent to payer. Add #[account(mut, close = from)] on escrow. Lamports auto refund. Brutal efficiency.

Token Accounts in PDA? SPL Twist

SOL boring? PDAs hold SPL tokens too. Associated Token Account (ATA) derived from PDA owner.

Seeds: PDA seeds + token mint + program (Token2022 or whatever). Use findprogramaddress for ATA.

In experience, mix SPL + PDA for vaults. Import anchor_spl::token::{Token, TokenAccount}. InitIfNeeded for token acc.

Issue: PDA can't sign token transfers directly. CPI to Token program with PDA seeds as authority.

Quick SPL PDA Vault

  1. Derive vault_pda = find seeds ["vault", user.()]
  2. Derive ata = getassociatedtokenaddress(&vaultpda, &mint)
  3. In instr: #[account(mut)] ata: Account<'info, TokenAccount>
  4. Transfer tokens to ata, PDA owns.

Why? Multisig, staking pools. Gas? Token tx ~0.00001 SOL.

Debug Like a Pro

Stuck? Logs: anchor logs or Playground console. PDA wrong? Log seeds, bump.

Repro: GitHub repos like chang47/solana pda example program. Fork, tweak.

Edge: Multiple PDAs per instr. Array of accounts with seeds. Or realloc for bigger data - #[account(realloc = new_space, realloc::payer = user)]. Rent diff refunded/charged.

Deploy to Devnet, Frontend Tease

Tests pass? solana config set --url devnet; anchor deploy. ~0.4 SOL fee first time (account rent).

Frontend? @solana/web3.js + @coral xyz/anchor. Derive PDA client side, pass accounts.

What's next? CPI to other programs - invoke System from PDA. Or your own.

Honestly, PDAs clicked for me after 3 failed tests. Persist. Build this escrow, fund/withdraw. Then NFTs, games. Solana's fast - 50k TPS possible.

Hit snags? Tweak seeds. You're good.