In Anchor, #[account(init)] and #[account(init_if_needed)] are convenient ways to (conditionally) create and initialize accounts. But what do these constraints actually generate under the hood? And how can seemingly harmless account-creation conveniences lead to griefing/DoS patterns (especially around associated token accounts)? Let’s dive in.
Examples#
init and init_if_needed are commonly used in Anchor.
Below are two simple examples from https://github.com/solana-developers/anchor-examples.
rustuse anchor_lang::prelude::*; declare_id!("2TiUn4ZNzQsSgwMnfD1fpzXNrBmqqB8BYeDF4xVb5WcF"); #[program] pub mod example { use super::*; pub fn initialize(ctx: Context<Initialize>, input: u64) -> Result<()> { ctx.accounts.new_account.data = input; msg!("Changed data to: {}!", input); Ok(()) } } #[derive(Accounts)] pub struct Initialize<'info> { #[account(mut)] pub signer: Signer<'info>, #[account( init, payer = signer, space = 8 + 8 )] pub new_account: Account<'info, DataAccount>, pub system_program: Program<'info, System>, } #[account] pub struct DataAccount { data: u64, }
rustuse anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, token::{Mint, Token, TokenAccount}, }; declare_id!("7ZpxAfW6xKiGLjyvvUPB71HQKWTqs3VdeYTY5uT784VE"); #[program] pub mod example { use super::*; pub fn initialize(ctx: Context<Initialize>, input: u64) -> Result<()> { if ctx.accounts.new_account.is_initialized { msg!("Already initialized, data unchanged"); } else { ctx.accounts.new_account.is_initialized = true; ctx.accounts.new_account.data = input; msg!("Initializing account with data: {}", input); } Ok(()) } pub fn initialize_token_account(ctx: Context<InitializeTokenAccount>) -> Result<()> { // No additional checks needed, token account checks are done by token program msg!("Initialize associated token account if needed"); msg!("Associated token account: {}", ctx.accounts.associated_token.key()); Ok(()) } } #[derive(Accounts)] pub struct Initialize<'info> { #[account(mut)] pub signer: Signer<'info>, #[account( init_if_needed, payer = signer, space = 8 + 1 + 8 )] pub new_account: Account<'info, DataAccount>, pub system_program: Program<'info, System>, } #[derive(Accounts)] pub struct InitializeTokenAccount<'info> { #[account(mut)] pub signer: Signer<'info>, #[account( init_if_needed, payer = signer, associated_token::mint = mint, associated_token::authority = signer, )] pub associated_token: Account<'info, TokenAccount>, pub mint: Account<'info, Mint>, pub token_program: Program<'info, Token>, pub associated_token_program: Program<'info, AssociatedToken>, pub system_program: Program<'info, System>, } #[account] pub struct DataAccount { is_initialized: bool, // 1 byte data: u64, // 8 bytes }
The high-level intent is straightforward:
initcreates the account (and then deserializes it into the requested wrapper type).init_if_neededcreates the account only if Anchor decides the account still “needs initialization”; otherwise it skips creation and only validates that the existing account matches the expected invariants.
One important nuance (we’ll formalize it later): in Anchor’s generated code, “needs initialization” is largely approximated as “is still owned by the System Program”.
But what do these constraints do internally?
Background: Anchor parser and codegen#
In Anchor, the parser extracts and structures declarative constraints from user code, while the codegen phase materializes those constraints into concrete runtime validation logic.
The entire workflow is like this:
anchor program (Rust source)
-> rustc
-> Anchor procedural macros
-> syn-based parsing (AST)
-> constraint parsing / validation
-> code generation (TokenStream)
-> expanded Rust code
-> rustc continues compilation
-> Solana SBF/BPF compilation
Quick glossary:
synis a Rust crate for parsing Rust source code into an abstract syntax tree (AST). ATokenStream(proc_macro2::TokenStream) is the representation of Rust code that procedural macros produce — essentially the "output" of the macro that gets spliced back into the compiler's pipeline. SBF (Solana Bytecode Format, formerly BPF) is the bytecode format Solana validators execute.
The parser in Anchor is responsible for analyzing Anchor-specific Rust constructs at compile time.
Concretely, it:
- Parses Rust syntax into an abstract syntax tree (AST) using syn
- Identifies Anchor-specific attributes (e.g. #[account(...)], #[derive(Accounts)])
- Extracts account metadata and constraint definitions
- Organizes constraints into structured representations (constraint groups) that can be reasoned about programmatically
At this stage, no runtime logic is generated yet. The parser’s role is purely structural and semantic extraction, translating user-written Anchor code into an internal representation suitable for validation and code generation.
The codegen phase takes the parsed constraint groups and produces concrete Rust code that will be executed at runtime.
Specifically, it:
- Generates account validation logic (ownership checks, signer checks, mutability, PDA derivation, etc.)
- Emits CPI boilerplate and account deserialization logic
- Translates declarative constraints into imperative runtime checks
- Outputs Rust TokenStreams that are injected back into the compilation pipeline
The generated code is then compiled normally by rustc and eventually deployed as a Solana SBF program.
parser phase#
In https://github.com/coral-xyz/anchor/blob/347c0599b8310d84af4086cfe5c975733a9e17cd/lang/syn/src/lib.rs#L815-L820, ConstraintToken is parsed from the attribute token stream:
impl Parse for ConstraintToken {
fn parse(stream: ParseStream) -> ParseResult<Self> {
accounts_parser::constraints::parse_token(stream)
}
}
In the parse_token function, both init and init_if_needed parse into the same token variant (ConstraintToken::Init), with a boolean flag distinguishing them:
rust// Parses a single constraint from a parse stream for `#[account(<STREAM>)]`. pub fn parse_token(stream: ParseStream) -> ParseResult<ConstraintToken> { let ident = stream.call(Ident::parse_any)?; let kw = ident.to_string(); let c = match kw.as_str() { "init" => ConstraintToken::Init(Context::new( ident.span(), ConstraintInit { if_needed: false }, )), "init_if_needed" => ConstraintToken::Init(Context::new( ident.span(), ConstraintInit { if_needed: true }, )),
The only difference at this stage is if_needed: false (for init) vs if_needed: true (for init_if_needed).
When the constraint group is being built, the build() method performs the following checks for ConstraintInit.
rustpub fn parse(f: &syn::Field, f_ty: Option<&Ty>) -> ParseResult<ConstraintGroup> { let mut constraints = ConstraintGroupBuilder::new(f_ty); for attr in f.attrs.iter().filter(is_account) { for c in attr.parse_args_with(Punctuated::<ConstraintToken, Comma>::parse_terminated)? { constraints.add(c)?; } } let account_constraints = constraints.build()?; Ok(account_constraints) }
rust#[derive(Debug, Clone)] pub struct ConstraintInit { pub if_needed: bool, }
First, it checks the if_needed flag and ensures the init-if-needed cargo feature is enabled for anchor-lang. Otherwise it emits a compile-time error (this is macro expansion time, not a runtime “revert”).
rustif let Some(i) = &self.init { if cfg!(not(feature = "init-if-needed")) && i.if_needed { return Err(ParseError::new( i.span(), "init_if_needed requires that anchor-lang be imported \ with the init-if-needed cargo feature enabled. \ Carefully read the init_if_needed docs before using this feature \ to make sure you know how to protect yourself against \ re-initialization attacks.", )); }
Later, mut is checked. If the user explicitly specified mut alongside init/init_if_needed, the macro emits a compile-time error ("mut cannot be provided with init"), because init already implies mutability — Anchor auto-adds ConstraintMut in the None branch.
rustmatch self.mutable { Some(m) => { return Err(ParseError::new( m.span(), "mut cannot be provided with init", )) } None => self .mutable .replace(Context::new(i.span(), ConstraintMut { error: None })), };
Later, rent_exempt is checked. If it's not explicitly skipped, it defaults to ConstraintRentExempt::Enforce.
rust// Rent exempt if not explicitly skipped. if self.rent_exempt.is_none() { self.rent_exempt .replace(Context::new(i.span(), ConstraintRentExempt::Enforce)); }
Later, payer is checked. If it’s not provided, the macro emits a compile-time error ("payer must be provided when initializing an account").
rustif self.payer.is_none() { return Err(ParseError::new( i.span(), "payer must be provided when initializing an account", )); }
For a non-PDA account being initialized (no seeds, not an ATA init), Anchor also auto-adds a signer constraint. That matches the System Program requirement: creating a regular account requires the new account’s signature.
rust// When initializing a non-PDA account, the account being // initialized must sign to invoke the system program's create // account instruction. if self.signer.is_none() && self.seeds.is_none() && self.associated_token_mint.is_none() { self.signer .replace(Context::new(i.span(), ConstraintSigner { error: None })); }
bump is also checked. If a bump target is provided together with init, the macro emits a compile-time error ("bump targets should not be provided with init. Please use bump without a target.").
rust// Assert a bump target is not given on init. if let Some(b) = &self.bump { if b.bump.is_some() { return Err(ParseError::new( b.span(), "bump targets should not be provided with init. Please use bump without a target." )); } }
Lastly, there are checks for token account and mint related constraints.
rust// TokenAccount. if let Some(token_mint) = &self.token_mint { if self.token_authority.is_none() { return Err(ParseError::new( token_mint.span(), "when initializing, token authority must be provided if token mint is", )); } } if let Some(token_authority) = &self.token_authority { if self.token_mint.is_none() { return Err(ParseError::new( token_authority.span(), "when initializing, token mint must be provided if token authority is", )); } } // Mint. if let Some(mint_decimals) = &self.mint_decimals { if self.mint_authority.is_none() { return Err(ParseError::new( mint_decimals.span(), "when initializing, mint authority must be provided if mint decimals is", )); } } if let Some(mint_authority) = &self.mint_authority { if self.mint_decimals.is_none() { return Err(ParseError::new( mint_authority.span(), "when initializing, mint decimals must be provided if mint authority is", )); } }
Finally, the ConstraintInitGroup is generated. Pay attention to the kind field — it determines the type of account to initialize:
InitKind::Program: program accountInitKind::Mint: mint accountInitKind::Token: token accountInitKind::AssociatedToken: associated token account
rustOk(ConstraintGroup { init: init.as_ref().map(|i| Ok(ConstraintInitGroup { if_needed: i.if_needed, seeds: seeds.clone(), payer: into_inner!(payer.clone()).unwrap().target, space: space.clone().map(|s| s.space.clone()), kind: if let Some(tm) = &token_mint { InitKind::Token { mint: tm.clone().into_inner().mint, owner: match &token_authority { Some(a) => a.clone().into_inner().auth, None => return Err(ParseError::new( tm.span(), "authority must be provided to initialize a token program derived address" )), }, token_program: token_token_program.map(|tp| tp.into_inner().token_program), } } else if let Some(at) = &associated_token { InitKind::AssociatedToken { mint: at.mint.clone(), owner: at.wallet.clone(), token_program: associated_token_token_program.map(|tp| tp.into_inner().token_program), } } else if let Some(d) = &mint_decimals { InitKind::Mint { decimals: d.clone().into_inner().decimals, owner: match &mint_authority { Some(a) => a.clone().into_inner().mint_auth, None => return Err(ParseError::new( d.span(), "authority must be provided to initialize a mint program derived address" )) }, freeze_authority: mint_freeze_authority.map(|fa| fa.into_inner().mint_freeze_auth), token_program: mint_token_program.map(|tp| tp.into_inner().token_program), // extensions group_pointer_authority: extension_group_pointer_authority.map(|gpa| gpa.into_inner().authority), group_pointer_group_address: extension_group_pointer_group_address.map(|gpga| gpga.into_inner().group_address), group_member_pointer_authority: extension_group_member_pointer_authority.map(|gmpa| gmpa.into_inner().authority), group_member_pointer_member_address: extension_group_member_pointer_member_address.map(|gmpma| gmpma.into_inner().member_address), metadata_pointer_authority: extension_metadata_pointer_authority.map(|mpa| mpa.into_inner().authority), metadata_pointer_metadata_address: extension_metadata_pointer_metadata_address.map(|mpma| mpma.into_inner().metadata_address), close_authority: extension_close_authority.map(|ca| ca.into_inner().authority), permanent_delegate: extension_permanent_delegate.map(|pd| pd.into_inner().permanent_delegate), transfer_hook_authority: extension_transfer_hook_authority.map(|tha| tha.into_inner().authority), transfer_hook_program_id: extension_transfer_hook_program_id.map(|thpid| thpid.into_inner().program_id), } } else { InitKind::Program { owner: owner.as_ref().map(|o| o.owner_address.clone()), } },
So far, init vs init_if_needed differ mostly by a boolean flag plus a feature-gate. The interesting part is the runtime behavior produced by codegen.
codegen phase#
In the codegen phase, the init constraint compiles down via generate_constraint_init, called from generate_constraint (see https://github.com/coral-xyz/anchor/blob/347c0599b8310d84af4086cfe5c975733a9e17cd/lang/syn/src/codegen/accounts/constraints.rs).
rustfn generate_constraint( f: &Field, c: &Constraint, accs: &AccountsStruct, ) -> proc_macro2::TokenStream { match c { Constraint::Init(c) => generate_constraint_init(f, c, accs), Constraint::Zeroed(c) => generate_constraint_zeroed(f, c, accs), Constraint::Mut(c) => generate_constraint_mut(f, c), Constraint::Dup(_) => quote! {}, // No-op: dup is handled by duplicate checking logic Constraint::HasOne(c) => generate_constraint_has_one(f, c, accs), Constraint::Signer(c) => generate_constraint_signer(f, c), Constraint::Raw(c) => generate_constraint_raw(&f.ident, c), Constraint::Owner(c) => generate_constraint_owner(f, c), Constraint::RentExempt(c) => generate_constraint_rent_exempt(f, c), Constraint::Seeds(c) => generate_constraint_seeds(f, c), Constraint::Executable(c) => generate_constraint_executable(f, c), Constraint::Close(c) => generate_constraint_close(f, c, accs), Constraint::Address(c) => generate_constraint_address(f, c), Constraint::AssociatedToken(c) => generate_constraint_associated_token(f, c, accs), Constraint::TokenAccount(c) => generate_constraint_token_account(f, c, accs), Constraint::Mint(c) => generate_constraint_mint(f, c, accs), Constraint::Realloc(c) => generate_constraint_realloc(f, c, accs), } }
This will finally call generate_constraint_init_group:
rustfn generate_constraint_init_group( f: &Field, c: &ConstraintInitGroup, accs: &AccountsStruct, ) -> proc_macro2::TokenStream
The if_needed flag is extracted from the constraint group.
rustlet if_needed = if c.if_needed { quote! {true} } else { quote! {false} };
Then the seeds are checked and find_pda and seeds_with_bump are generated.
rust// PDA bump seeds. let (find_pda, seeds_with_bump) = match &c.seeds { None => (quote! {}, quote! {}), Some(c) => match &c.seeds { // If the bump is provided with init *and target*, then force it to be the // canonical bump. // // Note that for `#[account(init, seeds)]`, find_program_address has already // been run in the init constraint find_pda variable. SeedsExpr::List(list) => { // Optional prefix (either empty or "<list>,") let maybe_seeds_plus_comma = (!list.is_empty()).then(|| quote! { #list, }); let validate_pda = if let Some(b) = &c.bump { quote! { if #field.key() != __pda_address { return Err(anchor_lang::error::Error::from( anchor_lang::error::ErrorCode::ConstraintSeeds ).with_account_name(#name_str) .with_pubkeys((#field.key(), __pda_address))); } if __bump != #b { return Err(anchor_lang::error::Error::from( anchor_lang::error::ErrorCode::ConstraintSeeds ).with_account_name(#name_str) .with_values((__bump, #b))); } } } else { quote! { if #field.key() != __pda_address { return Err(anchor_lang::error::Error::from( anchor_lang::error::ErrorCode::ConstraintSeeds ).with_account_name(#name_str) .with_pubkeys((#field.key(), __pda_address))); } } }; let bump_tok = if f.is_optional { quote!(Some(__bump)) } else { quote!(__bump) }; ( quote! { let (__pda_address, __bump) = Pubkey::find_program_address( &[ #maybe_seeds_plus_comma ], __program_id, ); __bumps.#field = #bump_tok; #validate_pda }, quote! { &[ #maybe_seeds_plus_comma &[__bump][..] ][..] }, ) } SeedsExpr::Expr(expr) => { let bump_tok = if f.is_optional { quote!(Some(__bump)) } else { quote!(__bump) }; ( quote! { let __seeds_slice: &[&[u8]] = #expr; let (__pda_address, __bump) = Pubkey::find_program_address(__seeds_slice, __program_id); __bumps.#field = #bump_tok; // Build signer seeds at runtime = seeds + bump let mut __signer_seeds_vec: ::std::vec::Vec<&[u8]> = __seeds_slice.to_vec(); __signer_seeds_vec.push(&[__bump][..]); let __signer_seeds = __signer_seeds_vec; if #field.key() != __pda_address { return Err(anchor_lang::error::Error::from( anchor_lang::error::ErrorCode::ConstraintSeeds ).with_account_name(#name_str) .with_pubkeys((#field.key(), __pda_address))); } }, quote! { &__signer_seeds[..] }, ) } }, };
Later, match &c.kind dispatches code generation for each account type: Program, Mint, Token, and AssociatedToken.
What “needs initialization” means to Anchor#
Before diving into each InitKind, it’s useful to pin down the key conditional in Anchor’s generated code:
- For
init, Anchor always runs the account-creation path. - For
init_if_needed, Anchor runs the account-creation path only if the current owner is the System Program; otherwise it skips creation and instead deserializes + validates invariants.
This “owner == system program” heuristic is what makes init_if_needed work well for cases like ATAs (which, once created, are owned by the token program), and also what makes it potentially dangerous if you treat an already-existing program-owned account as “fresh” without additional application-level checks.
Here, “owner” refers to AccountInfo.owner (the program id that owns the account), not the wallet/authority that controls it.
InitKind::Program (regular program-owned account)#
The owner is checked; if not provided, the default owner is the currently executing program.
rust// Define the owner of the account being created. If not specified, // default to the currently executing program. let (owner, owner_optional_check) = match owner { None => ( quote! { __program_id }, quote! {}, ), Some(o) => { // We clone the `check_scope` here to avoid collisions with the // `payer_optional_check`, which is in a separate scope let owner_optional_check = check_scope.clone().generate_check(o); ( quote! { &#o }, owner_optional_check, ) } };
Later, generate_create_account is called to build the account-creation CPI.
rust// CPI to the system program to create the account. let create_account = generate_create_account( field, quote! {space}, owner.clone(), quote! {#payer}, seeds_with_bump, );
Internally, generate_create_account handles a subtle but important edge case: the target account may already hold lamports (for example, someone pre-funded a PDA address).
- If the account’s lamports are zero, Anchor uses a normal
system_program::create_accountCPI. - If the account already has lamports, Anchor does not use
create_account; instead it tops up rent if needed, then usessystem_program::allocateandsystem_program::assign(with signer seeds for PDAs). This makes the flow robust against “pre-fund” griefing.
In other words, if if_needed is false (plain init), or if if_needed is true but the target account is still owned by the System Program, Anchor takes the creation path via generate_create_account. Otherwise, it assumes the account already exists and deserializes it via the checked path.
Note:
from_account_info_uncheckedskips owner/discriminator checks because the account was just created (there is nothing to validate yet).from_account_infoperforms full validation, which is appropriate for an account that already existed.
rust// Create the account. Always do this in the event // if needed is not specified or the system program is the owner. let pa: #ty_decl = if !#if_needed || actual_owner == &anchor_lang::solana_program::system_program::ID { #payer_optional_check // CPI to the system program to create. #create_account // Convert from account info to account context wrapper type. #from_account_info_unchecked } else { // Convert from account info to account context wrapper type. #from_account_info };
If it’s the init_if_needed case and Anchor skipped creation, it will validate basic invariants such as space, owner, and rent exemption.
rust// Assert the account was created correctly. if #if_needed { #owner_optional_check if space != actual_field.data_len() { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintSpace).with_account_name(#name_str).with_values((space, actual_field.data_len()))); } if actual_owner != #owner { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintOwner).with_account_name(#name_str).with_pubkeys((*actual_owner, *#owner))); } { let required_lamports = __anchor_rent.minimum_balance(space); if pa.to_account_info().lamports() < required_lamports { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintRentExempt).with_account_name(#name_str)); } } }
InitKind::Mint#
The logic is similar to the Program case. After several checks on the mint parameters, generate_create_account is called again.
rustlet create_account = generate_create_account( field, mint_space, quote! {&#token_program.key()}, quote! {#payer}, seeds_with_bump, );
In the generated code, if the account needs to be created, create_account runs and any extensions are initialized. In the init_if_needed case where the account already exists, Anchor instead validates that mint_authority, freeze_authority, and decimals match the declared values.
rustif !#if_needed || owner_program == &anchor_lang::solana_program::system_program::ID { // Define payer variable. #payer_optional_check // Create the account with the system program. #create_account let cpi_program_id = #token_program.key(); // Initialize extensions. if let Some(extensions) = #extensions { ... } // Initialize the mint account. let accounts = ::anchor_spl::token_interface::InitializeMint2 { mint: #field.to_account_info(), }; let cpi_ctx = anchor_lang::context::CpiContext::new(cpi_program_id, accounts); ::anchor_spl::token_interface::initialize_mint2(cpi_ctx, #decimals, &#owner.key(), #freeze_authority)?; } let pa: #ty_decl = #from_account_info_unchecked; if #if_needed { if pa.mint_authority != anchor_lang::solana_program::program_option::COption::Some(#owner.key()) { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintMintMintAuthority).with_account_name(#name_str)); } if pa.freeze_authority .as_ref() .map(|fa| #freeze_authority.as_ref().map(|expected_fa| fa != *expected_fa).unwrap_or(true)) .unwrap_or(#freeze_authority.is_some()) { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintMintFreezeAuthority).with_account_name(#name_str)); } if pa.decimals != #decimals { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintMintDecimals).with_account_name(#name_str).with_values((pa.decimals, #decimals))); } if owner_program != &#token_program.key() { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintMintTokenProgram).with_account_name(#name_str).with_pubkeys((*owner_program, #token_program.key()))); } }
InitKind::Token#
The logic is similar to the Program case. After several checks on the token parameters, generate_create_account is called again.
rustlet create_account = generate_create_account( field, quote! {#token_account_space}, quote! {&#token_program.key()}, quote! {#payer}, seeds_with_bump, );
In the generated code, if the account needs to be created, create_account runs and the token account is initialized. In the init_if_needed case where the account already exists, Anchor instead validates that mint, owner, and token_program match the declared values.
rustif !#if_needed || owner_program == &anchor_lang::solana_program::system_program::ID { #payer_optional_check // Create the account with the system program. #create_account // Initialize the token account. let cpi_program_id = #token_program.key(); let accounts = ::anchor_spl::token_interface::InitializeAccount3 { account: #field.to_account_info(), mint: #mint.to_account_info(), authority: #owner.to_account_info(), }; let cpi_ctx = anchor_lang::context::CpiContext::new(cpi_program_id, accounts); ::anchor_spl::token_interface::initialize_account3(cpi_ctx)?; } let pa: #ty_decl = #from_account_info_unchecked; if #if_needed { if pa.mint != #mint.key() { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintTokenMint).with_account_name(#name_str).with_pubkeys((pa.mint, #mint.key()))); } if pa.owner != #owner.key() { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintTokenOwner).with_account_name(#name_str).with_pubkeys((pa.owner, #owner.key()))); } if owner_program != &#token_program.key() { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintTokenTokenProgram).with_account_name(#name_str).with_pubkeys((*owner_program, #token_program.key()))); } }
InitKind::AssociatedToken#
The logic here is slightly different. If Anchor takes the creation path, it calls ::anchor_spl::associated_token::create to create the associated token account. If Anchor skips creation (the init_if_needed + non-system-owned case), it checks invariants like mint, owner, token_program, and that the address is the derived ATA.
rustif !#if_needed || owner_program == &anchor_lang::solana_program::system_program::ID { #payer_optional_check ::anchor_spl::associated_token::create( anchor_lang::context::CpiContext::new( associated_token_program.key(), ::anchor_spl::associated_token::Create { payer: #payer.to_account_info(), associated_token: #field.to_account_info(), authority: #owner.to_account_info(), mint: #mint.to_account_info(), system_program: system_program.to_account_info(), token_program: #token_program.to_account_info(), } ) )?; } let pa: #ty_decl = #from_account_info_unchecked; if #if_needed { if pa.mint != #mint.key() { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintTokenMint).with_account_name(#name_str).with_pubkeys((pa.mint, #mint.key()))); } if pa.owner != #owner.key() { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintTokenOwner).with_account_name(#name_str).with_pubkeys((pa.owner, #owner.key()))); } if owner_program != &#token_program.key() { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::ConstraintAssociatedTokenTokenProgram).with_account_name(#name_str).with_pubkeys((*owner_program, #token_program.key()))); } if pa.key() != ::anchor_spl::associated_token::get_associated_token_address_with_program_id(&#owner.key(), &#mint.key(), &#token_program.key()) { return Err(anchor_lang::error::Error::from(anchor_lang::error::ErrorCode::AccountNotAssociatedTokenAccount).with_account_name(#name_str)); } }
Associated token account (ATA) griefing with init#
At runtime, Anchor calls ::anchor_spl::associated_token::create to create the associated token account when init is used.
rustif !#if_needed || owner_program == &anchor_lang::solana_program::system_program::ID { #payer_optional_check ::anchor_spl::associated_token::create( anchor_lang::context::CpiContext::new( associated_token_program.key(), ::anchor_spl::associated_token::Create { payer: #payer.to_account_info(), associated_token: #field.to_account_info(), authority: #owner.to_account_info(), mint: #mint.to_account_info(), system_program: system_program.to_account_info(), token_program: #token_program.to_account_info(), } ) )?; }
So, if if_needed is false (plain init), or if if_needed is true but the account is still system-owned, Anchor will attempt to create the ATA via a CPI into the associated token account program.
This is the normal case for ATA accounts declared by the program:
rust#[account( init, payer = signer, associated_token::mint = mint, associated_token::authority = vault, )] pub associated_token: Account<'info, TokenAccount>,
But what if the ATA already exists — potentially because a third party created it in advance (which is allowed), or simply because the user already interacted with that mint elsewhere?
Recall that an ATA address is a PDA derived from (wallet, token_program_id, mint) under the ATA program id, so “creating an ATA early” means creating the canonical address that your program will later expect.
In the associated token account program, the process_create_associated_token_account handler enforces two relevant properties:
- There is no requirement that the
funder_info(payer) equals thewallet_account_info(the wallet that will own the ATA). So anyone can pay to create someone else’s ATA.
rustlet funder_info = next_account_info(account_info_iter)?; let associated_token_account_info = next_account_info(account_info_iter)?; let wallet_account_info = next_account_info(account_info_iter)?; let spl_token_mint_info = next_account_info(account_info_iter)?; let system_program_info = next_account_info(account_info_iter)?; let spl_token_program_info = next_account_info(account_info_iter)?; let spl_token_program_id = spl_token_program_info.key; let (associated_token_address, bump_seed) = get_associated_token_address_and_bump_seed_internal( wallet_account_info.key, spl_token_mint_info.key, program_id, spl_token_program_id, );
- For the non-idempotent create path (“Create” /
CreateMode::Always), if the account is already initialized, the ATA account is no longer system-owned, and creation fails:
rustif *associated_token_account_info.owner != system_program::id() { return Err(ProgramError::IllegalOwner); }
Therefore, if you use init for an ATA, any pre-existing ATA at that address causes Anchor’s CPI create to fail. A third party can “grief” by creating the ATA first, forcing your instruction to fail (a denial-of-service on that code path).
Note: newer ATA program versions also support an idempotent create instruction (“CreateIdempotent” / CreateMode::Idempotent) that returns Ok(()) if the ATA already exists with the correct mint/owner. Anchor’s #[account(init, associated_token::...)] flow calls ::anchor_spl::associated_token::create, which maps to the non-idempotent create path, so the failure mode remains.
init_if_needed is not affected in the same way because it skips the CPI create call when the ATA already exists (i.e., when the account owner is the token program, not the System Program). In that case, Anchor instead validates that the provided account is the correct ATA (mint/owner/token program and derived address).
rustif !#if_needed || owner_program == &anchor_lang::solana_program::system_program::ID {
A mitigation strategy is to prefer init_if_needed for ATA accounts when possible.
If you’re not relying on Anchor’s init/init_if_needed for ATAs, another robust option is to CPI into ::anchor_spl::associated_token::create_idempotent instead of create, so “already exists” becomes a no-op (as long as the existing account matches the expected mint/owner).
