I spent a week building a counter program in Anchor. By the end, every
user had their own account, a global config singleton controlled whether
new counters could be created, and a close instruction returned the rent
deposit back to the wallet. None of that would have been possible without
Program Derived Addresses.
This post is what I wish someone had handed me on day one.
The Problem PDAs Solve
On Solana, programs are stateless. If your program needs to remember
something, a user's score, a vault balance, a config flag, it needs
a dedicated account to store it in. And that account needs an address
your program can find again later, without the client having to pass
around a randomly generated keypair.
PDAs are that address. Deterministic. Derivable from inputs your
program already knows. No private key required.
The Mental Model
Here is the Web2 analogy that helped me most:
A PDA is like a database primary key you can compute from the
row's logical identity.
In Postgres, if you want one row per user, you use user_id as the
primary key. You never store the key separately — you derive it from
who is asking. PDAs work the same way. You feed in a list of seeds
(bytes that describe the identity of the thing you want to store) plus
your program's ID, and you get back a deterministic address.
Every time you run the derivation with the same inputs, you get the
same address. That is the whole point.
Where the analogy breaks: the address may or may not have an account
at it yet. Deriving a PDA does not create anything on the chain. It just
computes a public key. The account gets created when your program runs
an instruction with the init constraint, and that creation costs
rent, paid by whoever is signing the transaction.
One more thing the analogy misses: the program ID is baked into the
derivation. The same seeds run through a different program produce a
completely different address. This means only your program can
predictably derive and sign for its own PDAs. No other program can
guess them or collide with them accidentally.
Anatomy of a Derivation
Here is the accounts struct from my counter program, exactly as it
appears in lib.rs:
#[derive(Accounts)]
pub struct InitCounter {
#[account(
init,
payer = user,
space = 8 + Counter::INIT_SPACE,
seeds = [b"counter", user.key().as_ref()],
bump
)]
pub counter: Account,
#[account(mut)]
pub user: Signer,
pub system_program: Program,
}
Let me walk through every piece.
seeds = [b"counter", user.key().as_ref()]
The seed array is the input to the hash. It has two parts here:
-
b"counter"— a static byte string that acts as a namespace. It ensures this address is clearly "a counter account," not something else. -
user.key().as_ref()— the signer's public key as bytes. This is what makes the address unique per user.
Under the hood, Anchor runs sha256(seeds + program_id + bump)
and checks whether the result lands on the ed25519 elliptic curve.
If it does, it decrements the bump and tries again. The first result
that lands off the curve is returned as the canonical PDA. Off-curve
means no private key can produce it, which means only your program
can sign for it.
bump
The bump is a single byte (0–255) appended to the hash input.
PublicKey.findProgramAddressSync starts at 255 and counts down
until it finds an off-curve result. The first bump that works is
the canonical bump. Anchor computes it for you and stores it in
ctx.bumps.counter.
init
This constraint tells Anchor to create the account at the derived
address if it doesn't already exist. It makes a CPI (cross-program
invocation) to the System Program, which allocates the bytes and
transfers the rent-exempt deposit from the payer.
space = 8 + Counter::INIT_SPACE
The 8 bytes are Anchor's discriminator — a prefix stamped on every
account so the program can later verify "yes, this is a Counter
account, not something else." Counter::INIT_SPACE is computed
automatically by the #[derive(InitSpace)] macro.
Why the Seeds Matter
The seeds are your access control policy.
My counter program uses [b"counter", user.key().as_ref()]. That
means every signer gets their own PDA. Alice's counter and Bob's
counter live at completely different addresses, derived independently.
// Alice's counter
const [alicePda] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), alice.toBuffer()],
programId
);
// Bob's counter
const [bobPda] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), bob.toBuffer()],
programId
);
console.log(alicePda.equals(bobPda)); // false — always
Compare that to the config singleton in my program, which uses
[b"config"] — no wallet in the seeds:
// Same address for every caller
const [configFromAlice] = PublicKey.findProgramAddressSync(
[Buffer.from("config")], programId
);
const [configFromBob] = PublicKey.findProgramAddressSync(
[Buffer.from("config")], programId
);
console.log(configFromAlice.equals(configFromBob)); // true — always
One address. Every caller computes the same thing. That's exactly what
you want for a global config — one row at the top of the database that
everyone reads from.
Use the wrong pattern and you get disasters. If I had written my
counter as [b"counter"], the first user to call init_counter
would own the only counter that can ever exist. Everyone else would
get "account already in use."
One byte changes everything:
["counter", walletA] → E4C9RStxbiweqVv7rHM88HgzWNhcCibTHWtGn7HrowjM
["counters", walletA] → AoaX8ntGs9tj7b96pbMcPHD822X8tEqRAg7p48VgdcmZ
["counter\0",walletA] → Es37q2wXjeYPkGhfcn4qnB3Uu167VmbtKUVaQ8i87LTm
["Counter", walletA] → 2WSX2ScZwFgDDYZWkspqrCHTWnSsGoXquH425a2zgsWy
Same program, same wallet, four completely different addresses.
There is no "close enough" in PDA derivation.
What the Bump Buys You
The bump is not magic. It is just the first byte value (starting from
255, counting down) that pushed the hash result off the ed25519 curve.
Off-curve means no private key exists at that point. No private key
means no human can sign for it. Only your program can — by providing
the same seeds plus bump to the runtime when it needs to authorize
something.
Always store the bump on the account:
#[account]
#[derive(InitSpace)]
pub struct Counter {
pub user: Pubkey,
pub count: u64,
pub bump: u8, // ← store this
}
pub fn init_counter(ctx: Context<InitCounter>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.bump = ctx.bumps.counter; // ← Anchor gives it to you here
Ok(())
}
Then on subsequent instructions, pass it back instead of re-deriving:
#[account(
mut,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump, // ← use the stored bump, not bump alone
)]
pub counter: Account<'info, Counter>,
Re-deriving the bump on every call is unnecessary compute. Storing
it once and reusing it is free. Anchor's bump constraint (without
= counter.bump) re-derives every time. bump = counter.bump
uses the stored value. Use the stored value.
The Full Lifecycle
My week in four steps:
1. Derive the address (off-chain)
const [counterPda] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), wallet.toBuffer()],
programId
);
// counterPda is an address. Nothing on the chain yet.
2. Initialize the account (init instruction)
The init constraint on the Anchor accounts struct creates the
account at the derived address, allocates the right number of bytes,
and funds the rent-exempt deposit from the payer. After this
transaction, the account exists on the chain with your program as its
owner.
3. Mutate data (subsequent instructions)
Every later instruction re-derives the PDA from the seeds and
validates that it matches the account passed in. The seeds and bump
Constraints do this automatically. Your handler just reads and writes
the fields.
4. Close the account (close instruction)
Closing a PDA is not like dropping a table row. It is:
- Transfer all lamports to the destination wallet
- Zero out the account data
- The runtime removes the account from state at the end of the transaction (a zero-lamport account cannot persist)
In Anchor, one constraint does all of this:
#[account(
mut,
close = user, // ← drains lamports to user, zeros data
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump,
has_one = user,
)]
pub counter: Account<'info, Counter>,
The rent deposit comes back to the wallet. The account disappears.
getAccountInfo returns null on the next call.
What I Would Tell Past Me
The program ID is part of the derivation. The same seeds in a
different program produce a completely different address. This is a
feature — your PDAs belong to your program and no other.
PDAs cannot sign transactions themselves. Only programs can sign
on a PDA's behalf, by providing the seeds and bump to the runtime as
"signer seeds." This is how your program authorizes CPIs from a PDA
it owns.
init_if_needed is a footgun. It initializes the account if it
doesn't exist, and silently does nothing if it does. That sounds
convenient until a caller reuses it to overwrite an account they
don't own. Use init when you mean "create exactly once."
The seeds enforce access control, not just naming. When Anchor
validates a PDA constraint, it re-derives the expected address from
the seeds and compares it to what you passed in. If someone passes
the wrong account, the derivation produces a different address, and
the transaction is rejected before your handler runs. You do not need
an explicit ownership check — the seeds are the ownership check.
Design your seed scheme before you write any code. You cannot
change the seeds after deployment. A counter keyed by
[b"counter", user.key()] and one keyed by
[b"user_counter", user.key()] are in completely different address
spaces. Pick once, keep forever.
Resources
- Program Derived Addresses — Solana docs The canonical reference for how PDAs are derived and what guarantees you actually get.
- PDAs in Anchor The seed and bump constraint syntax, with examples.
-
Anchor account constraints reference
Full list of constraints including
init,has_one,seeds,bump,close, andconstraint. - GitHub
The full counter program from this arc is available in my
100 Days of Solana repo. If anything in this post is wrong,
leave a comment — I'm still learning.
This post is part of *#100DaysOfSolana*. Building on Solana
every day.
Top comments (0)