Sign in

PDAs: addresses with no private key

A normal Solana account is controlled by whoever holds its private key. That works fine for wallets, where a person is in charge. It does not work for programs. Programs can't hold keys, can't sign with them, can't be trusted to keep one secret from anyone watching the chain. The fix is a different kind of address: one derived from a program ID and some seeds, with no private key in existence anywhere. The program signs for that address using the seeds themselves. These are called Program-Derived Addresses, or PDAs, and they're how every nontrivial Solana program manages its own state.

Why PDAs have to exist

A normal account's address is the public key of an ECDSA keypair. To authorize anything that account does, you sign a transaction with the matching private key. That model is fine when there's a human or a server holding the key in secret.

A program can't do this. The program is open-source bytecode running on every validator at once. Anything the program "knows" is visible to anyone running it. If a program tried to hold a private key, every validator would see it, which means every validator could sign with it, which means the key is effectively public, which means it isn't a key at all. The whole concept of a key the program controls just collapses.

But programs need to control accounts. They need vaults that hold user deposits. They need state accounts that only the program is allowed to mutate. They need to sign for token transfers out of their own pools. None of that works with the standard keypair model.

The resolution is to invent a kind of address that nobody can sign for in the normal way, and grant the program a special ability to sign for it instead. That's what a PDA is. A 32-byte value that looks like a public key but isn't one, derived deterministically from the program's address and some seeds chosen by the developer. No private key exists for it, because the derivation lands at a point that isn't on the secp256k1 curve, and the curve is where private keys come from. The runtime gives the program a back door: if a program submits the seeds, the runtime treats it as authorization for the PDA those seeds derive to.

Both halves of the diagram produce the same kind of artifact: a 32-byte address the runtime understands. The difference is in how that address is reached and who's allowed to sign for it. A wallet's address is reachable through a private key. A PDA's address is reachable only by re-running the derivation with the seeds.

The bump trick

The derivation looks roughly like this. Take the seeds, append a single byte called the bump, append the program ID, append a tag string, and hash the whole thing with SHA-256. The result is 32 bytes. If those 32 bytes happen to land on the secp256k1 curve, the derivation could have a corresponding private key, and the whole point of PDAs would be broken. So if the result is on the curve, the derivation rejects it and tries again with a smaller bump byte.

The bump starts at 255 and decreases. Roughly half of all 32-byte values land on the curve, so on average two or three tries are enough to find one that doesn't. The first off-curve result encountered, with the highest bump, is the canonical PDA for those seeds. The bump that produced it is the canonical bump.

The function that runs this loop is called find_program_address. You hand it the seeds and the program ID, and it returns the canonical PDA along with the bump that produced it. Off-chain code calls it before submitting a transaction so the right account address can be included. On-chain code mostly avoids find_program_address, since running the loop costs compute units, and instead uses a cheaper function called create_program_address that takes a known bump and checks it directly. The pattern is: compute the canonical bump once with find_program_address, store it, and from then on pass it in everywhere.

Anchor handles this for you when you write seeds = [...] and bump. The bump keyword without a value means "compute and verify the canonical bump." If you store the bump on the account, you can write bump = self.bump instead, which skips the search and uses the stored value. Most programs store the bump on the account they're deriving so subsequent instructions stay cheap.

Deriving PDAs from the client

Most of the time you need to know the PDA address from your TypeScript client before you can build a transaction that touches it. The Anchor SDK uses it to fill in the accounts list, and your tests use it to fetch state back after the call. The function you reach for is PublicKey.findProgramAddressSync from @solana/web3.js:

typescript
import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("YourProgram1111111111111111111111111111111111");
const user = userKeypair.publicKey;

// Same algorithm as find_program_address on chain, just running off-chain.
const [vaultPda, vaultBump] = PublicKey.findProgramAddressSync(
  [Buffer.from("vault"), user.toBuffer()],
  programId,
);

console.log("vault address:", vaultPda.toBase58());
console.log("canonical bump:", vaultBump);

The function returns a tuple of the PDA and the canonical bump. It runs the same loop the on-chain version does, just in JavaScript. Since the seeds are public and the program ID is public, you can compute any PDA your program uses from outside the program.

The one thing that has to match exactly is the seed bytes. If your program declares seeds = [b"vault", user.key().as_ref()], your client has to pass Buffer.from("vault") and user.toBuffer() in the same order. The common bug here is a seed encoding mismatch: passing a string where the program expects a number, swapping the order of two seeds, or calling .toString() instead of .toBuffer() on a pubkey. When this happens, the client computes a different PDA than the program expects, and the transaction fails at constraint-check time with a "seeds constraint violated" error. Keep the seed shapes in sync between the two sides and the addresses will agree.

The address is the lookup

The second reason PDAs are powerful, beyond the signing ability, is what their derivation enables architecturally. Because the address is a hash of public inputs, you can encode application logic directly into the address.

Want one vault per user? Use seeds [b"vault", user.key()]. Each user's pubkey produces a unique PDA. To find a user's vault, you compute the PDA from their pubkey. No mapping table needed.

The mental shift is the same one anyone who's used content-addressed storage has already made. In a normal database, you'd have a users table mapping user IDs to vault row IDs, and you'd look up the vault by joining. With PDAs, the address of the vault is a hash of the user's identity. You don't look up the mapping. You compute the location from the identity itself.

This composes well. A vote record that's unique per user and per proposal can use seeds [b"vote", user.key(), proposal.key()]. A daily counter PDA could use [b"counter", day_number.to_le_bytes()]. Any tuple of public values that uniquely identifies the account you want can be the seeds. The runtime guarantees the resulting address is unique to that tuple under your program.

The constraint is that seeds together cannot exceed 32 bytes per seed and the total seed count is capped at 16. That's plenty for almost any indexing scheme you'd want, and it's a small price for the architectural simplicity.

Storing the bump

One last practical detail. When you initialize a PDA account, you'll usually store the canonical bump as a field on the account itself. The next instruction that needs to sign for this PDA can read the bump from the account in one fetch instead of running find_program_address again. The savings add up: a single find_program_address can cost 1,500 to 12,000 compute units depending on how many bumps it has to try, while reading a stored bump from an already-loaded account is essentially free.

The convention shows up in account structs as a field like pub bump: u8, populated in the init instruction by the value Anchor computed, and read back in every subsequent instruction via bump = state.bump on the seeds constraint. This is the standard idiom and worth adopting from your first program.

What a PDA does and doesn't change

A PDA does not change anything about the account model. The account at a PDA still has the same five fields: lamports, data, owner, executable, and an address. Reading from it works exactly the way reading from any account works. Writing to it requires it to be owned by the program, exactly the way any program-owned account requires.

What changes is who can sign for it. A normal account's signature comes from a keypair. A PDA's "signature" comes from a program re-deriving the seeds inside a cross-program invocation. The runtime treats the two as equivalent for authorization purposes.

That sentence is the whole conceptual core. Once you've internalized it, every PDA pattern in real Solana code follows: programs creating accounts they own, programs signing for token transfers, programs maintaining per-user state without storing a key per user, programs holding pool funds without anyone holding the pool's key.