DEV Community

Cover image for Arc 10 Catch-Up: Designing Solana State with PDAs
Matthew Revell for 100 Days of Solana

Posted on

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
);
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

Changing it again to bob produces another:

const [bobPda] = PublicKey.findProgramAddressSync(
  [Buffer.from("bob")],
  programId
);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Derive the address.
  2. 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()]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Wallet B gets an address derived from:

"counter" + wallet B public key
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

Several constraints work together here.

init tells Anchor to create the account.

payer = user says the user will fund the account's rent-exempt lamport deposit.

space = 8 + Counter::INIT_SPACE allocates enough storage for the account.

seeds declares how the PDA should be derived.

bump tells 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,
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

Before the handler runs, Anchor can:

  1. Read the user's public key.
  2. Combine it with the counter seed.
  3. Re-derive the expected PDA.
  4. Compare that address with the supplied counter account.
  5. Check that the account's stored user field 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(())
}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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>,
Enter fullscreen mode Exit fullscreen mode

Here, has_one = admin checks that the public key stored in config.admin matches the signer passed as admin.

Notice that the config seeds contain no user key, so the derivation alone cannot tell us who is allowed to administer the account. The stored admin field is the only link, and has_one is what enforces it. This is the case where has_one is doing essential work rather than doubling up on the seed check.

If the counter accounts are rows keyed by user ID, the config account is the settings table: one row, program-wide, with a recorded owner. And has_one behaves much like a foreign-key check, verifying that a stored reference actually points at the account the instruction received. The difference from a Web2 database is that these checks execute on-chain, as part of every transaction.

We could also enforce the pause rule directly on the account. To do that, the increment instruction's accounts struct gains the config account alongside the counter:

#[account(
    seeds = [b"config"],
    bump = config.bump,
    constraint = !config.paused
)]
pub config: Account<'info, Config>,
Enter fullscreen mode Exit fullscreen mode

Because the config account is now part of the instruction's required accounts, its constraints run during account validation, and an increment fails while the program is paused.

The handler does not need to contain a separate conditional:

if config.paused {
    return err!(CounterError::ProgramPaused);
}
Enter fullscreen mode Exit fullscreen mode

That does not mean every business rule belongs in an account constraint. Some rules are clearer inside the handler.

But Arc 10 showed how much of the program's structure can be expressed declaratively:

  • seeds identifies the expected PDA
  • bump completes its derivation
  • has_one checks a stored relationship
  • constraint applies a custom condition
  • mut marks accounts whose state may change
  • Signer proves that the required wallet authorised the transaction

These constraints run before the handler.

The instruction only reaches its state-changing logic after the required accounts and relationships have been validated.

Singleton initialisation needs an explicit governance decision

Our exercise allowed the first caller of init_config to become the administrator.

That kept the initialisation code approachable and let us focus on PDAs and constraints.

It should not be copied into a production program without considering the consequences.

If anyone can initialise the singleton config account, then whoever reaches it first may become the program administrator.

A production deployment should define who is allowed to perform that initial setup.

Depending on the program, that authority might be:

  • a known deployment wallet
  • the program's upgrade authority
  • a multisig
  • an existing governance account
  • another PDA with its own initialisation rules

The correct design depends on how the program will be governed.

The important lesson is that a singleton PDA gives us one canonical address. It does not decide who should be allowed to create or control the account at that address.

That decision still belongs to the program.

Closing an account completes the state lifecycle

Creating an account costs lamports.

The payer deposits enough lamports to make the account rent-exempt, and those lamports remain attached to the account while it exists.

When the state is no longer needed, the program can close the account and return those lamports.

We added a close_counter instruction using Anchor's close constraint:

#[derive(Accounts)]
pub struct CloseCounter<'info> {
    #[account(
        mut,
        seeds = [b"counter", user.key().as_ref()],
        bump = counter.bump,
        has_one = user,
        close = user
    )]
    pub counter: Account<'info, Counter>,

    #[account(mut)]
    pub user: Signer<'info>,
}
Enter fullscreen mode Exit fullscreen mode

The familiar constraints still protect the account.

seeds and bump verify that this is the user's canonical counter PDA.

has_one = user checks the ownership relationship stored inside the account.

close = user tells Anchor where to send the account's remaining lamports.

The handler itself can be empty:

