Solana PDAs: program-derived addresses, the actual math
PDAs are addresses no private key can sign for, except the owning program. Here's how find_program_address works, what the bump is, and how to use them.
A Program Derived Address (PDA) is a Solana address that nobody's private key can sign for. Instead, the program that derived it can sign on its behalf via invoke_signed. PDAs are how programs own accounts, produce deterministic addresses for state, and act as authorities for tokens.
The derivation
A PDA is the SHA-256 hash of: a list of seeds + a bump byte (0-255) + the program ID + the magic string "ProgramDerivedAddress". The result is treated as a 32-byte public key.
pda = SHA256(seed1 || seed2 || ... || bump || program_id || "ProgramDerivedAddress")The catch: not every hash output is a valid ed25519 public key. Solana requires PDAs to be off the ed25519 curve — the whole point is "no private key exists for this address". About 50% of hash outputs land off-curve.
So derivation iterates: try bump 255, check if off-curve; if not, try 254; continue until a valid PDA is found. The bump that worked is the canonical bump.
find_program_address vs create_program_address
use solana_program::pubkey::Pubkey;
// Off-chain or off-loop: searches for the bump, returns (pda, canonical_bump)
let (pda, bump) = Pubkey::find_program_address(
&[b"vault", user.as_ref()],
&program_id,
);
// On-chain hot path: deterministic, just hashes — no search. Use the
// stored bump from a previous call to avoid the cost of grinding.
let pda = Pubkey::create_program_address(
&[b"vault", user.as_ref(), &[bump]],
&program_id,
).expect("invalid PDA");find_program_address can loop up to 255 times — worst case is ~1,500 CUs. create_program_address is one shot. Store the bump in your account, then use create_program_address inside the program. Only call find_program_address from the client or at one-time initialisation.
Signing for a PDA
use solana_program::{program::invoke_signed, system_instruction};
let seeds = &[b"vault", user.as_ref(), &[bump]];
let signer_seeds = &[&seeds[..]];
invoke_signed(
&system_instruction::transfer(&pda, &recipient, lamports),
&[pda_account.clone(), recipient.clone(), system_program.clone()],
signer_seeds,
)?;The runtime re-derives the PDA from the supplied seeds + the invoking program's ID. If it matches the account you're trying to sign for, the signature is accepted. Only the program that derived the PDA can sign for it — that's the entire security model.
Anchor's ergonomics
#[derive(Accounts)]
#[instruction(user_id: u64)]
pub struct Initialize<'info> {
#[account(
init,
payer = payer,
space = 8 + Vault::INIT_SPACE,
seeds = [b"vault", payer.key().as_ref(), &user_id.to_le_bytes()],
bump,
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}Anchor computes the PDA from seeds, verifies the passed-in account matches, stores the canonical bump on the account so future calls skip the search, and auto-signs via invoke_signed when the PDA needs to act as an authority.
Canonical PDA patterns
One PDA per user. Seeds: [b"vault", user.key().as_ref()]. Most common pattern.
One PDA per (user, item). Seeds: [b"position", user.as_ref(), market.as_ref()]. DEXes and lending markets use this for per-user-per-market state.
Singleton PDA. Seeds: [b"config"]. A single program-wide config.
Numbered series. Seeds: [b"order", &id.to_le_bytes()]. Order books, proposals, anything with a monotonic ID.
Limits and gotchas
- Max 16 seeds per derivation
- Each seed up to 32 bytes
- Never trust client-supplied bumps. A malicious client could supply a non-canonical bump that derives a different valid PDA, breaking your access control. Either re-derive on-chain (slow but safe) or check against the stored canonical bump.
- PDAs cannot move SOL like a wallet does — they're program-owned. Use
invoke_signedwith the PDA as signer to debit them.
Off-chain derivation in TypeScript
import { PublicKey } from "@solana/web3.js"
const [pda, bump] = PublicKey.findProgramAddressSync(
[Buffer.from("vault"), userPubkey.toBuffer()],
programId,
)
console.log("pda:", pda.toBase58(), "bump:", bump)Same derivation, same result. Compute the PDA address before sending a transaction so you can include it in the account list.
References
PDAs are the load-bearing primitive for program-owned state on Solana. Get the derivation right, store the canonical bump, and never trust client-supplied bumps.