The Seed Set Is Your Schema: Reading PDA Identity Bugs in a Live Anchor Program

A Program Derived Address is not a name you pick for an account. It is a declaration of how many of that account may exist and who they belong to, written in a syntax so small that a single missing 32-byte seed reads as a typo and behaves like a protocol rewrite. In the BTR review, one omitted voter.key() froze an entire governance system and opened a token-theft path at the same time, and the fix for one of those is not the fix for the other.

I spent a week inside btrfi/btr-contracts with three other researchers on a Pashov Audit Group engagement, June 29th through July 5th 2025. Twenty five issues came out: five critical, two high, five medium, thirteen low. Five of the criticals live in the same neighborhood, and the neighborhood is seed design. This is a teardown of what a seed set actually promises the runtime, the four distinct ways BTR broke that promise, and how to read any #[account(seeds = [...])] block and know within seconds which of the four you are looking at.

Diagram showing many voters colliding into one shared voter_info address versus one unique account per voter pair

The seed set is a schema, and its first field is cardinality

A PDA is derived deterministically from a program ID and an ordered list of seeds, up to 16 of them at 32 bytes each, pushed through a hash with a bump byte until the result falls off the Ed25519 curve. Determinism is the entire value proposition. It is also the entire risk, because determinism means the address is a pure function of the seeds, so the seed list is literally the primary key of a table you are declaring on chain.

Read it that way and the BTR bug is visible without reading a line of instruction logic. Here is CreateVoterInfo as it shipped to us.

#[account(
    init,
    payer = voter,
    space = 8 + 32 + 8 + 32,
    seeds = [b"voter_info", voting_event.key().as_ref()],  // primary key: (voting_event)
    bump
)]
pub voter_info: Account<'info, VoterInfo>,

The primary key is (voting_event). One row per voting event. Not one row per voter per voting event, which is what a governance system needs and what the rest of the codebase clearly assumed, because VotingEvent carries both a min_participants field and a current_participants counter. The struct is describing a many-to-one relationship that the seed set forbids.

The failure is not subtle once the second user arrives. Alice calls create_voter_info for event 7 and the account at ["voter_info", event_7] is created. Bob calls the same instruction for the same event, Anchor's init tries to create an account that already exists, and the transaction reverts. Not a race, not a rare interleaving. Every voter after the first is permanently locked out, so current_participants can never reach min_participants, so the event can never complete, so nobody who did vote can ever claim back their escrowed GovBTR. A one-participant governance system that also traps the one participant's tokens.

The fix is one seed.

#[account(
    init,
    payer = voter,
    space = 8 + 32 + 8 + 32,
    // primary key: (voting_event, voter) -> one row per participant per event
    seeds = [b"voter_info", voting_event.key().as_ref(), voter.key().as_ref()],
    bump
)]
pub voter_info: Account<'info, VoterInfo>,

That change has to land in every instruction that derives or accesses the same PDA, which in BTR meant create_voter_info.rs, vote.rs, and claim.rs. A seed set is a schema, so changing it is a migration, and half-migrating it just moves the collision somewhere quieter. Both C-02 and C-04 in that report are this exact bug found independently by two researchers on the team, which tells you something about how visible it becomes once you start reading seeds as cardinality rather than as an address recipe.

Here is the habit that catches it every time. Before reading any instruction body, I list every PDA in the program and write the seed set as a tuple, then write next to it the sentence "there is exactly one of these per ___". If the blank does not match the entity relationships in the account structs, the program has a cardinality bug regardless of what the handler code does.

A seed set declares a uniqueness constraint, and a missing seed does not shift an address, it deletes a dimension of your data model.

Deriving is not authorizing

Fixing the cardinality does not fix the theft, and this is where most engineers stop one step too early. Adding voter.key() to the seeds guarantees each voter gets a distinct account. It does not guarantee that the person signing a later transaction is the voter whose account is being passed in. Those are different properties, and the runtime only enforces the first.

BTR's claim path shows the gap cleanly. ClaimGovBtr accepted a signer called user, took a voter_info account, and transferred the escrowed governance tokens out to a token account owned by user.

#[account(
    mut,
    seeds = [b"voter_info", voting_event.key().as_ref()],  // derived, but bound to nobody
    bump
)]
pub voter_info: Account<'info, VoterInfo>,

