VL004 — Non-canonical PDA bump

An account whose seeds are validated against a bump the caller supplied. A set of seeds has more than one valid address, and an attacker picks which one you get.

The model, in four lines

This rule is easy to get backwards, so here is the whole model up front:

  • Bare bump is safe, and is the recommended form. Anchor computes the canonical bump itself.
  • bump = <expr> means "trust this bump". Anchor validates against the value you hand it, whatever it is.
  • That is dangerous when <expr> is caller-controlled — an argument from #[instruction(...)]. This is what VL004 reports.
  • That is safe when <expr> is a stored canonical bumpbump = vault.bump, written at init time from Anchor's own derivation. Not reported.

If you have read older VaultLint material that said the opposite — that a bare bump should be replaced with bump = <stored bump> — that was wrong, it shipped in the first README, and it was corrected on 26 July 2026. A bare bump is not a finding and never should have been.

What Anchor actually generates

The distinction is not a matter of style; it selects a different function. From Anchor's own account-constraint code generation, in constraints.rs:

Anchor source — the two branches
// [list], no bump       -> find_program_address + store __bump
// [list], explicit bump -> create_program_address with list + bump

find_program_address tries bump values from 255 downward and returns the first that yields a valid off-curve address. That result is the canonical bump, and there is exactly one of it per seed set. Anchor stores it as __bump, which is what ctx.bumps exposes.

create_program_address does no searching. It takes the bump it is given, appends it to the seeds, and returns the resulting address — canonical or not. Anchor then checks that the account you passed sits at that address. The check is real, but it verifies the account matches the bump the caller chose, not that the bump was the right one.

Why one seed set has many addresses

A program-derived address is hash(seeds ‖ bump ‖ program_id), kept only if the result falls off the ed25519 curve. Roughly half of all bump values produce a valid address, so a given seed set typically has on the order of a hundred valid PDAs, not one. Convention picks the highest — the canonical bump — precisely so that "the PDA for these seeds" is a well-defined phrase.

Break that convention and the phrase stops meaning anything. When the caller supplies the bump, they are choosing among ~100 distinct addresses that all satisfy the same seeds = [...] constraint. Every one of them passes Anchor's check. Only one of them is the account your program's other instructions will ever touch.

Severity

Medium, and it never changes. VL004 does not break CI at the default --fail-on highVL002 is the only rule that can report High.

Medium because exploitability depends on what the shadow account is used for. A caller-chosen bump on an account that is only ever read alongside its own seeds may be harmless; the same shape on an account that gates a withdrawal is not. The finding tells you the address is not pinned; you know what sits at it.

Vulnerable code

Vulnerable — the bump arrives from the instruction
#[derive(Accounts)]
#[instruction(user_bump: u8)]
pub struct Withdraw<'info> {
    #[account(
        mut,
        seeds = [b"vault", user.key().as_ref()],
        bump = user_bump
    )]
    pub vault: Account<'info, Vault>,

    pub user: Signer<'info>,
}

The struct declares user_bump as an instruction argument and then validates the account against it. Anchor generates a create_program_address call using a byte the transaction sender wrote.

Why this is dangerous

Because the seeds here include user.key(), the attacker legitimately controls the seed material for their own vault. What they gain from a free bump is a second, third, hundredth vault under the same seeds — each at a different address, each one Anchor will happily accept.

The concrete attack: an attacker calls whatever instruction initialises a vault with a non-canonical bump, creating a shadow account that the rest of the protocol does not know about. They deposit into the canonical vault, borrow against it, then present the shadow vault — same seeds, same signer, different bump — to an accounting instruction that checks only seeds and bump = user_bump. From that instruction's point of view the constraint held, so it acts on a balance that was never debited. Repeat with a fresh bump each time.

Variants of this land wherever "one account per user" is an assumption rather than an enforced fact: one stake position per staker, one vote record per proposal, one escrow per trade. The invariant is enforced by the address being unique, and a caller-supplied bump removes exactly that.

It also breaks the reverse direction. Your program signs for its PDA with invoke_signed using the canonical bump it computed. If some other instruction accepted a non-canonical one, the two halves of the protocol are addressing different accounts while both believe the seeds identify one.

Fixed code

The simplest fix is to delete the = user_bump and the #[instruction] argument with it:

