Skip to content

Program Derived Addresses

Program Derived Addresses (PDAs) are deterministic addresses derived from a combination of seeds and a program ID. They are one of the most important primitives in SVM development — used to create program-owned accounts with predictable addresses, manage authority over resources, and build composable on-chain data structures.

Upstream-compatible

PDA derivation on Zink is identical to Solana mainnet. The same seeds and program ID produce the same PDA on both networks.

What is a PDA?

A PDA is a 32-byte public key that:

  1. Is deterministically derived from a set of arbitrary byte seeds and a program ID.
  2. Does not lie on the Ed25519 curve — meaning no private key exists for this address. No external wallet can sign for a PDA.
  3. Can only be "signed for" by the program that derived it, and only during CPI via invoke_signed.

This makes PDAs ideal for accounts that should be controlled exclusively by program logic rather than by an external keypair.

How derivation works

PDA derivation uses SHA-256 to hash the seeds together with the program ID, then checks whether the resulting point lies on the Ed25519 curve:

hash = SHA-256(seed_0, seed_1, ..., seed_n, program_id, "ProgramDerivedAddress")

If the hash happens to land on the curve (i.e., it is a valid public key with a corresponding private key), the derivation fails for that set of seeds. To handle this, the runtime introduces a bump seed.

The bump seed

The bump seed is a single byte (0–255) appended to the seeds to nudge the hash off the Ed25519 curve. The derivation process tries bump values starting from 255 and counting down until it finds a value that produces an off-curve point.

The first bump that works is called the canonical bump. Using the canonical bump is critical for determinism — it ensures that everyone deriving the same PDA arrives at the same address.

seeds = [b"user_data", user_pubkey.as_ref()]
bump = 254  ← the canonical bump (found by decrementing from 255)

PDA = SHA-256(b"user_data", user_pubkey, [254], program_id, "ProgramDerivedAddress")

  off-curve 32-byte address ✓

Deriving PDAs

The Solana SDK provides two functions:

find_program_address

Finds the PDA and its canonical bump by trying bump values from 255 downward:

rust
let (pda, bump) = Pubkey::find_program_address(
    &[b"user_data", user_pubkey.as_ref()],
    &program_id,
);

This is the function you use in client code and during account initialization — whenever you need both the address and the bump.

create_program_address

Creates a PDA from seeds and a known bump, without searching. This is cheaper because it does a single hash instead of looping:

rust
let pda = Pubkey::create_program_address(
    &[b"user_data", user_pubkey.as_ref(), &[bump]],
    &program_id,
)?;

Use this in on-chain program code when you already know the bump (e.g., it was stored in the account data or passed as an instruction argument).

Best practice

Store the canonical bump in the PDA's account data at initialization time. This way, subsequent instructions can use create_program_address (a single hash) instead of find_program_address (up to 256 hashes), saving compute units.

Common PDA patterns

Config / singleton accounts

A single global config account for your program:

rust
// seeds: just a static string
let (config_pda, bump) = Pubkey::find_program_address(
    &[b"config"],
    &program_id,
);

User-scoped state

An account per user, deterministically addressed:

rust
// seeds: static prefix + user's public key
let (user_pda, bump) = Pubkey::find_program_address(
    &[b"user_profile", user_pubkey.as_ref()],
    &program_id,
);

Relationship accounts

An account representing a relationship between two entities:

rust
// seeds: prefix + both public keys
let (pair_pda, bump) = Pubkey::find_program_address(
    &[b"pair", mint_a.as_ref(), mint_b.as_ref()],
    &program_id,
);

Authority / vault signers

PDAs used as signing authorities for token accounts or vaults. The PDA itself holds no data — it exists only so the program can sign CPIs on its behalf:

rust
let (vault_authority, bump) = Pubkey::find_program_address(
    &[b"vault_authority", vault_pubkey.as_ref()],
    &program_id,
);

Signing with PDAs in CPI

A PDA has no private key, so it cannot sign transactions in the normal sense. Instead, when a program makes a cross-program invocation using invoke_signed, it provides the seeds (including the bump) that derive the PDA. The runtime verifies that the seeds + program ID produce the claimed PDA address, and if so, grants signer privilege for that CPI.

rust
invoke_signed(
    &transfer_instruction,
    &[vault_account.clone(), destination.clone()],
    &[&[b"vault_authority", vault_pubkey.as_ref(), &[bump]]],  // signer seeds
)?;

Only the program whose ID was used in the derivation can sign for a given PDA. This is the mechanism that gives programs exclusive control over PDA-addressed resources.

PDA constraints in Starframe

Starframe models PDA seeds as reusable Rust types and validates them through account wrappers:

rust
#[derive(Debug, GetSeeds, Clone)]
#[get_seeds(seed_const = b"user_profile")]
pub struct UserProfileSeeds {
    pub authority: Pubkey,
}

#[derive(AccountSet, Debug)]
pub struct UpdateProfileAccounts {
    pub authority: Signer,

    #[validate(arg = Seeds(UserProfileSeeds { authority: *self.authority.pubkey() }))]
    pub user_profile: Mut<Seeded<Account<UserProfile>>>,
}

The seed layout is defined once and reused anywhere the account must be found, initialized, validated, or used as a signer for a CPI.

Seed constraints

  • Maximum number of seeds: 16 (including the bump seed).
  • Maximum seed length: 32 bytes per seed.
  • Seeds are arbitrary byte slices — strings, public keys, integers serialized to bytes, or any combination.
  • The bump seed is always the last seed and is a single byte (u8).

Zink is a general-purpose SVM network for programs, operators, and bridge integrations.