I spent five days building with PDAs on Solana — Day 64 through Day 68 of my #100DaysOfSolana challenge. By the end I had a working counter program, a config singleton, a close instruction that returned real lamports, and a seed collision experiment where Anchor rejected a spoofed transaction live in my terminal.
This post is the explainer I wish I had on Day 1. It is aimed at backend engineers who know databases but have never touched Solana.
All the code is real. Every terminal output below came from my actual runs.
The Problem PDAs Solve
On Solana, programs are stateless. They hold no memory between calls. Every time your program runs, it starts fresh.
So if your program needs to remember something — a user's score, a game state, a global config — it has to store that data in a separate account on-chain. And to find that account again later, it needs a deterministic address it can recompute without storing it anywhere.
PDAs are that address.
A Program Derived Address is a 32-byte public key that your program derives on demand from a set of seeds. Give it the same seeds tomorrow, six months from now, from any machine in the world, and you get the same address back. No lookup table. No database. Pure computation.
The Mental Model
Here is the Web2 analogy that finally made it click for me:
💡 A PDA is like a database primary key you compute from the row's logical identity — except the "database" is the entire Solana account model, and the program ID is baked into every key.
In a relational database you might write:
-- primary key computed from logical identity
SELECT * FROM accounts
WHERE program_id = 'my_program'
AND user_id = 'alice'
AND namespace = 'counter';
On Solana that becomes:
SHA256(
"counter" ← static seed / namespace
+ alice_wallet_pubkey ← dynamic seed / row identity
+ program_id ← always included automatically
+ "ProgramDerivedAddress" ← Solana's magic suffix
)
→ 32-byte address
Where the analogy breaks — and you must know this:
- PDAs are not rows in a table. The address exists mathematically before any account is created at it. Deriving and creating are two separate steps.
- The program ID is baked in. Same seeds, different program → completely different address. Your seeds are never globally unique.
- No private key can ever exist for a PDA. PDAs land off the Ed25519 elliptic curve on purpose. Only your program can authorize actions on their behalf.
Anatomy of a Derivation
Here is the exact Anchor constraint from my counter program:
#[account(
init,
payer = user,
space = 8 + Counter::INIT_SPACE,
seeds = [b"counter", user.key().as_ref()],
bump
)]
pub counter: Account<'info, Counter>,
b"counter" — the static seed prefix
A hardcoded byte string that acts as a namespace. It says: "this PDA belongs to the counter feature." If the same program also has a vault PDA, it uses b"vault" — different prefix, guaranteed no collision.
user.key().as_ref() — the dynamic seed
The wallet public key of whoever is calling the instruction. Including it means every wallet gets its own independent PDA. Alice's counter and Bob's counter will always be at different addresses.
What actually gets hashed
SHA256(seeds[0] + seeds[1] + ... + program_id + "ProgramDerivedAddress")
The program ID is always appended automatically. You never pass it manually.
bump — not magic, just one byte
This is the part that confused me most. The derivation needs to land off the Ed25519 curve, because on-curve addresses could theoretically have private keys.
But most SHA256 outputs land on the curve. So Solana iterates:
bump = 255 → hash → on curve? ❌ try again
bump = 254 → hash → on curve? ❌ try again
bump = 253 → hash → off curve? ✅ canonical bump found
The bump is simply the first value that produced a valid off-curve address. Anchor calls find_program_address automatically and stores the result when you write bump in the constraint.
Why the Seeds Matter: Every Byte Counts
Per-user PDAs: include the wallet pubkey
const [pdaA] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), walletA.publicKey.toBuffer()],
programId
);
const [pdaB] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), walletB.publicKey.toBuffer()],
programId
);
Terminal output:
Wallet A PDA : 6khGZheFP1Yn61p7VJmwQtPd5gc5S2BHru8nugJFBHXy
Wallet B PDA : EEwiF4AFLd5nEFgYJA6SN6bRQPEaH9kHj4a8GtkdkyJx
Same address?: false ✅
Two wallets, two completely separate accounts. Use this pattern for per-user counters, inventories, profiles — any data that belongs to one wallet.
Global singleton PDA: omit the wallet pubkey
const [globalPDA_fromA] = PublicKey.findProgramAddressSync(
[Buffer.from("counter")],
programId
);
const [globalPDA_fromB] = PublicKey.findProgramAddressSync(
[Buffer.from("counter")],
programId
);
Terminal output:
Derived from A: 4f9BPE2BaRMG7SH339sw6dTigKqGJdeSKrtfaiZJXhmc
Derived from B: 4f9BPE2BaRMG7SH339sw6dTigKqGJdeSKrtfaiZJXhmc
Same address?: true ✅
Same address from every wallet. Use this for a global config singleton — one account the whole program shares. My Day 66 config PDA used exactly this:
#[account(
init,
payer = admin,
space = 8 + Config::INIT_SPACE,
seeds = [b"config"],
bump
)]
pub config: Account<'info, Config>,
Near-miss variants — one byte, totally different address
seeds = ["counter", walletA] → 6khGZheFP1Yn61p7VJmwQtPd5gc5S2BHru8nugJFBHXy
seeds = ["counters", walletA] → A2EKGjtfrPaHWjfoBLaMVtMu7drnAnhUgRZAKh4PmRBa
seeds = ["counter\0", walletA] → FfcErcB7U2FkY3YJNiC5UbejQZ7wH8uUEasK6DBxUkkd
seeds = ["Counter", walletA] → FuR4MUZVwDK5qrKA8dpxHH6PY5Xo4uhGSXQLbEeTLS64
One extra letter. One null byte. One capital C. Four completely different addresses. There is no "close enough" in PDA space. Your seeds are an exact hash input.
What the Bump Buys You
Always use the canonical bump
find_program_address tries bumps from 255 downward and returns the first one that works. That is the canonical bump — the only safe one to use, because it is deterministic. Everyone who derives this PDA will always get the same bump.
There is a lower-level function called create_program_address that lets you pass any bump. Do not use it unless you have a specific reason. If you pass the wrong bump, you silently derive a different address and the program rejects the account.
Store the bump once, re-pass it forever
#[account]
#[derive(InitSpace)]
pub struct Counter {
pub authority: Pubkey,
pub count: u64,
pub bump: u8, // ← stored at init time, never re-derived
}
At init, Anchor stores the canonical bump:
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.authority = ctx.accounts.user.key();
counter.count = 0;
counter.bump = ctx.bumps.counter; // ← store it here
Ok(())
}
On every subsequent instruction, re-pass the stored bump:
// ✅ Correct — reads 1 stored byte, free
#[account(
mut,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump,
has_one = authority,
)]
pub counter: Account<'info, Counter>,
// ❌ Avoid — Anchor re-runs find_program_address, loops up to 256 times
#[account(
mut,
seeds = [b"counter", user.key().as_ref()],
bump,
)]
pub counter: Account<'info, Counter>,
The Full Lifecycle
Step 1 — Derive (off-chain, zero cost, no transaction)
const [counterPDA, bump] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), wallet.publicKey.toBuffer()],
program.programId
);
// The address exists mathematically. No account at this address yet.
Step 2 — Init (creates the account on-chain, pays rent)
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.authority = ctx.accounts.user.key();
counter.count = 0;
counter.bump = ctx.bumps.counter;
Ok(())
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = user,
space = 8 + Counter::INIT_SPACE,
seeds = [b"counter", user.key().as_ref()],
bump
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
What is rent? On Solana, storing data on-chain requires a lamport deposit. A lamport is the smallest unit of SOL — 1 SOL equals 1,000,000,000 lamports. If an account holds enough lamports to cover ~2 years of storage, it is "rent-exempt" and lives forever. That deposit is fully yours — you get every lamport back when you close the account.
My counter account cost 1,231,920 lamports to create.
Step 3 — Mutate (update data, no new account creation)
pub fn increment(ctx: Context<Increment>) -> Result<()> {
ctx.accounts.counter.count += 1;
Ok(())
}
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(
mut,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump,
has_one = authority, // asserts counter.authority == authority.key()
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub authority: Signer<'info>,
pub user: SystemAccount<'info>,
}
has_one = authority is Anchor shorthand that verifies counter.authority == ctx.accounts.authority.key() before any logic runs. No custom auth code needed.
Step 4 — Close (reclaim rent, mark for garbage collection)
#[derive(Accounts)]
pub struct CloseCounter<'info> {
#[account(
mut,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump,
has_one = authority,
close = authority // zeroes data, transfers lamports back
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub authority: Signer<'info>,
pub user: SystemAccount<'info>,
}
close is not DELETE FROM table. Here is exactly what happens at end of transaction:
- Account data is zeroed
- Lamport balance is transferred to
authority - Account is marked for garbage collection by the runtime
The PDA address still exists mathematically. You could init a new account at the same address tomorrow. A closed-then-reopened account is called a reinitialization attack — Anchor's init constraint protects against it by checking the 8-byte account discriminator on every init.
Terminal output after close:
Counter closed ✅
Lamports returned : 1,231,920
getAccountInfo : null
1,231,920 lamports back in my wallet. Real money returned from storage I no longer needed.
The Spoof Test: Where ConstraintSeeds Earns Its Name
On Day 68 I tried to pass Wallet B's counter PDA to a transaction signed by Wallet A:
// walletA is the signer
// pdaB is wallet B's counter — wrong PDA for this signer
await program.methods
.increment()
.accounts({
counter: pdaB, // ← attacker passes wrong PDA
authority: walletA.publicKey,
})
.signers([walletA])
.rpc();
Terminal output:
AnchorError caused by account: counter.
Error Code: ConstraintSeeds. Error Number: 2006.
Error Message: A seeds constraint was violated.
Here is exactly what Anchor did under the hood:
- Instruction arrives:
counter = pdaB,authority = walletA - Anchor re-derives:
seeds = [b"counter", walletA.key()]→ getspdaA -
pdaA ≠ pdaB→ throwsConstraintSeedsimmediately - My business logic never ran
You did not write that security check. Anchor did. The seeds are the access control.
What I Would Tell Past Me
🔑 The program ID is always part of the derivation.
Same seeds in a different program → completely different PDA. Your seeds are only unique within your specific program. This is a feature — it means no other program can collide with your PDAs.
🔑 PDAs cannot sign transactions on their own.
They have no private key by design. Only programs can sign on a PDA's behalf by passing signer_seeds to invoke_signed. If you ever need a PDA to transfer lamports or call another program, this is the mechanism.
🔑 Always store and re-pass the canonical bump.
Find it once at init with ctx.bumps.counter, store it as a u8 in your account struct, and pass bump = counter.bump on every subsequent instruction. Re-derivation loops up to 256 times. Reading one stored byte is free.
🔑 init_if_needed is a footgun.
It is convenient for accounts that should be created on first use. But if an attacker can trigger it after you expect the account to exist, they can reset your state. Only reach for it deliberately, with extra constraints guarding against reinitialisation.
🔑 Closing is not deleting.
The PDA address is a derivation — it always exists mathematically. close returns the lamports and zeroes the data, but the address is re-derivable forever. Build your program assuming a closed account can be reopened.
Resources and Code
Everything I cited in this post is verifiable:
-
Solana PDA documentation — canonical explanation of off-curve derivation and
find_program_address -
Anchor PDA guide — constraints, bump storage,
init_if_needed,close - anchor-lang crate docs — full API reference
My code from this week — all five days, live on GitHub:
| Day | Topic | Link |
|---|---|---|
| Day 64 | Derive PDA | day-64-derive-pda |
| Day 65 | PDA Counter | day-65-pda-counter |
| Day 66 | Config PDA | day-66-config-pda |
| Day 67 | Close PDA | day-67-close-pda |
| Day 68 | PDA Collision Explorer | day-68-pda-collisions |
Full repo: github.com/gopichandchalla16/100-days-of-solana
This is Day 69 of my #100DaysOfSolana build log. If something here is wrong or unclear, tell me in the comments — I am still learning and I will fix it.
Top comments (0)