Fixed — let Anchor derive the canonical bump
#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(
        mut,
        seeds = [b"vault", user.key().as_ref()],
        bump
    )]
    pub vault: Account<'info, Vault>,

    pub user: Signer<'info>,
}

Anchor calls find_program_address and there is exactly one address that can satisfy the constraint. Inside the handler the value is available as ctx.bumps.vault if you need it for invoke_signed.

If you want to avoid the compute cost of the search on a hot path, store the canonical bump at init and validate against your own state:

Also fixed — validate against the stored canonical bump
// at init: vault.bump = ctx.bumps.vault;

#[account(
    mut,
    seeds = [b"vault", user.key().as_ref()],
    bump = vault.bump
)]
pub vault: Account<'info, Vault>,

This is safe for a reason worth being precise about, because it is easy to state incorrectly: Anchor is not checking that vault.bump is canonical. It still calls create_program_address with whatever byte is in that field. The safety comes from where the byte came from — it was written once, at init, from Anchor's own find_program_address result, into an account only this program can modify. The guarantee is provenance, not verification. Take the value from an untrusted source instead and the form is back to being the vulnerable one.

Direct create_program_address calls

VL004 has a second, independent trigger: a direct call to create_program_address anywhere in the scanned code, whatever it is spelled as (Pubkey::create_program_address, or a use-shortened create_program_address). This one is not Anchor-specific and fires in native Solana programs too.

Flagged — accepts whatever bump it is handed
let vault_pda = Pubkey::create_program_address(
    &[b"vault", user.key.as_ref(), &[bump]],
    program_id,
)?;
Fixed — derive the canonical bump instead
let (vault_pda, _bump) = Pubkey::find_program_address(
    &[b"vault", user.key.as_ref()],
    program_id,
);

What decides the finding is the bump you hand the call, read from the last seed — which is where the bump goes. A bare identifier, as above, is reported: nothing at that call proves the byte is canonical. A bump taken out of account data is not:

Also fixed — derive with the bump the program stored at init
let vault_pda = Pubkey::create_program_address(
    &[b"vault", user.key.as_ref(), &[ctx.accounts.vault.bump]],
    program_id,
)?;

This is the same provenance argument as bump = vault.bump above, and for the same reason the rule stays silent on it. Until 0.1.1 the trigger was unconditional and reported this form too — a rule that fires on the fix it recommends teaches people to ignore it.

One shape is still reported without inspection: a call whose seeds arrive as a variable, create_program_address(seeds, program_id). The bump is not written at the call site, so nothing there rules out a non-canonical one. If yours is fine, suppress it with the reason, as below.

What the rule does not flag

  • Bare bump. The safe and recommended form. Anchor derives the canonical value itself. This is worth repeating because the rule once said the opposite.
  • bump = <account>.<field> and any other field access, method call or literal. Only a bare identifier that matches an #[instruction(...)] argument name of the same struct is reported.
  • A bare identifier that is not an instruction argument — a constant, a local, something computed in the struct's own context. It is not caller-controlled, so it is not this finding.
  • Fields carrying init. On creation Anchor derives and stores the bump itself; there is no pre-existing account to be redirected to.
  • Fields with seeds and no bump at all, and fields with a bump but no seeds — neither shape produces the create_program_address branch this rule is about.
  • find_program_address in any spelling. It is the canonical derivation and is never reported.
  • Test, benchmark and fuzz code, which is never scanned. See what gets scanned.

One limit worth stating: the rule reads the #[derive(Accounts)] struct, not the handler. A bump that reaches the constraint indirectly — passed through a wrapper, or read from an account the same instruction has just written — is outside what VL004 inspects.

Suppressing a finding

Suppress with a reason
// vaultlint:allow VL004 — bump comes from our own PDA registry, not the caller
#[account(seeds = [b"vault", user.key().as_ref()], bump = registry_bump)]
pub vault: Account<'info, Vault>,

The comment is looked for on the finding's own line first, then upward through the contiguous block of comment lines, #[...] attribute lines — including multi-line ones, which matters here because an Anchor #[account(...)] block often spans six lines — and blank lines. The walk stops at the first line that is none of those.

The rule id is matched on a word boundary, so vaultlint:allow VL0041 does not silence VL004, and a bare vaultlint:allow with no id is intentionally not honoured.

References

Run this check on every PR.

VaultLint is an offline security linter for Solana and Anchor programs. Five narrow rules, hand-written, no telemetry.

See all five rules