The model, in four lines
This rule is easy to get backwards, so here is the whole model up front:
- Bare
bumpis 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 bump —bump = vault.bump, written atinittime 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:
// [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 high — VL002 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
#[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:
#[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:
// 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.
let vault_pda = Pubkey::create_program_address(
&[b"vault", user.key.as_ref(), &[bump]],
program_id,
)?;
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:
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
seedsand nobumpat all, and fields with abumpbut noseeds— neither shape produces thecreate_program_addressbranch this rule is about. find_program_addressin 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
// 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
- Anchor — account constraint code generation (
constraints.rs), the code generation that decides betweenfind_program_addressandcreate_program_address. This is the primary source for everything on this page. - Solana docs — Program Derived Addresses, on canonical bumps and off-curve addresses.
- Sealevel Attacks — bump seed canonicalization, a runnable demonstration.
- Common Solana Program Vulnerabilities — PDA mistakes in context.
- vaultlint on GitHub — the rule's source is
src/rules/pda.rs.
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