Arc 10 Catch-Up: Designing Solana State with PDAs
Arc 10 Catch-Up: Designing Solana State with PDAs
Arc 10 covered Days 64-70 of Epoch 3, and it was all about Program Derived Addresses.
Arc 9 gave us our first Solana program. We built a counter, stored its state in an account, restricted updates to the correct authority, and used LiteSVM to prove those rules worked. But the counter account had a limitation. It used a randomly generated keypair for its address. That is manageable in a small exercise. The client creates the account, keeps hold of its public key, and passes that address back whenever it wants to increment the counter.
It becomes awkward once we want one counter per user. Where do we store all those addresses? How does a user find their counter from another device? How does the program know that the account it received is the correct counter for that wallet?
Arc 10 answered those questions with Program Derived Addresses, or PDAs. A PDA gives a Solana program a predictable way to create and locate state. Its address comes from the program ID and a set of seeds chosen by the program. For our counter, that meant deriving an address from the word counter and the user's public key. The same inputs always produce the same address. Different users produce different addresses. That makes PDA design part of both the program's state model and its security model.
A PDA gives program state a predictable address
Before Arc 10, our client created the counter account using a fresh keypair. That gave the account a unique address, but there was no relationship between the address and what the account represented. The address did not tell us:
- which program used the account
- which user owned the counter
- whether it was the correct counter for a particular instruction
- how to find it again without saving the address somewhere
PDAs solve this by deriving an address from known inputs. In JavaScript, we can derive one using findProgramAddressSync:
const [counterPda, bump] = PublicKey.findProgramAddressSync(
[Buffer.from("counter")],
programId
);
The inputs here are:
- the program ID
- the seed
counter - a bump value found during derivation
Given those same inputs, we get the same PDA every time. That is the first important property: determinism. We can close the script, run it again tomorrow, and derive the same address without storing it in a database or configuration file.
The second important property is that every byte matters. Changing the seed from counter to alice produces a completely different address:
const [alicePda] = PublicKey.findProgramAddressSync(
[Buffer.from("alice")],
programId
);
Changing it again to bob produces another:
const [bobPda] = PublicKey.findProgramAddressSync(
[Buffer.from("bob")],
programId
);
Restoring the original seed gives us the original PDA again. That small experiment made the model tangible. A PDA is a deterministic address calculated from program-specific inputs.
A PDA has no private key
There is another important difference between a PDA and an ordinary wallet address. A normal Solana keypair contains a public key and a private key. The private key can sign transactions on behalf of the public key. A PDA is deliberately derived so that it falls outside the ed25519 curve used for Solana keypairs. That means there is no corresponding private key. Nobody can generate a secret key for the PDA and sign as it directly.
Instead, the program associated with the PDA can authorise actions for it by supplying the same seeds and bump used to derive it. This is especially useful when a program needs to control assets or call another program through a Cross-Program Invocation. The PDA can act as an authority without requiring someone to store and protect another private key. We did not need all of that capability for our counter, but the underlying rule matters: The program controls the derivation logic, and the derivation logic determines which address the program recognises.
A PDA is an address before it is an account
One easy mistake is to talk about deriving a PDA as though we have created an account. We have not. Derivation only calculates an address. We can derive a PDA that has never held any data, lamports, tokens, or executable code. Until an instruction creates an account at that address, there is nothing there to fetch.
That gives us two separate steps:
- Derive the address.
- Initialise an account at that address.
Anchor helps us combine those steps when the account is first created. But the distinction is useful when debugging. If we derive the correct PDA and getAccountInfo returns null, that does not necessarily mean the derivation failed. It may simply mean the account has not been initialised yet.
Adding the user creates one counter per wallet
The fixed seed [b"counter"] gives us one address for the entire program. That could be useful for a global account, but it cannot give every user their own counter. To do that, we added the user's public key to the seed list:
seeds = [b"counter", user.key().as_ref()]
Now the PDA depends on both:
- the fixed namespace
counter - the public key of the user
Wallet A gets an address derived from: "counter" + wallet A public key. Wallet B gets an address derived from: "counter" + wallet B public key. Because the public keys differ, the resulting PDAs differ. That gives us a predictable one-counter-per-wallet model.
The client does not need to create a random keypair for the counter. It does not need to store the counter address in local storage. It does not need to query a registry to discover which account belongs to the user. It can derive the address again whenever it needs it. In Web2 terms, it is similar to locating a row using a known composite key. The program defines the namespace, the wallet identifies the user, and the combination identifies the state.
Anchor can create the account at the derived address
We expressed that seed scheme inside our Anchor accounts struct:
#[derive(Accounts)]
pub struct InitializeCounter<'info> {
#[account(
init,
payer = user,
space = 8 + Counter::INIT_SPACE,
seeds = [b"counter", user.key().as_ref()],
bump
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
Several constraints work together here:
inittells Anchor to create the account.payer = usersays the user will fund the account's rent-exempt lamport deposit.space = 8 + Counter::INIT_SPACEallocates enough storage for the account.seedsdeclares how the PDA should be derived.bumptells Anchor to find and use the canonical bump for that derivation.
The space value deserves a moment of attention. With InitSpace, Anchor calculates the size of the account's fields for us, but the allocation still needs an extra eight bytes for Anchor's account discriminator. The discriminator identifies which Anchor account type the data belongs to, and it helps prevent one account type from being deserialised as another simply because its bytes happen to have a compatible shape. Getting this wrong can stop the account from initialising or leave too little space for its data.
The account itself stores the user, the count, and the bump:
#[account]
#[derive(InitSpace)]
pub struct Counter {
pub user: Pubkey,
pub count: u64,
pub bump: u8,
}
The initialisation handler can then populate those fields:
pub fn initialize_counter(ctx: Context<InitializeCounter>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.user = ctx.accounts.user.key();
counter.count = 0;
counter.bump = ctx.bumps.counter;
Ok(())
}
Anchor has already checked the account address and created the account before this handler runs. The handler only needs to write the initial state. That follows the same pattern we saw in Arc 9:
- the accounts struct describes the required accounts and validates them
- the handler performs the instruction's state change
The bump completes the derivation
The bump can feel mysterious when we first encounter it. Solana needs PDAs to be off the ed25519 curve so that no private key exists for them. The derivation process starts with the program ID and the seeds, then tries bump values until it finds an address that satisfies that requirement. findProgramAddressSync returns both results:
const [counterPda, bump] = PublicKey.findProgramAddressSync(
[
Buffer.from("counter"),
user.publicKey.toBuffer(),
],
programId
);
Anchor normally handles this search for us. When we write a bare bump during initialisation, Anchor runs the search, finds the canonical bump, and makes it available through ctx.bumps.counter so we can store it in the account.
That stored value is why later instructions use a different form: bump = counter.bump. This tells Anchor: do not run the search again. Take the stored bump, perform a single derivation with it, and check the result against the supplied account. The search loop costs compute units, and it would otherwise run on every instruction that touches the account. Storing the bump once at initialisation and reusing it turns that repeated search into a single calculation.
So the bump is part of how Solana finds a valid off-curve address, and storing it is a small optimisation the program pays for once and benefits from on every subsequent instruction.
Seeds are validation rules
At first, PDAs can look like an address-generation convenience. They save the client from keeping track of random account addresses. That is useful, but it is only half of the story. The same seeds also let Anchor verify that an instruction received the correct account.
Our increment instruction used the counter's PDA constraints again:
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(
mut,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump,
has_one = user
)]
pub counter: Account<'info, Counter>,
pub user: Signer<'info>,
}
Before the handler runs, Anchor can:
- Read the user's public key.
- Combine it with the
counterseed. - Re-derive the expected PDA.
- Compare that address with the supplied counter account.
- Check that the account's stored
userfield matches the signer.
If any of those checks fail, the transaction is rejected before the instruction changes state.
Strictly speaking, the seed constraint alone already binds this counter to the signer, because the signer's public key is part of the derivation. The has_one = user check overlaps with it here, acting as defence in depth. Where has_one really earns its keep is when the seeds do not include the related key, and the stored relationship is the only thing tying the account to a signer. We will see that shortly with the config account.
Either way, the program does not need to repeat address checks inside every handler. The account constraints declare the invariant once: This instruction accepts the counter derived for this user, and that counter must record the same user internally. The handler can then remain focused on the state change:
pub fn increment(ctx: Context<Increment>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = counter.count.checked_add(1).ok_or(ProgramError::ArithmeticOverflow)?;
Ok(())
}
The seed constraint proves we received the expected address. The has_one constraint proves the account data records the expected relationship. Together, they protect the counter from substitution.
A spoofing attempt showed the security boundary
We tested those constraints by deliberately passing the wrong account. Wallet A had its own counter PDA. Wallet B had a different counter PDA. Then Wallet A called increment while supplying Wallet B's counter account.
Without PDA validation, the program might have accepted any account with the right data shape. With the seed constraint in place, Anchor derived the counter address expected for Wallet A and compared it with the supplied address. They did not match. The instruction failed before the handler executed.
That test made the security role of PDAs much clearer. The PDA is not secure merely because its address looks unusual. The protection comes from the program declaring the correct derivation and validating that derivation whenever the account is used.
Global state needs a different seed scheme
The per-user counter gave each wallet its own state. Real applications often need another category of state: information that applies to the whole program. For that, we added a configuration PDA:
seeds = [b"config"]
Because this derivation does not include a user public key, every caller derives the same address. That makes it suitable for singleton state.
Our config account stored fields such as:
#[account]
#[derive(InitSpace)]
pub struct Config {
pub admin: Pubkey,
pub paused: bool,
pub total_counters: u64,
pub bump: u8,
}
Now the program had two kinds of state:
- one global configuration account
- one counter account per user
The config account could hold program-wide policy. The counter account could hold user-specific data. The two can also meet in a single instruction. Our initialize_counter handler incremented config.total_counters alongside creating the user's counter, so one transaction touched both the global account and the per-user account.
That pattern, one instruction updating global and scoped state together, appears constantly in real programs. A program might have:
- one global marketplace configuration
- one position per trader
- one vault per token mint
- one profile per wallet
- one pool account per asset pair
The seed scheme tells us which state is shared and which state is scoped to a particular entity.
Constraints let us express relationships and business rules
As the program gained more state, Anchor constraints became its main validation language. For example:
#[account(
seeds = [b"config"],
bump = config.bump,
has_one = admin
)]
pub config: Account<'info, Config>,
pub admin: Signer<'info>,
Here, has_one = admin checks that the public key stored in config.admin matches the signer passed as admin.
Comments
No comments yet. Start the discussion.