Solana Programs Part 4
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
-
NFT creators to set up a collection of NFTs (e.g., 10,000 diverse Okay Bears) to be distributed
-
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 typeCandyMachineData) -
additional configs for each NFT (e.g.,
nameanduri)
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):
-
create a new token mint (a unique identifier for the to-be-minted NFT), and mint one token to the user’s token account
-
create a new metadata account for the NFT (this will call the token metadata program’s
CreateMetadataAccountV2instruction) -
create a master edition account of the NFT (this will call the token metadata program’s
CreateMasterEditionV3instruction) -
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
UpdateMetadataAccountV2instruction
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(¤t_ix.program_id, &crate::id())
122 && !cmp_pubkeys(¤t_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