pub fn close_counter(
    _ctx: Context<CloseCounter>
) -> Result<()> {
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Once the instruction completes, Anchor:

  • transfers the account's lamports to the user
  • invalidates the account data
  • leaves no live account at that PDA

We can still derive the same address later because the derivation inputs have not changed.

But fetching the account will show that it no longer exists.

That makes closing a PDA-backed account feel different from deleting a row in a normal database. We are releasing on-chain storage and returning its refundable deposit.

It also shows the full lifecycle of program state:

  1. Derive the address.
  2. Initialise the account.
  3. Read and mutate its data.
  4. Enforce who may use it.
  5. Close it when it is no longer needed.
  6. Recover the lamports that funded it.

Seed design defines the state namespace

The experimentation day made us change the seeds and observe the consequences.

First, we derived counters for two users using:

[b"counter", user.key().as_ref()]
Enter fullscreen mode Exit fullscreen mode

The two wallets produced two different addresses.

That is the behaviour we wanted.

Then we removed the user public key and derived both counters from:

[b"counter"]
Enter fullscreen mode Exit fullscreen mode

Both wallets now produced the same address.

There was no cryptographic collision. The derivation worked exactly as designed.

The problem was our design.

We had asked the program for one global counter address, then tried to use it as though every user had their own.

Only the first account could be initialised there. The second user would attempt to initialise an address that was already occupied.

This is one of the most important lessons from the arc.

Seed design determines the namespace of the program's state.

Including a user public key creates user-scoped state.

Including a mint creates mint-scoped state.

Including two asset addresses could create one account per pair.

Using only a fixed seed creates a singleton.

The program will follow the scheme exactly, even when the scheme does not match the application we intended to build.

Almost identical seeds are still different seeds

We also tried small changes to the seed bytes:

counter
counters
Counter
counter\0
Enter fullscreen mode Exit fullscreen mode

Each produced a different PDA.

Those values may look related to a person, but the derivation process does not interpret their meaning.

It only sees bytes.

That means:

  • uppercase and lowercase values differ
  • singular and plural values differ
  • invisible terminators or extra bytes matter
  • changing seed order changes the result
  • changing the program ID changes the result

This makes seed conventions worth planning.

Once a program is deployed and accounts have been created, changing a seed can make the new version derive different addresses from the accounts that already exist.

The old state is still on-chain, but the updated program may no longer look for it at the same address.

So seeds should be treated as part of the program's persistent interface, rather than temporary labels we can rename without consequences.

Writing the explanation exposed the gaps

The last two days moved away from program code.

First, we wrote a long-form explanation of PDAs.

That meant describing:

  • why random account keypairs become inconvenient
  • how seeds and program IDs produce deterministic addresses
  • what the bump does
  • why PDAs have no private keys
  • why deriving an address does not create an account
  • how Anchor validates PDA constraints
  • how seed design affects ownership and account scope
  • how PDA-backed accounts can be closed

Then we turned that explanation into a shorter social post or thread.

The public version needed a useful example and some evidence from the work: code, test output, terminal output, or an Explorer screenshot.

That final step matters because PDAs can feel clear while we are following an exercise and become much harder to explain without the scaffolding.

Writing the model in plain language forces us to answer the questions that code alone can hide.

Why is the address predictable?

Why can nobody hold its private key?

Why does adding a wallet public key produce one account per user?

Why does a seed constraint prevent account substitution?

Why does the address remain derivable after the account is closed?

If we cannot answer those questions clearly, we probably need another pass through the experiment.

What Arc 10 taught us

Arc 10 turned account addresses into part of the program design.

By the end of the arc, we had seen how to:

  • derive a PDA from a program ID, seeds, and a bump
  • verify that the same inputs always produce the same address
  • create one state account per user
  • initialise an Anchor account at a PDA
  • distinguish a derived address from an initialised account
  • use seeds and bump to validate an account
  • store the bump once and reuse it to save compute
  • use has_one to bind stored state to a signer
  • combine global configuration with per-user state
  • enforce a pause rule before the handler runs
  • close an account and reclaim its lamports
  • test account-substitution attempts
  • observe how incomplete seed schemes create logical address conflicts
  • explain PDA design in public

The counter still performed the same basic job as it did in Arc 9.

It initialised a number, stored an owner, and allowed that number to be incremented.

But the surrounding design became much more useful.

Each wallet could find its counter without saving a random address.

The program could prove that the supplied counter belonged in that user's namespace.

Global policy lived at one predictable config address.

User state lived at a different predictable address for every wallet.

And when the state was no longer needed, the user could close it and recover the lamports used to create it.

That is the lasting lesson from Arc 10.

PDA design is state-model design.

The seeds decide what state exists, how many instances can exist, what each instance belongs to, and how the program can locate and validate it later.

Revisit the Arc 10 challenges

Day 64: Derive your first Program Derived Address and see how seeds change the result
Day 65: Replace random counter keypairs with one predictable PDA per user
Day 66: Add a global configuration PDA and enforce admin and pause rules
Day 67: Close a user's counter and return its rent-exempt lamports
Day 68: Break the seed scheme and test whether account spoofing succeeds
Day 69: Write a long-form explanation of how PDAs shape program state
Day 70: Turn your PDA explanation into a public post with evidence from your work

Top comments (0)