NFT Minting Contract
This contract implements a complete NFT collection with Metaplex standards, featuring minting limits, whitelist phases, and royalty enforcement.
Collection Architecture
Text
┌─────────────────────────────────────────────────────────────────┐
│ NFT Collection Structure │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Collection Config (PDA) │ │
│ │ ───────────────────────────────────────────────────── │ │
│ │ Authority Creator │ │
│ │ Collection Mint Master NFT │ │
│ │ Price 0.5 SOL │ │
│ │ Max Supply 10,000 │ │
│ │ Items Minted 1,234 │ │
│ │ Start Time 1704067200 │ │
│ │ WL Root 0xabc... │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────┼────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ NFT #1 │ │ NFT #2 │ │ NFT #N │ │
│ │ Mint │ │ Mint │ │ Mint │ │
│ │ Metadata │ │ Metadata │ │ Metadata │ │
│ │ Master │ │ Master │ │ Master │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Complete Implementation
Rust
use anchor_lang::prelude::*;
use anchor_lang::system_program;
use anchor_spl::token::{self, Token, TokenAccount, Mint, MintTo};
use anchor_spl::associated_token::AssociatedToken;
use anchor_spl::metadata::{
create_master_edition_v3,
create_metadata_accounts_v3,
Metadata,
CreateMasterEditionV3,
CreateMetadataAccountsV3,
mpl_token_metadata::types::{Creator, DataV2, Collection},
};
use solana_program::keccak;
declare_id!("NFT1111111111111111111111111111111111111111");
#[program]
pub mod nft_minting {
use super::*;
/// Initialize the NFT collection
pub fn initialize_collection(
ctx: Context<InitializeCollection>,
name: String,
symbol: String,
uri: String,
price: u64,
max_supply: u32,
start_time: i64,
whitelist_price: u64,
whitelist_end_time: i64,
royalty_basis_points: u16,
) -> Result<()> {
require!(max_supply > 0, NftError::InvalidMaxSupply);
require!(price > 0, NftError::InvalidPrice);
require!(royalty_basis_points <= 10000, NftError::InvalidRoyalty);
let config = &mut ctx.accounts.config;
config.authority = ctx.accounts.authority.key();
config.collection_mint = ctx.accounts.collection_mint.key();
config.treasury = ctx.accounts.treasury.key();
config.name = name.clone();
config.symbol = symbol.clone();
config.base_uri = uri.clone();
config.price = price;
config.whitelist_price = whitelist_price;
config.max_supply = max_supply;
config.items_minted = 0;
config.start_time = start_time;
config.whitelist_end_time = whitelist_end_time;
config.whitelist_root = [0u8; 32];
config.royalty_basis_points = royalty_basis_points;
config.paused = false;
config.bump = ctx.bumps.config;
// Create collection NFT
let seeds = &[
b"config",
ctx.accounts.authority.key().as_ref(),
&[config.bump],
];
let signer_seeds = &[&seeds[..]];
// Mint collection NFT
let cpi_accounts = MintTo {
mint: ctx.accounts.collection_mint.to_account_info(),
to: ctx.accounts.collection_token.to_account_info(),
authority: ctx.accounts.config.to_account_info(),
};
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
signer_seeds,
);
token::mint_to(cpi_ctx, 1)?;
// Create collection metadata
let creators = vec![
Creator {
address: ctx.accounts.authority.key(),
verified: false,
share: 100,
},
];
let collection_data = DataV2 {
name: name.clone(),
symbol: symbol.clone(),
uri: uri.clone(),
seller_fee_basis_points: royalty_basis_points,
creators: Some(creators),
collection: None,
uses: None,
};
create_metadata_accounts_v3(
CpiContext::new_with_signer(
ctx.accounts.metadata_program.to_account_info(),
CreateMetadataAccountsV3 {
metadata: ctx.accounts.collection_metadata.to_account_info(),
mint: ctx.accounts.collection_mint.to_account_info(),
mint_authority: ctx.accounts.config.to_account_info(),
payer: ctx.accounts.authority.to_account_info(),
update_authority: ctx.accounts.config.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
rent: ctx.accounts.rent.to_account_info(),
},
signer_seeds,
),
collection_data,
true,
true,
None,
)?;
// Create master edition
create_master_edition_v3(
CpiContext::new_with_signer(
ctx.accounts.metadata_program.to_account_info(),
CreateMasterEditionV3 {
edition: ctx.accounts.collection_master_edition.to_account_info(),
mint: ctx.accounts.collection_mint.to_account_info(),
update_authority: ctx.accounts.config.to_account_info(),
mint_authority: ctx.accounts.config.to_account_info(),
payer: ctx.accounts.authority.to_account_info(),
metadata: ctx.accounts.collection_metadata.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
rent: ctx.accounts.rent.to_account_info(),
},
signer_seeds,
),
Some(0), // 0 = non-fungible (no editions allowed)
)?;
emit!(CollectionInitialized {
config: ctx.accounts.config.key(),
collection_mint: config.collection_mint,
max_supply,
price,
});
Ok(())
}
/// Set whitelist merkle root
pub fn set_whitelist_root(
ctx: Context<UpdateConfig>,
root: [u8; 32],
) -> Result<()> {
ctx.accounts.config.whitelist_root = root;
Ok(())
}
/// Mint an NFT (public or whitelist)
pub fn mint_nft(
ctx: Context<MintNft>,
name: String,
uri: String,
whitelist_proof: Option<Vec<[u8; 32]>>,
) -> Result<()> {
let config = &ctx.accounts.config;
let clock = Clock::get()?;
require!(!config.paused, NftError::MintingPaused);
require!(config.items_minted < config.max_supply, NftError::SoldOut);
// Determine price and verify access
let (price, is_whitelist) = if clock.unix_timestamp < config.whitelist_end_time {
// Whitelist phase
if let Some(proof) = whitelist_proof {
let leaf = keccak::hashv(&[ctx.accounts.minter.key().as_ref()]);
require!(
verify_proof(&proof, &config.whitelist_root, &leaf.0),
NftError::NotWhitelisted
);
(config.whitelist_price, true)
} else {
return Err(NftError::NotWhitelisted.into());
}
} else {
// Public phase
require!(
clock.unix_timestamp >= config.start_time,
NftError::MintNotStarted
);
(config.price, false)
};
// Check per-wallet limit
let mint_tracker = &mut ctx.accounts.mint_tracker;
let max_per_wallet = if is_whitelist { 2 } else { 5 };
require!(
mint_tracker.count < max_per_wallet,
NftError::WalletLimitExceeded
);
// Transfer payment
system_program::transfer(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
system_program::Transfer {
from: ctx.accounts.minter.to_account_info(),
to: ctx.accounts.treasury.to_account_info(),
},
),
price,
)?;
// Mint NFT token
let seeds = &[
b"config",
config.authority.as_ref(),
&[config.bump],
];
let signer_seeds = &[&seeds[..]];
let cpi_accounts = MintTo {
mint: ctx.accounts.nft_mint.to_account_info(),
to: ctx.accounts.nft_token.to_account_info(),
authority: ctx.accounts.config.to_account_info(),
};
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
signer_seeds,
);
token::mint_to(cpi_ctx, 1)?;
// Create metadata
let nft_number = config.items_minted + 1;
let creators = vec![
Creator {
address: config.authority,
verified: false,
share: 100,
},
];
let metadata_data = DataV2 {
name,
symbol: config.symbol.clone(),
uri,
seller_fee_basis_points: config.royalty_basis_points,
creators: Some(creators),
collection: Some(Collection {
verified: false,
key: config.collection_mint,
}),
uses: None,
};
create_metadata_accounts_v3(
CpiContext::new_with_signer(
ctx.accounts.metadata_program.to_account_info(),
CreateMetadataAccountsV3 {
metadata: ctx.accounts.nft_metadata.to_account_info(),
mint: ctx.accounts.nft_mint.to_account_info(),
mint_authority: ctx.accounts.config.to_account_info(),
payer: ctx.accounts.minter.to_account_info(),
update_authority: ctx.accounts.config.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
rent: ctx.accounts.rent.to_account_info(),
},
signer_seeds,
),
metadata_data,
true,
true,
None,
)?;
// Create master edition
create_master_edition_v3(
CpiContext::new_with_signer(
ctx.accounts.metadata_program.to_account_info(),
CreateMasterEditionV3 {
edition: ctx.accounts.nft_master_edition.to_account_info(),
mint: ctx.accounts.nft_mint.to_account_info(),
update_authority: ctx.accounts.config.to_account_info(),
mint_authority: ctx.accounts.config.to_account_info(),
payer: ctx.accounts.minter.to_account_info(),
metadata: ctx.accounts.nft_metadata.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
rent: ctx.accounts.rent.to_account_info(),
},
signer_seeds,
),
Some(0),
)?;
// Update state
let config = &mut ctx.accounts.config;
config.items_minted += 1;
let mint_tracker = &mut ctx.accounts.mint_tracker;
mint_tracker.count += 1;
emit!(NftMinted {
config: ctx.accounts.config.key(),
mint: ctx.accounts.nft_mint.key(),
minter: ctx.accounts.minter.key(),
number: nft_number,
is_whitelist,
});
Ok(())
}
/// Update collection configuration
pub fn update_config(
ctx: Context<UpdateConfig>,
new_price: Option<u64>,
new_start_time: Option<i64>,
new_base_uri: Option<String>,
) -> Result<()> {
let config = &mut ctx.accounts.config;
if let Some(price) = new_price {
config.price = price;
}
if let Some(time) = new_start_time {
config.start_time = time;
}
if let Some(uri) = new_base_uri {
config.base_uri = uri;
}
Ok(())
}
/// Pause/unpause minting
pub fn set_paused(ctx: Context<UpdateConfig>, paused: bool) -> Result<()> {
ctx.accounts.config.paused = paused;
Ok(())
}
/// Withdraw funds from treasury
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
**ctx.accounts.treasury.to_account_info().try_borrow_mut_lamports()? -= amount;
**ctx.accounts.authority.to_account_info().try_borrow_mut_lamports()? += amount;
Ok(())
}
}
// Merkle proof verification
fn verify_proof(proof: &[[u8; 32]], root: &[u8; 32], leaf: &[u8; 32]) -> bool {
let mut computed_hash = *leaf;
for proof_element in proof.iter() {
if computed_hash <= *proof_element {
computed_hash = keccak::hashv(&[&computed_hash, proof_element]).0;
} else {
computed_hash = keccak::hashv(&[proof_element, &computed_hash]).0;
}
}
computed_hash == *root
}
// Accounts
#[derive(Accounts)]
pub struct InitializeCollection<'info> {
#[account(
init,
payer = authority,
space = 8 + CollectionConfig::INIT_SPACE,
seeds = [b"config", authority.key().as_ref()],
bump
)]
pub config: Account<'info, CollectionConfig>,
#[account(
init,
payer = authority,
mint::decimals = 0,
mint::authority = config,
mint::freeze_authority = config,
)]
pub collection_mint: Account<'info, Mint>,
#[account(
init,
payer = authority,
associated_token::mint = collection_mint,
associated_token::authority = authority,
)]
pub collection_token: Account<'info, TokenAccount>,
/// CHECK: Created by Metaplex
#[account(mut)]
pub collection_metadata: UncheckedAccount<'info>,
/// CHECK: Created by Metaplex
#[account(mut)]
pub collection_master_edition: UncheckedAccount<'info>,
/// CHECK: Treasury address
pub treasury: UncheckedAccount<'info>,
#[account(mut)]
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub metadata_program: Program<'info, Metadata>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
#[derive(Accounts)]
pub struct MintNft<'info> {
#[account(
mut,
seeds = [b"config", config.authority.as_ref()],
bump = config.bump,
)]
pub config: Account<'info, CollectionConfig>,
#[account(
init_if_needed,
payer = minter,
space = 8 + MintTracker::INIT_SPACE,
seeds = [b"tracker", config.key().as_ref(), minter.key().as_ref()],
bump
)]
pub mint_tracker: Account<'info, MintTracker>,
#[account(
init,
payer = minter,
mint::decimals = 0,
mint::authority = config,
mint::freeze_authority = config,
)]
pub nft_mint: Account<'info, Mint>,
#[account(
init,
payer = minter,
associated_token::mint = nft_mint,
associated_token::authority = minter,
)]
pub nft_token: Account<'info, TokenAccount>,
/// CHECK: Created by Metaplex
#[account(mut)]
pub nft_metadata: UncheckedAccount<'info>,
/// CHECK: Created by Metaplex
#[account(mut)]
pub nft_master_edition: UncheckedAccount<'info>,
/// CHECK: Verified in config
#[account(
mut,
address = config.treasury,
)]
pub treasury: UncheckedAccount<'info>,
#[account(mut)]
pub minter: Signer<'info>,
pub token_program: Program<'info, Token>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub metadata_program: Program<'info, Metadata>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
#[derive(Accounts)]
pub struct UpdateConfig<'info> {
#[account(
mut,
seeds = [b"config", config.authority.as_ref()],
bump = config.bump,
has_one = authority,
)]
pub config: Account<'info, CollectionConfig>,
pub authority: Signer<'info>,
}
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(
seeds = [b"config", config.authority.as_ref()],
bump = config.bump,
has_one = authority,
has_one = treasury,
)]
pub config: Account<'info, CollectionConfig>,
#[account(mut)]
/// CHECK: Verified via has_one
pub treasury: UncheckedAccount<'info>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
// State
#[account]
#[derive(InitSpace)]
pub struct CollectionConfig {
pub authority: Pubkey,
pub collection_mint: Pubkey,
pub treasury: Pubkey,
#[max_len(32)]
pub name: String,
#[max_len(10)]
pub symbol: String,
#[max_len(200)]
pub base_uri: String,
pub price: u64,
pub whitelist_price: u64,
pub max_supply: u32,
pub items_minted: u32,
pub start_time: i64,
pub whitelist_end_time: i64,
pub whitelist_root: [u8; 32],
pub royalty_basis_points: u16,
pub paused: bool,
pub bump: u8,
}
#[account]
#[derive(InitSpace)]
pub struct MintTracker {
pub count: u8,
}
// Events
#[event]
pub struct CollectionInitialized {
pub config: Pubkey,
pub collection_mint: Pubkey,
pub max_supply: u32,
pub price: u64,
}
#[event]
pub struct NftMinted {
pub config: Pubkey,
pub mint: Pubkey,
pub minter: Pubkey,
pub number: u32,
pub is_whitelist: bool,
}
// Errors
#[error_code]
pub enum NftError {
#[msg("Invalid max supply")]
InvalidMaxSupply,
#[msg("Invalid price")]
InvalidPrice,
#[msg("Invalid royalty (max 100%)")]
InvalidRoyalty,
#[msg("Minting is paused")]
MintingPaused,
#[msg("Collection is sold out")]
SoldOut,
#[msg("Minting has not started")]
MintNotStarted,
#[msg("Not on whitelist")]
NotWhitelisted,
#[msg("Wallet mint limit exceeded")]
WalletLimitExceeded,
}
TypeScript Client
TypeScript
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { NftMinting } from "../target/types/nft_minting";
import * as token from "@solana/spl-token";
import {
Metaplex,
keypairIdentity,
bundlrStorage,
} from "@metaplex-foundation/js";
import { MerkleTree } from "merkletreejs";
import { keccak256 } from "js-sha3";
export class NftMintingClient {
private metaplex: Metaplex;
constructor(
private program: Program<NftMinting>,
private provider: anchor.AnchorProvider,
) {
this.metaplex = Metaplex.make(provider.connection)
.use(keypairIdentity((provider.wallet as anchor.Wallet).payer))
.use(bundlrStorage());
}
async initializeCollection(
name: string,
symbol: string,
uri: string,
price: anchor.BN,
maxSupply: number,
startTime: anchor.BN,
whitelistPrice: anchor.BN,
whitelistEndTime: anchor.BN,
royaltyBasisPoints: number,
treasury: anchor.web3.PublicKey,
): Promise<{
config: anchor.web3.PublicKey;
collectionMint: anchor.web3.PublicKey;
}> {
const authority = this.provider.wallet.publicKey;
const [configPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("config"), authority.toBuffer()],
this.program.programId,
);
const collectionMint = anchor.web3.Keypair.generate();
const collectionToken = await token.getAssociatedTokenAddress(
collectionMint.publicKey,
authority,
);
const [collectionMetadata] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("metadata"),
new anchor.web3.PublicKey(
"metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s",
).toBuffer(),
collectionMint.publicKey.toBuffer(),
],
new anchor.web3.PublicKey("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"),
);
const [collectionMasterEdition] =
anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("metadata"),
new anchor.web3.PublicKey(
"metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s",
).toBuffer(),
collectionMint.publicKey.toBuffer(),
Buffer.from("edition"),
],
new anchor.web3.PublicKey(
"metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s",
),
);
await this.program.methods
.initializeCollection(
name,
symbol,
uri,
price,
maxSupply,
startTime,
whitelistPrice,
whitelistEndTime,
royaltyBasisPoints,
)
.accounts({
config: configPda,
collectionMint: collectionMint.publicKey,
collectionToken,
collectionMetadata,
collectionMasterEdition,
treasury,
authority,
tokenProgram: token.TOKEN_PROGRAM_ID,
associatedTokenProgram: token.ASSOCIATED_TOKEN_PROGRAM_ID,
metadataProgram: new anchor.web3.PublicKey(
"metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s",
),
systemProgram: anchor.web3.SystemProgram.programId,
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
})
.signers([collectionMint])
.rpc();
return {
config: configPda,
collectionMint: collectionMint.publicKey,
};
}
async mintNft(
config: anchor.web3.PublicKey,
name: string,
uri: string,
whitelistProof?: Buffer[],
): Promise<anchor.web3.PublicKey> {
const configData =
await this.program.account.collectionConfig.fetch(config);
const minter = this.provider.wallet.publicKey;
const [trackerPda] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("tracker"), config.toBuffer(), minter.toBuffer()],
this.program.programId,
);
const nftMint = anchor.web3.Keypair.generate();
const nftToken = await token.getAssociatedTokenAddress(
nftMint.publicKey,
minter,
);
const [nftMetadata] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("metadata"),
new anchor.web3.PublicKey(
"metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s",
).toBuffer(),
nftMint.publicKey.toBuffer(),
],
new anchor.web3.PublicKey("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"),
);
const [nftMasterEdition] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("metadata"),
new anchor.web3.PublicKey(
"metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s",
).toBuffer(),
nftMint.publicKey.toBuffer(),
Buffer.from("edition"),
],
new anchor.web3.PublicKey("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"),
);
const proof = whitelistProof?.map((p) => Array.from(p)) ?? null;
await this.program.methods
.mintNft(name, uri, proof)
.accounts({
config,
mintTracker: trackerPda,
nftMint: nftMint.publicKey,
nftToken,
nftMetadata,
nftMasterEdition,
treasury: configData.treasury,
minter,
tokenProgram: token.TOKEN_PROGRAM_ID,
associatedTokenProgram: token.ASSOCIATED_TOKEN_PROGRAM_ID,
metadataProgram: new anchor.web3.PublicKey(
"metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s",
),
systemProgram: anchor.web3.SystemProgram.programId,
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
})
.signers([nftMint])
.rpc();
return nftMint.publicKey;
}
}
// Whitelist utilities
export function createMerkleTree(addresses: anchor.web3.PublicKey[]) {
const leaves = addresses.map((addr) =>
Buffer.from(keccak256(addr.toBuffer()), "hex"),
);
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
const root = tree.getRoot();
return {
tree,
root: Array.from(root) as number[],
getProof: (address: anchor.web3.PublicKey) => {
const leaf = Buffer.from(keccak256(address.toBuffer()), "hex");
return tree.getProof(leaf).map((p) => p.data);
},
};
}
Mint Phases
| Phase | Duration | Price | Limit | Access |
|---|---|---|---|---|
| Whitelist | Pre-launch | WL Price | 2 per wallet | Merkle proof |
| Public | Post-WL | Full Price | 5 per wallet | Anyone |
Metadata Structure
JSON
{
"name": "Collection Name #1",
"symbol": "COLL",
"description": "A unique NFT from the collection",
"seller_fee_basis_points": 500,
"image": "https://arweave.net/...",
"external_url": "https://example.com",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Rarity", "value": "Legendary" }
],
"properties": {
"files": [{ "uri": "...", "type": "image/png" }],
"category": "image",
"creators": [{ "address": "...", "share": 100 }]
}
}
Next: Data Indexing - Indexing and querying on-chain data.