#[account(mut, token::mint = govbtr_token, token::authority = user)]
pub user_govbtr_account: Account<'info, TokenAccount>,

Trace the checks the program actually performs. The seeds and bump constraint proves the account is a PDA derived from the currently executing program. The token::authority = user constraint proves the destination belongs to the signer. Nothing anywhere proves voter_info.voter == user.key(). So the attack is not an exploit so much as a normal transaction with a different account in one slot: enumerate the program's voter_info PDAs, pass someone else's into claim, receive their tokens in your own ATA.

The report walked out four consequences and they are worth keeping separate, because they hit different parts of the system. Unauthorized transfer moves the tokens. Vote aggregation lets one attacker sweep many voters' governance weight into a single wallet, which corrupts the outcome of the vote and not just the balance sheet. Legitimate voters lose both their tokens and their standing. And once the escrow account is drained to zero, whoever emptied it can reclaim its rent.

Two constraints close it, and using both is deliberate.

// 1. bind the address to the signer
#[account(
    mut,
    seeds = [b"voter_info", voting_event.key().as_ref(), user.key().as_ref()],
    bump,
    constraint = voter_info.voter == user.key() @ CustomError::UnauthorizedClaim
)]
pub voter_info: Account<'info, VoterInfo>,
// 2. or assert it in the handler, which is the same check moved later
require!(
    ctx.accounts.user.key() == ctx.accounts.voter_info.voter,
    CustomError::UnauthorizedClaim
);

require! returns the given error when the condition is false, so either placement is sound. The seeds version fails earlier and costs less compute. Keeping the stored-field comparison as well is not redundancy for its own sake: the seeds check binds the address, the field check binds the data, and they diverge the moment anyone adds an instruction that mutates voter_info.voter or reuses the struct under a second seed prefix. Anchor's has_one expresses that second check idiomatically when the field name matches the account name.

The distinction generalizes past this program. A PDA seed containing a pubkey answers "which account is this", never "who is allowed to act on it". If the signer's key appears in the seeds and nowhere in a constraint, you have addressed the account and authorized nobody. This is the same shape as the missing-owner-check and missing-signer-check families catalogued in sealevel-attacks, just wearing PDA clothing, which is why it slips past reviewers who have internalized the classic list.

Address derivation is a naming scheme, authorization is a comparison, and a program that performs the first while skipping the second hands out its escrow to whoever asks politely.

The mirror failure: a PDA that is never constrained at all

Both bugs so far are about a seed list that is present and wrong. The inverse is worse and it hides better, because there is no seed list to read and therefore nothing that looks incorrect. In admin_functions.rs, the ApproveMintEvent context took the program's global authority account like this.

#[derive(Accounts)]
pub struct ApproveMintEvent<'info> {
    // no seeds, no bump: type-checked only, not identity-checked
    pub global_data: Account<'info, GlobalData>,
    // ...other accounts, several of which DO carry seeds + bump
}

What Account<'info, GlobalData> actually verifies is narrow: the account is owned by the executing program, and its first eight bytes match the GlobalData discriminator. That is it. It does not verify the account is the one canonical global-data PDA, because nothing told it there was a canonical one.

GlobalData is where BTR stores authorized_signers and min_num_signers. So the attack is to manufacture a second account that satisfies both checks and carries attacker-chosen contents. Create a program that initializes an account with the GlobalData layout and the right discriminator, assign ownership to the BTR program, set authorized_signers[0] to your own key, set min_num_signers to 1, then pass that account as global_data when calling approve_mint_event. Every authorization check inside the handler reads your forged account and agrees with you.

The payoff is not only unauthorized minting. Because the approval path increments voting_event.total_minted and flips status when the cap is reached, an attacker can mint exactly enough to satisfy the terminal condition and strand a real voting event.

if voting_event.total_minted == voting_event.max_btr_mintable {
    voting_event.status = VotingStatus::AllMinted;   // event is now unvotable
}

Voting requires VotingStatus::Live, so a legitimate event pushed into AllMinted by a forged authority is dead. One missing constraint converts an authorization bypass into a griefing primitive against unrelated users.

The fix is the constraint that every other account in that same struct already had.

