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.
devrels.xyz/a/93short linkA 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.
Keep reading
CPI is how Solana programs compose. Account meta forwarding, invoke vs invoke_signed, signer seeds, the 4-deep call limit, and reading return data from the called program.
Rank programs by what actually gets called on Solana and you get a different picture than TVL charts: SPL Token at 346M invocations a day, the Pump.fun stack summing to ~100M across three programs, Meteora's open-source DAMM v2 out-calling Jupiter, and — at #6 — a program that looks like anonymous bot infrastructure on every explorer but resolves to Phoenix Perps once you dig. Sourced from ProgramWatch's free API, then cross-checked against the OtterSec verify registry, Solana Compass, and each team's repos — which is where the real story turned out to be.
An audit report is worthless if you can't confirm the deployed bytecode is what was audited. Solana verified builds fix that: a Docker-pinned toolchain produces a deterministic .so, its hash is compared to the on-chain program data, and the result is written to a PDA anyone can read. Solana Explorer shows a verified badge. Here's the full workflow.
Get new articles in your inbox
Technical deep-dives on Solana tooling, infrastructure, and ecosystem. No noise.
