Sec3 logo — Solana smart contract security firm
Back to Blog
Education

Solana Programs Part 4

Sec3 Research Team

The Metaplex Candy Machine is among the most popular smart contracts used for NFT minting on Solana. Recently, it has even implemented sophisticated logics for detecting and taxing bots.

How does the candy machine program work internally? What are its intended use cases and dependencies? How does it detect bots? This article elaborates on these technical details.

What Is Metaplex Candy Machine?

The Candy Machine is a distribution program used by

  1. NFT creators to set up a collection of NFTs (e.g., 10,000 diverse Okay Bears) to be distributed

  2. Users to mint the NFTs created in Step 1 (note: bots can mint NFT too!)

For 1, the distributor must first call the InitializeCandyMachine instruction to create a candy machine:

pub struct CandyMachine {
    pub authority: Pubkey,
    pub wallet: Pubkey,
    pub token_mint: Option<Pubkey>,
    pub items_redeemed: u64,
    pub data: CandyMachineData,
    // there's a borsh vec u32 denoting how many actual lines of data there
    // There is actually lines and lines of data after this but we explicitly
    // here there is a borsh vec u32 indicating number of bytes in bitmask a
    // here there is a number of bytes equal to ceil(max_number_of_lines/8)

A candy machine has a few important pieces of information:

  • authority: who is authorized to update the candy machine, withdraw funds, etc

  • wallet: the wallet for receiving payment from NFT minters

  • token_mint: an optional SPL token mint for the wallet (if payment is in SPL token)

  • items_redeemed: total number of NFTs minted so far

  • data: the NFT metadata (of type CandyMachineData)

  • additional configs for each NFT (e.g., name and uri)

The CandyMachineData includes the necessary attributes for NFT minting:

pub struct CandyMachineData {
    pub uuid: String,
    pub price: u64,
    /// The symbol for the asset
    pub symbol: String,
    /// Royalty basis points that goes to creators in secondary sales (0-10000)
    pub seller_fee_basis_points: u16,
    pub max_supply: u64,
    pub is_mutable: bool,
    pub retain_authority: bool,
    pub go_live_date: Option<i64>,
    pub end_settings: Option<EndSettings>,
    pub creators: Vec<Creator>,
    pub hidden_settings: Option<HiddenSettings>,
    pub whitelist_mint_settings: Option<WhitelistMintSettings>,
    pub items_available: u64,
    /// If [`Some`] requires gateway tokens on mint
    pub gatekeeper: Option<GatekeeperConfig>,

price is the price paid by users to mint an NFT, with optional discount configured in whitelist_mint_settings.

creators specifies the list of creators of the NFT collection.

items_available is number of NFTs in the collection.

For 2, any one can mint NFTs through a candy machine created in Step 1, by calling the MintNFT instruction. MintNFT is a fairly complex instruction (over 600 lines of source code in Rust). We next explain MintNFT in detail.

Mint NFT

To mint an NFT on Metaplex, there are several essential steps (see Part 3: Metaplex Token Metadata):

  1. create a new token mint (a unique identifier for the to-be-minted NFT), and mint one token to the user’s token account

  2. create a new metadata account for the NFT (this will call the token metadata program’s CreateMetadataAccountV2 instruction)

  3. create a master edition account of the NFT (this will call the token metadata program’s CreateMasterEditionV3 instruction)