#[account(
    seeds = [GLOBAL_DATA_PREFIX.as_bytes()],
    bump
)]
pub global_data: Account<'info, GlobalData>,

What makes this class dangerous in review is that a bare Account<T> reads as finished code. There is no wrong seed to spot, no obviously missing check, just an account declaration with correct types. I look for it by inventory rather than by inspection: list every singleton config or authority account the program owns, then grep every context that mentions it and confirm each one carries seeds and bump. In BTR the tell was internal inconsistency, since the neighboring accounts in the same struct were properly constrained and this one was not. Inconsistency inside a single struct is usually a better signal than any individual line.

An Account<T> proves a type, a seeds constraint proves an identity, and a singleton authority account that only proves its type is a forgery waiting to be passed in.

Seeds are a namespace, and the counter that indexes it has a width

Seed sets that include a counter inherit that counter's integer width as a hard limit on the namespace, which is a coupling that almost never appears in a design document. BTR indexed voting events by an ID, and the ID had two different types in two different files.

// state/global_data.rs
pub struct GlobalData {
    pub id: u8,          // the allocator: 0..=255 and then it is over
    // ...
}

// state/voting_event.rs
pub struct VotingEvent {
    pub id: u64,         // the record: room for 1.8e19
    // ...
}
// instructions/create_voting.rs
voting_event.id = global_data.id as u64;   // widening a value that was never wide
global_data.id = global_data.id + 1;       // wraps or panics at 255

The VotingEvent.id field advertises a namespace with room for 18,446,744,073,709,551,615 entries. The allocator that fills it holds 256. Every instruction that referenced an event took #[instruction(voting_id: u8)], so the narrow type was load-bearing throughout, not a single stray declaration. A governance system with a 255-event ceiling is a governance system with an expiry date, and the cast to u64 is exactly the kind of line that makes the ceiling invisible during review.

The same report carried a second width bug of a different flavor, which is worth naming because it rhymes: the reward math routed a u64 product through an f64 divide, and f64 carries only 53 bits of significand, so values above 2^53 stop being exactly representable long before u64 runs out. Both bugs are the same reading error, trusting a declared field width instead of the narrowest type the value actually passes through.

There is a second failure stacked on the first, and it is the one that makes this a seed problem rather than a typing problem. Neither voting_event nor govbtr_account_escrow is ever closed. So once the counter is exhausted or manipulated back onto a used index, init at that already-materialized address fails and the instruction is dead at that slot. The seed namespace and the account lifecycle are the same system: an ID space you cannot advance past and accounts you cannot retire is a program that runs out of addresses it is allowed to use.

Diagram of the u8 identifier namespace exhausting at 255 and colliding with unclosed voting event and escrow accounts

The fix is to widen the allocator to match the record and to make retirement possible.

pub struct GlobalData {
    pub id: u64,     // allocator width now matches VotingEvent.id
    // ...
}

// and every instruction that addresses an event by id
#[instruction(voting_id: u64)]

Closing is the other half. Anchor's close constraint sends the lamports to a target and resets the data, which both frees the address and returns the rent. BTR left user_entry, staker_info, and vault_pool open after they became useless, so every user who fully unstaked was donating their rent-exempt balance permanently. That balance is proportional to the account's data size, so it is a real per-user cost, not a rounding error.

#[account(
    mut,
    seeds = [VAULT_POOL_PREFIX.as_bytes(), &_vault_id.to_le_bytes()],
    bump,
    close = user            // frees the address AND refunds the rent
)]
pub vault_pool: Box<Account<'info, VaultPool>>,

One caveat the report made explicit, because it bites teams that bolt closing on later: a token account cannot be closed while it holds a balance, so anyone can grief a cancellation flow by sending one token to the escrow. Sweep the balance to a governance-controlled address first, then close.

A counter in a seed set is a namespace allocator, so its integer width and your account-closing policy jointly decide how long the program can keep operating.

When the address is not yours to create first

The last seed failure is the one where your derivation is correct, your constraints are correct, and someone else still wins, because a deterministic address can be materialized by anyone who can compute it. BTR's CreateStakerInfo initialized the staker's governance ATA as part of onboarding.

#[account(
    init,                                       // hard requirement: must not exist yet
    payer = staker,
    associated_token::mint = gov_token,
    associated_token::authority = staker,
)]
pub gov_token_account: Account<'info, TokenAccount>,

