Good to see you again. In the previous lesson, you established the core idea: a Program Derived Address (PDA) has no private key, yet its deriving program can receive signer privilege for it during a CPI. This lesson makes that model concrete: you will derive a PDA from byte seeds and a program ID, inspect the returned bump, and understand why every client and program must use the same seed schema.
This is the first lesson in the PDA and program-controlled-assets module. We begin with deterministic addresses, then use them to initialize accounts, sign CPIs, and eventually control SPL token vaults.
A PDA derivation is a local, deterministic calculation
A PDA is not discovered on-chain and does not require an RPC call. Given exactly the same inputs, any machine can calculate exactly the same address.
The inputs are:
- A program ID: the public key of the program whose PDA namespace you are using.
- One or more seeds: ordered byte arrays chosen by your application.
- A bump: a one-byte value found by the standard derivation search.
Conceptually, Solana hashes the inputs and checks whether the candidate address is off the Ed25519 curve:
Only an off-curve candidate is a valid PDA. The cryptographic details are handled by the SDK; your essential responsibility is to define and use a stable seed schema.
For a per-user profile, a good schema might be:
["profile", user public key bytes]
For a token vault scoped to both a user and a mint:
["vault", user public key bytes, mint public key bytes]
The following details are non-negotiable:
- Seed order matters.
- Seed bytes matter. A public key’s raw 32 bytes are different from its Base58 text representation.
- The program ID matters. The same seeds under another program ID produce a different PDA.
- Deriving an address does not create an account there. Account initialization comes next lesson.
Read the official documentation for the precise limits and the SDK terminology before writing the code.
Read Solana’s official “PDA Derivation” documentation to connect the off-curve rule with the practical SDK functions you will use in TypeScript.
In Background, read the off curve explanation, then scan the PDA vs keypair accounts table. Next, read Optional Seeds and Bump Seed, focusing on the seed and bump rules. Note the documented limits: at most 16 seeds and at most 32 bytes per individual seed. In Common Seed Patterns, read the seed-schema guidance. Then study the TypeScript examples in Examples: Derive a PDA, especially “Derive a PDA with multiple seeds.” Finally, read Iterating All Possible Bumps and its sample output; it illustrates why the first successful bump is canonical.
Deriving a per-user PDA in TypeScript
For an Anchor integration test or Node-based client, the familiar @solana/web3.js API is PublicKey.findProgramAddressSync. It returns a pair:
[pda, bump]
The following example derives a profile PDA. The generated user key is only an identifier for demonstration; it does not need SOL and never signs anything here.
import { Keypair, PublicKey } from "@solana/web3.js";
// Replace this with the deployed ID of your own Anchor program.
// In an Anchor test, you would usually use: const programId = program.programId;
const programId = new PublicKey(
"Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS",
);
const user = Keypair.generate().publicKey;
const profileSeeds = [
Buffer.from("profile", "utf8"),
user.toBuffer(),
];
const [profilePda, profileBump] = PublicKey.findProgramAddressSync(
profileSeeds,
programId,
);
console.log("User:", user.toBase58());
console.log("Profile PDA:", profilePda.toBase58());
console.log("Canonical bump:", profileBump);
A few observations make this more useful than it first appears:
- Running the code again produces a different PDA because it generates a new
userpublic key. - If you replace
Keypair.generate()with a fixed public key, the output is reproducible. - A client, an Anchor test, and your deployed program can all independently calculate this address, provided they use the same seed bytes and program ID.
"profile"is a namespace prefix. It prevents your future profile addresses from colliding conceptually with, say,"vault"or"order"addresses that use the same user key.
The findProgramAddressSync function performs the bump search for you. Usually, that is the function you want when you know the logical seeds but do not yet know the bump.
There is a second API, createProgramAddressSync. It does not search. Instead, it accepts a bump you already have and succeeds only if those full inputs produce an off-curve address.
const reconstructedPda = PublicKey.createProgramAddressSync(
[
...profileSeeds,
Buffer.from([profileBump]),
],
programId,
);
console.assert(
reconstructedPda.equals(profilePda),
"The stored or supplied bump must recreate the expected PDA",
);
Notice the division of responsibility:
| Function | Inputs | What it does |
|---|---|---|
findProgramAddressSync | Application seeds and program ID | Searches for and returns the PDA plus its canonical bump. |
createProgramAddressSync | Application seeds, explicit bump, and program ID | Reconstructs one candidate PDA; throws if it is on-curve. |
Do not add a guessed bump to the seed list passed to findProgramAddressSync. That function owns the canonical search. Add the returned bump only when you need to reconstruct the PDA explicitly, such as when supplying signer seeds for invoke_signed later.
The bump is a deterministic search result, not a secret
The bump seed exists because not every 32-byte hash can serve as a PDA. A candidate that lies on the Ed25519 curve could, in principle, correspond to a normal public key with a private key. Solana rejects that result for PDA purposes.
The standard finder begins with bump , derives a candidate, and tests whether it is off-curve. If that candidate is invalid, it tries a lower bump. The first successful bump in that descending search is the canonical bump.