  4. set metadata’s update_authority to a user-supplied account (or candy_machine.authority if candy_machine.data.retain_authority is true); this will call the token metadata program’s UpdateMetadataAccountV2 instruction

The MintNFT instruction depends on the token metadata program and it includes all the above four steps and, additionally, a logic for users to pay for the minted NFT and logics for detecting and taxing bots.

How Does a User Mint an NFT on Candy Machine?

To process the MintNFT instruction, the function handle_mint_nft is invoked with a number of input accounts, e.g., candy_machine , candy_machine_creator , wallet , metadata , mint , mint_authority , etc.

92    pub fn handle_mint_nft<'info>(
93        ctx: Context<'_, '_, '_, 'info, MintNFT<'info>>,
94        creator_bump: u8,
95    ) -> Result<()> {
96        let candy_machine = &mut ctx.accounts.candy_machine;
97        let candy_machine_creator = &ctx.accounts.candy_machine_creator;
98        // Note this is the wallet of the Candy machine
99        let wallet = &ctx.accounts.wallet;
100       let payer = &ctx.accounts.payer;
101       let token_program = &ctx.accounts.token_program;

The user has to pay a price to mint each NFT. The price is specified in the candy machine in the amount of either a SPL token or Sol:

let mut price = candy_machine.data.price;

When SPL token is used, the token mint is specified by candy_machine.token_mint and the payment is transferred from the source token account to the candy_machine.wallet account by spl_token_transfer (line 468):

456       if let Some(mint) = candy_machine.token_mint {
457           let token_account_info = &ctx.remaining_accounts[remaining_accounts_counter];
458           remaining_accounts_counter += 1;
459           let transfer_authority_info = &ctx.remaining_accounts[remaining_accounts_counter];
460           // If we add more extra accounts later on we need to uncomment the following line out.
461           // remaining_accounts_counter += 1;
462           let token_account = assert_is_ata(token_account_info, &payer.key(), &mint)?;
463
464           if token_account.amount < price {
465               return err!(CandyError::NotEnoughTokens);
466           }
467
468           spl_token_transfer(TokenTransferParams {
469               source: token_account_info.clone(),
470               destination: wallet.to_account_info(),
471               authority: transfer_authority_info.clone(),
472               authority_signer_seeds: &[],
473               token_program: token_program.to_account_info(),
474               amount: price,
475           })?;

Otherwise, the price amount of Sol is transferred from the payer account to the wallet account directly via system_instruction::transfer (line 482):

477       if ctx.accounts.payer.lamports() < price {
478           return err!(CandyError::NotEnoughSOL);
479       }
480
481       invoke(
482           &system_instruction::transfer(&ctx.accounts.payer.key(), &wallet.key(), price),
483           &[
484               ctx.accounts.payer.to_account_info(),
485               wallet.to_account_info(),
486               ctx.accounts.system_program.to_account_info(),
487           ],

The distributor can also offer a discount price for users through configuring whitelist_mint_settings in candy_machine.data:

293       if let Some(ws) = &candy_machine.data.whitelist_mint_settings {
367               if let Some(dp) = ws.discount_price {
368                   price = dp;

Which NFT to Mint From a Collection?

For every NFT in the collection, the candy machine maintains a ConfigLine containing the NFT’s name and uri:

50    pub struct ConfigLine {
51        pub name: String,
52        /// URI pointing to JSON representing the asset
53        pub uri: String,

If candy_machine.data.hidden_settings is set, the minted NFT name is ordered by a mint_number (e.g. Okay Bear #1756):

686   pub fn get_config_line(
687       a: &Account<'_, CandyMachine>,
688       index: usize,
689       mint_number: u64,
690   ) -> Result<ConfigLine> {
691       if let Some(hs) = &a.data.hidden_settings {
692           return Ok(ConfigLine {
693               name: hs.name.clone() + "#" + &(mint_number + 1).to_string(),
694               uri: hs.uri.clone(),

The mint_number is incremented by one upon each successful MintNFT call.

Otherwise if hidden_settings is not set, the MintNFT instruction uses an index calculated from recent_slothashes.data to determine which NFT to mint from the collection:

491       let data = recent_slothashes.data.borrow();
492       let most_recent = array_ref![data, 12, 8];
493
494       let index = u64::from_le_bytes(*most_recent);
495       let modded: usize = index
496           .checked_rem(candy_machine.data.items_available)
497           .ok_or(CandyError::NumericalOverflowError)? as usize;
498
499       let config_line = get_config_line(candy_machine, modded, candy_machine.items_redeemed)?;

The get_config_line function takes the modded index as input and traverses through the remaining NFTs in the collection (using a bit mask):

614   pub fn get_good_index(
615       arr: &mut RefMut<&mut [u8]>,
616       items_available: usize,
617       index: usize,
618       pos: bool,
619   ) -> Result<(usize, bool)> {
620       let mut index_to_use = index;
621       let mut taken = 1;
622       let mut found = false;
623       let bit_mask_vec_start = CONFIG_ARRAY_START
624           + 4
625           + (items_available) * CONFIG_LINE_SIZE
626           + 4
627           + items_available
628               .checked_div(8)
629               .ok_or(CandyError::NumericalOverflowError)?
630           + 4;
631
632       while taken > 0 && index_to_use < items_available {
633           let my_position_in_vec = bit_mask_vec_start
634               + index_to_use
635                   .checked_div(8)
636                   .ok_or(CandyError::NumericalOverflowError)?;

Finally, after checking all constraints and setting up the CPI account data to call the token metadata program, the following three functions are invoked in the written order to complete the NFT minting process:

config_line contains the information of the minted NFT

548       invoke_signed(
549           &create_metadata_accounts_v2(
550               ctx.accounts.token_metadata_program.key(),
551               ctx.accounts.metadata.key(),
552               ctx.accounts.mint.key(),
553               ctx.accounts.mint_authority.key(),
554               ctx.accounts.payer.key(),
555               candy_machine_creator.key(),
556               config_line.name,
557               candy_machine.data.symbol.clone(),
558               config_line.uri,
559               Some(creators),
560               candy_machine.data.seller_fee_basis_points,
561               true,
562               candy_machine.data.is_mutable,
563               None,
564               None,

config_line contains the information of the minted NFT

569       invoke_signed(
570           &create_master_edition_v3(
571               ctx.accounts.token_metadata_program.key(),
572               ctx.accounts.master_edition.key(),
573               ctx.accounts.mint.key(),
574               candy_machine_creator.key(),
575               ctx.accounts.mint_authority.key(),
576               ctx.accounts.metadata.key(),
577               ctx.accounts.payer.key(),
578               Some(candy_machine.data.max_supply),
589       invoke_signed(
590           &update_metadata_accounts_v2(
591               ctx.accounts.token_metadata_program.key(),
592               ctx.accounts.metadata.key(),
593               candy_machine_creator.key(),
594               new_update_authority,
595               None,
596               Some(true),
597               if !candy_machine.data.is_mutable {
598                   Some(false)
599               } else {
600                   None

Note that the candy_machine_creator account (PDA created by the candy machine program) is also specified as a creator of the NFT:

44    #[account(seeds=[PREFIX.as_bytes(), candy_machine.key().as_ref()], bump=creator_bump)]
45    candy_machine_creator: UncheckedAccount<'info>,
509       let mut creators: Vec<mpl_token_metadata::state::Creator> =
510           vec![mpl_token_metadata::state::Creator {
511               address: candy_machine_creator.key(),
512               verified: true,
513               share: 0,

Why Not Use the Token Metadata Program Directly?

Readers may wonder, to mint an NFT, why should we use a candy machine instead of calling the token metadata program directly?

These two programs have different use cases:

  • for candy machine, users mint from a collection of NFTs created by the distributor (i.e., the distributor represents the original NFT creators)

  • for token metadata, users create their own NFTs one by one

How Does Candy Machine Detect and Tax Bots?

The candy machine program detects bots and charges a fee (BOT_FEE: 0.01 Sol) for each bot call to the MintNFT instruction:

227   pub fn punish_bots<'a>(
228       err: CandyError,
229       bot_account: AccountInfo<'a>,
230       payment_account: AccountInfo<'a>,
231       system_program: AccountInfo<'a>,
232       fee: u64,
233   ) -> Result<()> {
234       msg!(
235           "{}, Candy Machine Botting is taxed at {:?} lamports",
236           err.to_string(),
237           fee
238       );
239       let final_fee = fee.min(bot_account.lamports());
240       invoke(
241           &system_instruction::transfer(bot_account.key, payment_account.key, final_fee),
242           &[bot_account, payment_account, system_program],

The bot detection logic is implemented in the handle_mint_nft function, considering a good number of different scenarios:

Step 1. bots calling handle_mint_nft via CPI. Only CPI called by the Gumpdrop program is not considered as bots. GUMDROP_ID: gdrpGjVffourzkdDRrQmySw4aTHr8a3xmQzzxSwFD1a

120       // Restrict Who can call Candy Machine via CPI
121       if !cmp_pubkeys(&current_ix.program_id, &crate::id())
122           && !cmp_pubkeys(&current_ix.program_id, &GUMDROP_ID)
123       {
124           punish_bots(
125               CandyError::SuspiciousTransaction,
126               payer.to_account_info(),
127               ctx.accounts.candy_machine.to_account_info(),
128               ctx.accounts.system_program.to_account_info(),
129               BOT_FEE,

Step 2. bots missing set collection during the mint instruction (for candy machine with collection set):

133       let next_ix = get_instruction_relative(1, &instruction_sysvar_account_info);
134       match next_ix {
135           Ok(ix) => {
147           Err(_) => {
148               if is_feature_active(&candy_machine.data.uuid, COLLECTIONS_FEATURE_INDEX) {
149                   punish_bots(
150                       CandyError::MissingSetCollectionDuringMint,
151                       payer.to_account_info(),
152                       ctx.accounts.candy_machine.to_account_info(),
153                       ctx.accounts.system_program.to_account_info(),
154                       BOT_FEE,

Step 3. bot transactions containing any CPI from an unknown program:

160       let mut idx = 0;
161       let num_instructions =
162           read_u16(&mut idx, &instruction_sysvar).map_err(|_| ProgramError::InvalidAccountData)?;
163
164       for index in 0..num_instructions {
165           let mut current = 2 + (index * 2) as usize;
166           let start = read_u16(&mut current, &instruction_sysvar).unwrap();
167
168           current = start as usize;
169           let num_accounts = read_u16(&mut current, &instruction_sysvar).unwrap();
170           current += (num_accounts as usize) * (1 + 32);
171           let program_id = read_pubkey(&mut current, &instruction_sysvar).unwrap();
172
173           if !cmp_pubkeys(&program_id, &crate::id())
174               && !cmp_pubkeys(&program_id, &spl_token::id())
175               && !cmp_pubkeys(
176                   &program_id,
177                   &anchor_lang::solana_program::system_program::ID,
178               )
179               && !cmp_pubkeys(&program_id, &A_TOKEN)
180           {
181               msg!("Transaction had ix with program id {}", program_id);
182               punish_bots(
183                   CandyError::SuspiciousTransaction,
184                   payer.to_account_info(),
185                   ctx.accounts.candy_machine.to_account_info(),
186                   ctx.accounts.system_program.to_account_info(),
187                   BOT_FEE,

The known programs include the System program, SPL Token program, Associated Token program, and the Candy Machine program itself. Transactions containing any CPI not from the known programs are considered as bots.

Step 4. bots calling MintNFT after minting has stopped (after a certain timestamp):

194       if let Some(es) = &candy_machine.data.end_settings {
195           match es.end_setting_type {
196               EndSettingType::Date => {
197                   if clock.unix_timestamp > es.number as i64
198                       && !cmp_pubkeys(&ctx.accounts.payer.key(), &candy_machine.authority)
199                   {
200                       punish_bots(
201                           CandyError::CandyMachineNotLive,
202                           payer.to_account_info(),
203                           ctx.accounts.candy_machine.to_account_info(),
204                           ctx.accounts.system_program.to_account_info(),
205                           BOT_FEE,

Step 5. bots calling MintNFT after all available NFTs have been redeemed:

445       if candy_machine.items_redeemed >= candy_machine.data.items_available {
446           punish_bots(
447               CandyError::CandyMachineEmpty,
448               payer.to_account_info(),
449               ctx.accounts.candy_machine.to_account_info(),
450               ctx.accounts.system_program.to_account_info(),
451               BOT_FEE,
210               EndSettingType::Amount => {
211                   if candy_machine.items_redeemed >= es.number {
212                       if !cmp_pubkeys(&ctx.accounts.payer.key(), &candy_machine.authority) {
213                           punish_bots(
214                               CandyError::CandyMachineEmpty,
215                               payer.to_account_info(),
216                               ctx.accounts.candy_machine.to_account_info(),
217                               ctx.accounts.system_program.to_account_info(),
218                               BOT_FEE,

Step 6. bots missing gateway token when the candy machine’s gatekeeper is set

227       let mut remaining_accounts_counter: usize = 0;
228       if let Some(gatekeeper) = &candy_machine.data.gatekeeper {
229           if ctx.remaining_accounts.len() <= remaining_accounts_counter {
230               punish_bots(
231                   CandyError::GatewayTokenMissing,
232                   payer.to_account_info(),
233                   ctx.accounts.candy_machine.to_account_info(),
234                   ctx.accounts.system_program.to_account_info(),
235                   BOT_FEE,

Step 7. bots calling MintNFT before the candy machine’s go_live_date:

303       match candy_machine.data.go_live_date {
304           None => {
305               if !cmp_pubkeys(&ctx.accounts.payer.key(), &candy_machine.authority)
306                   && !ws.presale
307               {
308                   punish_bots(
309                       CandyError::CandyMachineNotLive,
310                       payer.to_account_info(),
311                       ctx.accounts.candy_machine.to_account_info(),
312                       ctx.accounts.system_program.to_account_info(),
313                       BOT_FEE,
314                   )?;
315                   return Ok(());
316               }
317           }
318           Some(val) => {
319               if clock.unix_timestamp < val
320                   && !cmp_pubkeys(&ctx.accounts.payer.key(), &candy_machine.authority)
321                   && !ws.presale
322               {
323                   punish_bots(
324                       CandyError::CandyMachineNotLive,
325                       payer.to_account_info(),
326                       ctx.accounts.candy_machine.to_account_info(),
327                       ctx.accounts.system_program.to_account_info(),
328                       BOT_FEE,
384               let go_live = assert_valid_go_live(payer, clock, candy_machine);
385               if go_live.is_err() {
386                   punish_bots(
387                       CandyError::CandyMachineNotLive,
388                       payer.to_account_info(),
389                       ctx.accounts.candy_machine.to_account_info(),
390                       ctx.accounts.system_program.to_account_info(),
391                       BOT_FEE,

Step 8. bots calling MintNFT with incorrect whitelist_token_mint:

344               let key_check = assert_keys_equal(whitelist_token_mint.key(), ws.mint);
345
346               if key_check.is_err() {
347                   punish_bots(
348                       CandyError::IncorrectOwner,
349                       payer.to_account_info(),
350                       ctx.accounts.candy_machine.to_account_info(),
351                       ctx.accounts.system_program.to_account_info(),
352                       BOT_FEE,

Step 9. bots calling MintNFT with whitelist_token_account while the candy machine has no discount and no presale (i.e., it has a forced whitelist):

371               if wta.amount == 0 && ws.discount_price.is_none() && !ws.presale {
372                   // A non-presale whitelist with no discount price is a forced whitelist
373                   // If a pre-sale has no discount, its no issue, because the "discount"
374                   // is minting first - a presale whitelist always has an open post sale.
375                   punish_bots(
376                       CandyError::NoWhitelistToken,
377                       payer.to_account_info(),
378                       ctx.accounts.candy_machine.to_account_info(),
379                       ctx.accounts.system_program.to_account_info(),
380                       BOT_FEE,
400           Err(_) => {
401               if ws.discount_price.is_none() && !ws.presale {
402                   // A non-presale whitelist with no discount price is a forced whitelist
403                   // If a pre-sale has no discount, its no issue, because the "discount"
404                   // is minting first - a presale whitelist always has an open post sale.
405                   punish_bots(
406                       CandyError::NoWhitelistToken,
407                       payer.to_account_info(),
408                       ctx.accounts.candy_machine.to_account_info(),
409                       ctx.accounts.system_program.to_account_info(),
410                       BOT_FEE,

Update and Configure Candy Machine

The authority can make changes to the candy machine through a few additional instructions: UpdateCandyMachine, AddConfigLines, SetCollection, RemoveCollection, SetCollectionDuringMint, as well as WithdrawFunds. These instructions are permissioned (signed by candy_machine.authority) otherwise will abort the transaction.


About sec3 (Formerly Soteria)

sec3 is a security research firm that prepares Solana projects for millions of users. sec3’s Launch Audit is a rigorous, researcher-led code examination that investigates and certifies mainnet-grade smart contracts; sec3’s continuous auditing software platform, X-ray, integrates with GitHub to progressively scan pull requests, helping projects fortify code before deployment; and sec3’s post-deployment security solution, WatchTower, ensures funds stay safe. sec3 is building technology-based scalable solutions for Web3 projects to ensure protocols stay safe as they scale.

To learn more about sec3, please visit https://www.sec3.dev

Related Posts

Education

Solana Programs Part 1

Most user-deployed Solana smart contracts (directly or transitively) use the token program to mint/transfer/burn tokens (i.e., SPL tokens). SPL tokens are similar to ERC20/ERC721 token with tricky differences. In this article, we elaborate on the SPL tokens and introduce the internals of the most commonly used instructions in the token program.

Read more