An associated token account address is a PDA of the wallet, the token program, and the mint, and the same documentation states plainly that it "may be created by anybody". That combination is the vulnerability. An attacker computes the ATA for any wallet they expect to stake, pays the couple of thousand lamports to create it, and that wallet's create_staker_info call now fails forever on the init constraint. Since staker info is a prerequisite for staking, the target is excluded from the protocol entirely, at attacker cost measured in fractions of a cent.

The fix is a one-word constraint change.

#[account(
    init_if_needed,                             // tolerate an account that already exists
    payer = staker,
    associated_token::mint = gov_token,
    associated_token::authority = staker,
)]
pub gov_token_account: Account<'info, TokenAccount>,

The reason this is safe here and not everywhere is worth stating precisely, because init_if_needed has a bad reputation it half deserves. It is dangerous when it can re-run against an account holding live state, since the handler may reinitialize fields an attacker wants reset. It is safe on an ATA because the ATA's ownership is fixed by its own derivation: whoever creates it, the account is owned by the wallet in the seeds, so a pre-created ATA is functionally identical to one the user created. The rule is that init_if_needed is acceptable when the account's identity and authority are fully determined by its address, and unacceptable when its contents carry trust. That distinction is the audit, not the keyword.

I have found this exact init DoS in programs that had already been through review, and the reason it survives is that it reads as strictness. Requiring the account not to exist looks like a safety property. On a permissionlessly creatable address it is a liveness bug with a public trigger.

If an address is derivable by anyone, treat its existence as adversary-controlled input and never as a precondition you get to demand.

Where this generalizes: four questions per seed set

Every failure above is a different answer to a different question about the same three lines of macro. Run these four in order on any #[account(seeds = [...])] you meet and the class falls out immediately.

What is the cardinality? Write the seed tuple, then finish the sentence "exactly one of these exists per ___". Compare that to the relationships the account structs imply. A min_participants field beside a per-event primary key is a contradiction, and contradictions between the schema and the seeds are always resolved in favor of the seeds, at runtime, in production.

Is the entity in the seeds the entity that signs? A pubkey seed identifies. A constraint or has_one authorizes. If a user key appears in the seeds and never in a comparison against the signer, assume theft is available and go find the transfer.

Is the account constrained at all? Inventory the singletons: global config, authority records, treasury pointers. A bare Account<T> on any of them means an attacker can supply a forged instance that passes the owner and discriminator checks and nothing else.

Can someone else reach this address first? If the seeds contain only public inputs, anyone can compute and create the account. init then becomes a denial-of-service trigger, and you either accept a pre-existing account with init_if_needed or re-derive the flow so the address depends on something the user controls.

Diagram of a four question audit decision path mapping each seed set failure to its specific fix

BTR's five criticals collapse into two of those four questions, which is the part I keep coming back to. It was not five independent mistakes by a careless team. It was one under-specified idea, "a voter has an info account", expressed in a syntax that does not force you to say how many or whose, and the same ambiguity then paid out as a governance freeze and as a drain. The report is one of a large archive of public Pashov Audit Group reviews, and reading a few of them back to back makes the pattern obvious: seed bugs cluster because seed design is done once, early, by whoever is writing the first instruction, and never revisited.

Write the seed tuple down as a primary key before you write the handler. The four questions take a minute each and they are the cheapest audit any Anchor program will ever get.

References

  1. Program Derived Addresses. Solana docs: deterministic derivation, seed limits, canonical bump.
  2. Account Constraints. Anchor docs: seeds, bump, has_one, constraint, init, init_if_needed, close.
  3. Solana Accounts. Rent-exempt minimum balance and its relationship to data size.
  4. require! macro. Anchor-lang: condition plus custom error.
  5. Associated Token Account Program. ATA derivation, and confirmation that anybody may create one for any wallet.
  6. u64 primitive. Rust docs: u64::MAX, checked_add, checked_mul.
  7. Double-precision floating-point format. f64 carries 53 bits of significand, so integers above 2^53 stop being exact.
  8. Sealevel Attacks. Catalogue of Solana account-model exploit classes.
  9. Pashov Audit Group public reports. The archive the BTR review belongs to.