All articles
solanapdaprogramsanchor

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.

text
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

rust
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

rust
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

rust
#[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_signed with the PDA as signer to debit them.

Off-chain derivation in TypeScript

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.

Solana PDAs: program-derived addresses, the actual math | devrels.xyz