On this page
- 1. init_if_needed re-initialises
- 2. close closes, and the account can come back
- 3. A PDA without a canonical bump is several PDAs
- 4. remaining_accounts are not validated by anyone
- 5. Two mutable accounts can be the same account
- 6. has_one checks a field, not a relationship
- What these have in common
- References
Anchor did something important for Solana security: it moved account validation from hand-written if statements at the top of every instruction into declarative constraints on a struct, checked before the handler runs. Most of the classic “missing owner check” and “missing signer check” bugs simply cannot happen in an Anchor program that uses the typed account wrappers.
What is left is a narrower class of bug: the constraint is present, and the author believed it checked something it does not. Each of the six below is a misreading rather than an omission, which is why code review misses them. The reviewer sees the constraint, recognises the intent, and moves on.
1. init_if_needed re-initialises
#[account(init_if_needed, payer = user, space = 8 + 32 + 8,
seeds = [b"vault", user.key().as_ref()], bump)]
pub vault: Account<'info, Vault>,
The intent: create the vault on first use, reuse it afterwards. The misreading: that “if needed” means the handler will never run against a fresh account twice. It means the allocation is skipped when the account exists. Your handler’s initialisation logic, the part that sets vault.authority = user.key() and vault.balance = 0, runs every time unless you guard it yourself.
So a caller who can reach the instruction again can reset the vault: zero a balance, or worse, reassign the authority. The constraint prevents a second allocation; it does not prevent a second initialisation.
The close: either use plain init and a separate instruction for the steady state, or check a flag in the handler (require!(!vault.initialized)) before writing initial values, and set it once. Anchor’s own documentation says init_if_needed needs exactly this care, which is why it sits behind a feature flag.
2. close closes, and the account can come back
#[account(mut, close = receiver)]
pub position: Account<'info, Position>,
The intent: after this instruction the position is gone and its rent is refunded. The misreading: that “gone” is permanent within the transaction. Anchor’s close zeroes the data, transfers the lamports out and reassigns the owner, and the runtime garbage-collects a zero-lamport account at the end of the transaction, not the instruction.
In the same transaction, a later instruction can transfer lamports back into that address. The account then survives with its original address, and if any of your other instructions reads it without checking the discriminator or the owner, it reads a resurrected position. Modern Anchor writes a closed-account discriminator precisely so that typed deserialisation refuses these, which is why the bug reappears in programs that read accounts through AccountInfo and deserialise by hand.
The close: never deserialise a closable account type outside the typed wrapper, and if you must, check that the discriminator is the live one. Do not rely on “it was closed earlier in the transaction”.
3. A PDA without a canonical bump is several PDAs
#[account(seeds = [b"config"], bump = config.bump)]
pub config: Account<'info, Config>,
The intent: this is the config account. The misreading: that seeds identify one address. find_program_address tries bump values from 255 downward and returns the first that lands off the curve; that is the canonical bump. But create_program_address accepts any bump that lands off the curve, and for a given seed set there are typically several. Each is a valid PDA of your program with the same seeds.
If the bump is read from the account being validated, as above, then an attacker who can get a second config-shaped account created at a non-canonical bump (through any instruction that initialises with a caller-supplied bump) can present it, and the constraint passes: the seeds match, the bump matches, because the bump came from the attacker’s account.
The close: store the canonical bump at initialisation time from ctx.bumps, and on every later instruction use bump (which makes Anchor call find_program_address and compare against the canonical result) or compare the stored bump against the canonical one. Never accept a bump as an instruction argument for a validation path.
4. remaining_accounts are not validated by anyone
for acc in ctx.remaining_accounts.iter() {
let pool: Account<Pool> = Account::try_from(acc)?;
total += pool.reserve;
}
The intent: iterate a variable-length list of pools. The misreading: that Account::try_from applies the same checks the struct constraints would. It checks the owner and the discriminator, which is real and worth having. It does not check seeds, does not check has_one, does not check that the pool belongs to this market, and does not check for duplicates. An attacker passes the same high-reserve pool five times, or a pool from an unrelated market they control, and the sum is whatever they want.
The close: treat remaining_accounts as untrusted input that needs the same validation you would write in a constraint, by hand: derive the expected address from seeds and compare, check the relationship fields, and reject duplicates by collecting keys into a set.
5. Two mutable accounts can be the same account
#[account(mut)]
pub from: Account<'info, TokenLedger>,
#[account(mut)]
pub to: Account<'info, TokenLedger>,
The intent: move a balance from one ledger to another. The misreading: that two struct fields are two accounts. Nothing stops a caller passing the same address for both. The handler then does from.balance -= amount; to.balance += amount; on two deserialised copies of one account, and whichever is serialised back last wins. Depending on Anchor’s write order, that is a free mint or a free burn.
The close: a constraint = from.key() != to.key() on one of the fields. It is one line, and it is missing from a remarkable number of transfer-shaped instructions.
6. has_one checks a field, not a relationship
#[account(mut, has_one = authority)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>,
The intent: only the vault’s authority can act. This one is correct as written, and it is on the list because of what happens when it is paraphrased. has_one = authority means “the account’s authority field equals the authority account’s key”, and Signer means that account signed. Both halves are needed. Two common paraphrases each drop one:
has_one = authoritywithauthority: AccountInfochecks the field but not the signature. Anyone can pass the real authority’s public key.authority: Signerwith aconstraint = vault.authority == authority.key()written against the wrong field (say,vault.creator) checks a signature and a relationship, but not the relationship you meant.
The close: for every privileged instruction, be able to say in one sentence which field on which account must equal which signer, and check that the constraint says exactly that.
What these have in common
Every one of these is an account-model bug, not an arithmetic or logic bug. The instruction’s handler is usually fine. What went wrong is the set of accounts it was allowed to run against, and Anchor’s constraints are the only thing standing between the handler and an attacker-chosen account set. That is why the constraints deserve the same review a Solidity auditor gives to msg.sender checks: line by line, asking not “is there a constraint” but “what exactly does this one reject”.
Bytecode analysis can recover most of this structure from a deployed program without the source, because the constraint checks compile to recognisable comparisons on the account array, and the SVM detector set covers duplicate mutable accounts, missing signer and owner checks, PDA bump validation and unvalidated CPI targets on that basis. It cannot recover your intent. Whether has_one = creator or has_one = authority is the right one is a question only the person who wrote the program can answer.