For a particular set of logical seeds, there may be multiple bumps that happen to produce off-curve addresses. They represent different PDA addresses. That is why “any valid bump” is not good enough when your application expects one profile, one vault, or one configuration account.
Suppose an application intends one profile per user:
["profile", user public key]
If it accepts any off-curve bump, an attacker may use a non-canonical bump to provide a second valid PDA-addressed account for the same logical user. If the program treats both as equivalent profiles, uniqueness assumptions and authorization checks can fail.
The canonical convention avoids that ambiguity:
- The client calls the standard finder.
- The program validates against the same canonical derivation.
- If a bump is stored in account state, it must be the canonical bump that reproduces that account’s address.
The bump therefore is public, deterministic, and frequently persisted for convenience. It is not a password, nonce, or source of entropy.
Define seed schemas in bytes, not in vague application terms
Seeds are byte arrays. “Use the order ID as a seed” is incomplete until you specify its byte encoding.
For a sequential order record, the intended schema could be:
["order", creator public key bytes, unsigned 64-bit order ID in little-endian bytes]
On a TypeScript client, encode the number deliberately:
const orderId = 42n;
const orderIdBytes = Buffer.alloc(8);
orderIdBytes.writeBigUInt64LE(orderId);
const orderSeeds = [
Buffer.from("order", "utf8"),
user.toBuffer(),
orderIdBytes,
];
const [orderPda, orderBump] = PublicKey.findProgramAddressSync(
orderSeeds,
programId,
);
Rust’s fixed-width integer conversion expresses the same protocol decision:
use solana_program::pubkey::Pubkey;
let order_id: u64 = 42;
let order_id_bytes = order_id.to_le_bytes();
let (order_pda, order_bump) = Pubkey::find_program_address(
&[b"order", user_pubkey.as_ref(), &order_id_bytes],
program_id,
);
The TypeScript and Rust code need not look identical; the resulting bytes must be identical.
A sound seed schema generally has:
- A short static prefix, such as
b"profile",b"vault", orb"order". - Fixed-width values where possible, especially public keys and integer identifiers.
- An explicit encoding for dynamic values.
- A documented ordering of seeds.
- A canonical-bump policy.
Avoid ambiguous concatenation
Solana hashes the concatenated seed bytes. Therefore, these two seed arrays are ambiguous:
["ab", "cd"]
["abcd"]
Both describe the same combined byte sequence. This is not normally a problem when using a fixed prefix plus 32-byte public keys and fixed-width integer encodings, but it matters when multiple arbitrary strings are used.
Prefer one of these approaches:
- Use fixed-length inputs such as public keys and
u64values. - Insert a clear separator where variable strings are unavoidable.
- Hash large or unbounded application data off-chain into a fixed-size digest before using it as a seed, while documenting the hashing scheme.
Also observe Solana’s seed limits: keep each seed at 32 bytes or fewer and keep the total seed count within the documented limit. A user-supplied display name is therefore usually a poor direct seed: it can be too long, ambiguously encoded, and unnecessarily tied to mutable UI data.
How Anchor expresses the same derivation
The preceding code derives an address on the client. In an Anchor program, PDA seed declarations live in account constraints. Anchor uses them to verify that the account supplied to an instruction is the expected PDA.
For the profile schema above, the essential pattern is:
#[account(
seeds = [b"profile", user.key().as_ref()],
bump,
)]
Here, bare bump means “use and validate the canonical bump.” Anchor derives the expected address using the current program ID unless you intentionally specify another program ID with seeds::program.
This is an important connection: the seed schema is shared protocol interface, not merely a frontend convention. A TypeScript client that derives ["profile", user] must agree exactly with the Anchor account constraint that checks ["profile", user].
Read Anchor’s “Program Derived Address” guide to see how the PDA calculation you performed in TypeScript becomes an account-validation rule inside an Anchor program.
In Anchor PDA Constraints, read the constraint model. Focus on the fact that seeds may be static bytes or dynamic values obtained from instruction accounts. Then, under Usage Examples, compare “Single Static Seed” with “Multiple Seeds and Account References.” Read the mixed seed example and relate it directly to the TypeScript ["profile", user public key] seed array above. The next lesson will add init and account allocation; for now, focus only on deterministic derivation and validation.
A practical PDA debugging checklist
When a client derives a PDA that does not match the address expected by an Anchor instruction, the cause is almost always one of the following:
-
Wrong program ID
Confirm that you used your deployed application program’s ID, not the System Program ID, Token Program ID, or a local program ID from another build. -
Different seed order
["vault", user, mint]and["vault", mint, user]are distinct derivations. -
Wrong representation of a public key
Use raw public-key bytes, such asuser.toBuffer()in@solana/web3.jsanduser.key().as_ref()in Anchor. Do not usetoBase58()text unless the protocol explicitly says it uses that UTF-8 text. -
Different integer encoding
A TypeScript decimal string such as"42"is not the same as Rust’s42u64.to_le_bytes(). -
Ignoring the returned bump
Use the canonical bump returned by the finder. Do not accept a caller-chosen alternative simply because it also produces an off-curve address. -
Confusing derivation with account creation
A correct PDA may have no account at that address yet. RPC returningnullmeans “not initialized,” not “the derivation is wrong.”
Key takeaways
- A PDA is deterministically derived from ordered byte seeds, a program ID, and a canonical bump.
findProgramAddressSyncfinds the canonical PDA and returns both the address and bump;createProgramAddressSyncreconstructs a PDA when a bump is already known.- The bump is a public one-byte value used to find an off-curve candidate. It is neither secret nor random.
- Canonical bumps prevent multiple addresses from representing what should be one logical protocol object.
- Client code and on-chain Anchor constraints must use exactly the same program ID, seed order, and byte encodings.
- PDA derivation calculates an address only. It does not allocate an account or place state at that address.
Next, you will initialize a real account at a PDA using Anchor’s init, seeds, bump, and payer constraints.
Can't find a good explanation? Sign up and we'll make it for you
Sign up