11. NFT Implementation

Learn how to build a simple NFT system with collections, minting, and marketplace features.

Sources:


NFT State Structures

Collection Account

#[account]
pub struct NftCollection {
    pub authority: Pubkey,
    pub collection_mint: Pubkey,
    pub name: String,
    pub symbol: String,
    pub uri: String,
    pub seller_fee_basis_points: u16,  // Royalty (e.g., 500 = 5%)
    pub total_supply: u64,             // 0 = unlimited
    pub minted_count: u64,
    pub is_mutable: bool,
    pub created_at: i64,
    pub bump: u8,
}

impl NftCollection {
    pub const MAX_NAME_LENGTH: usize = 32;
    pub const MAX_SYMBOL_LENGTH: usize = 10;
    pub const MAX_URI_LENGTH: usize = 200;
    
    pub const LEN: usize = 8  // discriminator
        + 32  // authority
        + 32  // collection_mint
        + 4 + Self::MAX_NAME_LENGTH
        + 4 + Self::MAX_SYMBOL_LENGTH
        + 4 + Self::MAX_URI_LENGTH
        + 2   // seller_fee_basis_points
        + 8   // total_supply
        + 8   // minted_count
        + 1   // is_mutable
        + 8   // created_at
        + 1;  // bump
}

NFT Metadata

#[account]
pub struct NftMetadata {
    pub mint: Pubkey,
    pub collection: Pubkey,
    pub owner: Pubkey,
    pub name: String,
    pub symbol: String,
    pub uri: String,
    pub creators: Vec<Creator>,
    pub is_mutable: bool,
    pub bump: u8,
}

#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
pub struct Creator {
    pub address: Pubkey,
    pub verified: bool,
    pub share: u8,  // Percentage (0-100)
}

NFT Listing (Marketplace)

#[account]
pub struct NftListing {
    pub seller: Pubkey,
    pub nft_mint: Pubkey,
    pub nft_token_account: Pubkey,
    pub price: u64,
    pub currency_mint: Option<Pubkey>,  // None = SOL
    pub listed_at: i64,
    pub expires_at: Option<i64>,
    pub bump: u8,
}

impl NftListing {
    pub const LEN: usize = 8 + 32 + 32 + 32 + 8 + 1 + 32 + 8 + 1 + 8 + 1;
    
    pub fn is_expired(&self, current_timestamp: i64) -> bool {
        if let Some(expires_at) = self.expires_at {
            current_timestamp > expires_at
        } else {
            false
        }
    }
}

Create Collection

#[derive(Accounts)]
pub struct CreateCollection<'info> {
    #[account(
        init,
        payer = authority,
        space = NftCollection::LEN,
        seeds = [SEED_NFT_COLLECTION, collection_mint.key().as_ref()],
        bump
    )]
    pub collection: Account<'info, NftCollection>,

    pub collection_mint: Account<'info, Mint>,

    #[account(mut)]
    pub authority: Signer<'info>,

    pub system_program: Program<'info, System>,
}

pub fn create_collection_handler(
    ctx: Context<CreateCollection>,
    name: String,
    symbol: String,
    uri: String,
    seller_fee_basis_points: u16,
    total_supply: u64,
    is_mutable: bool,
) -> Result<()> {
    // Validate inputs
    require!(
        name.len() <= NftCollection::MAX_NAME_LENGTH,
        ErrorCode::NameTooLong
    );
    require!(
        symbol.len() <= NftCollection::MAX_SYMBOL_LENGTH,
        ErrorCode::SymbolTooLong
    );
    require!(
        uri.len() <= NftCollection::MAX_URI_LENGTH,
        ErrorCode::UriTooLong
    );
    require!(
        seller_fee_basis_points <= 10000,
        ErrorCode::InvalidRoyalty
    );

    let collection = &mut ctx.accounts.collection;
    let clock = Clock::get()?;

    collection.authority = ctx.accounts.authority.key();
    collection.collection_mint = ctx.accounts.collection_mint.key();
    collection.name = name;
    collection.symbol = symbol;
    collection.uri = uri;
    collection.seller_fee_basis_points = seller_fee_basis_points;
    collection.total_supply = total_supply;
    collection.minted_count = 0;
    collection.is_mutable = is_mutable;
    collection.created_at = clock.unix_timestamp;
    collection.bump = ctx.bumps.collection;

    msg!("Collection created: {}", collection.name);
    Ok(())
}

Mint NFT

#[derive(Accounts)]
pub struct MintNft<'info> {
    #[account(
        mut,
        seeds = [SEED_NFT_COLLECTION, collection.collection_mint.as_ref()],
        bump = collection.bump,
        constraint = collection.authority == authority.key() @ ErrorCode::Unauthorized
    )]
    pub collection: Account<'info, NftCollection>,

    #[account(
        init,
        payer = authority,
        space = NftMetadata::LEN,
        seeds = [SEED_NFT_METADATA, nft_mint.key().as_ref()],
        bump
    )]
    pub nft_metadata: Account<'info, NftMetadata>,

    #[account(
        init,
        payer = authority,
        mint::decimals = 0,  // NFT has 0 decimals
        mint::authority = authority,
        mint::token_program = token_program,
    )]
    pub nft_mint: Account<'info, Mint>,

    #[account(
        init_if_needed,
        payer = authority,
        associated_token::mint = nft_mint,
        associated_token::authority = recipient,
        associated_token::token_program = token_program,
    )]
    pub recipient_token_account: Account<'info, TokenAccount>,

    /// CHECK: NFT recipient
    pub recipient: AccountInfo<'info>,

    #[account(mut)]
    pub authority: Signer<'info>,

    pub token_program: Program<'info, Token>,
    pub associated_token_program: Program<'info, AssociatedToken>,
    pub system_program: Program<'info, System>,
}

pub fn mint_nft_handler(
    ctx: Context<MintNft>,
    name: String,
    uri: String,
    creators: Vec<Creator>,
) -> Result<()> {
    let collection = &mut ctx.accounts.collection;

    // Check supply limit
    require!(
        collection.total_supply == 0 || collection.minted_count < collection.total_supply,
        ErrorCode::SupplyExceeded
    );

    // Validate creators share totals 100%
    let total_share: u16 = creators.iter().map(|c| c.share as u16).sum();
    require!(total_share == 100, ErrorCode::InvalidCreatorShares);

    // Setup metadata
    let nft_metadata = &mut ctx.accounts.nft_metadata;
    nft_metadata.mint = ctx.accounts.nft_mint.key();
    nft_metadata.collection = collection.key();
    nft_metadata.owner = ctx.accounts.recipient.key();
    nft_metadata.name = name;
    nft_metadata.symbol = collection.symbol.clone();
    nft_metadata.uri = uri;
    nft_metadata.creators = creators;
    nft_metadata.is_mutable = collection.is_mutable;
    nft_metadata.bump = ctx.bumps.nft_metadata;

    // Increment minted count
    collection.minted_count = collection
        .minted_count
        .checked_add(1)
        .ok_or(ErrorCode::ArithmeticOverflow)?;

    // Mint 1 token to recipient (NFT = 1 token with 0 decimals)
    token::mint_to(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            MintTo {
                mint: ctx.accounts.nft_mint.to_account_info(),
                to: ctx.accounts.recipient_token_account.to_account_info(),
                authority: ctx.accounts.authority.to_account_info(),
            },
        ),
        1,  // NFT = exactly 1 token
    )?;

    msg!("NFT minted: {} to {}", nft_metadata.name, nft_metadata.owner);
    Ok(())
}

List NFT for Sale

#[derive(Accounts)]
pub struct ListNft<'info> {
    #[account(
        init,
        payer = seller,
        space = NftListing::LEN,
        seeds = [
            SEED_NFT_LISTING,
            nft_mint.key().as_ref(),
            seller.key().as_ref()
        ],
        bump
    )]
    pub listing: Account<'info, NftListing>,

    pub nft_mint: Account<'info, Mint>,

    #[account(
        constraint = nft_token_account.owner == seller.key() @ ErrorCode::NotNftOwner,
        constraint = nft_token_account.mint == nft_mint.key() @ ErrorCode::InvalidMint,
        constraint = nft_token_account.amount == 1 @ ErrorCode::InvalidAmount
    )]
    pub nft_token_account: Account<'info, TokenAccount>,

    #[account(mut)]
    pub seller: Signer<'info>,

    pub system_program: Program<'info, System>,
}

pub fn list_nft_handler(
    ctx: Context<ListNft>,
    price: u64,
    currency_mint: Option<Pubkey>,
    expires_at: Option<i64>,
) -> Result<()> {
    require!(price > 0, ErrorCode::InvalidAmount);

    let listing = &mut ctx.accounts.listing;
    let clock = Clock::get()?;

    listing.seller = ctx.accounts.seller.key();
    listing.nft_mint = ctx.accounts.nft_mint.key();
    listing.nft_token_account = ctx.accounts.nft_token_account.key();
    listing.price = price;
    listing.currency_mint = currency_mint;
    listing.listed_at = clock.unix_timestamp;
    listing.expires_at = expires_at;
    listing.bump = ctx.bumps.listing;

    msg!("NFT listed for {} lamports", price);
    Ok(())
}

Buy NFT

#[derive(Accounts)]
pub struct BuyNft<'info> {
    #[account(
        mut,
        close = seller,  // Close listing after sale
        seeds = [
            SEED_NFT_LISTING,
            listing.nft_mint.as_ref(),
            listing.seller.as_ref()
        ],
        bump = listing.bump
    )]
    pub listing: Account<'info, NftListing>,

    #[account(
        mut,
        seeds = [SEED_NFT_METADATA, nft_mint.key().as_ref()],
        bump = nft_metadata.bump
    )]
    pub nft_metadata: Account<'info, NftMetadata>,

    pub nft_mint: Account<'info, Mint>,

    #[account(mut)]
    pub seller_nft_account: Account<'info, TokenAccount>,

    #[account(
        init_if_needed,
        payer = buyer,
        associated_token::mint = nft_mint,
        associated_token::authority = buyer,
    )]
    pub buyer_nft_account: Account<'info, TokenAccount>,

    #[account(mut)]
    /// CHECK: Seller receives payment
    pub seller: AccountInfo<'info>,

    #[account(mut)]
    pub buyer: Signer<'info>,

    pub token_program: Program<'info, Token>,
    pub associated_token_program: Program<'info, AssociatedToken>,
    pub system_program: Program<'info, System>,
}

pub fn buy_nft_handler(ctx: Context<BuyNft>) -> Result<()> {
    let listing = &ctx.accounts.listing;
    let clock = Clock::get()?;

    // Check not expired
    require!(
        !listing.is_expired(clock.unix_timestamp),
        ErrorCode::ListingExpired
    );

    // Only SOL payments in this example
    require!(listing.currency_mint.is_none(), ErrorCode::InvalidMint);

    // Transfer SOL from buyer to seller
    **ctx.accounts.buyer.try_borrow_mut_lamports()? -= listing.price;
    **ctx.accounts.seller.try_borrow_mut_lamports()? += listing.price;

    // Transfer NFT from seller to buyer
    token::transfer(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.seller_nft_account.to_account_info(),
                to: ctx.accounts.buyer_nft_account.to_account_info(),
                authority: ctx.accounts.seller.to_account_info(),
            },
        ),
        1,  // NFT = 1 token
    )?;

    // Update ownership in metadata
    let nft_metadata = &mut ctx.accounts.nft_metadata;
    nft_metadata.owner = ctx.accounts.buyer.key();

    msg!("NFT sold for {} lamports", listing.price);
    Ok(())
}

Cancel Listing

#[derive(Accounts)]
pub struct CancelListing<'info> {
    #[account(
        mut,
        close = seller,
        seeds = [
            SEED_NFT_LISTING,
            listing.nft_mint.as_ref(),
            seller.key().as_ref()
        ],
        bump = listing.bump,
        constraint = listing.seller == seller.key() @ ErrorCode::Unauthorized
    )]
    pub listing: Account<'info, NftListing>,

    #[account(mut)]
    pub seller: Signer<'info>,
}

pub fn cancel_listing_handler(ctx: Context<CancelListing>) -> Result<()> {
    msg!("Listing cancelled");
    Ok(())
}

Client-Side (TypeScript)

Create Collection

const collectionMint = Keypair.generate();

await program.methods
  .createCollection(
    "My NFT Collection",
    "MNFT",
    "https://arweave.net/collection-metadata",
    500,  // 5% royalty
    new BN(10000),  // Max supply
    true  // is_mutable
  )
  .accounts({
    collection: collectionPda,
    collectionMint: collectionMint.publicKey,
    authority: creator.publicKey,
    systemProgram: SystemProgram.programId,
  })
  .signers([creator, collectionMint])
  .rpc();

Mint NFT

const nftMint = Keypair.generate();

const creators = [
  {
    address: creator.publicKey,
    verified: true,
    share: 100,
  },
];

await program.methods
  .mintNft(
    "Cool NFT #1",
    "https://arweave.net/nft-metadata",
    creators
  )
  .accounts({
    collection: collectionPda,
    nftMetadata: nftMetadataPda,
    nftMint: nftMint.publicKey,
    recipientTokenAccount: recipientAta,
    recipient: recipient.publicKey,
    authority: creator.publicKey,
    tokenProgram: TOKEN_PROGRAM_ID,
    associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
    systemProgram: SystemProgram.programId,
  })
  .signers([creator, nftMint])
  .rpc();

List NFT

const price = 5 * LAMPORTS_PER_SOL; // 5 SOL
const expiresAt = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; // 7 days

await program.methods
  .listNft(new BN(price), null, new BN(expiresAt))
  .accounts({
    listing: listingPda,
    nftMint: nftMint.publicKey,
    nftTokenAccount: sellerNftAta,
    seller: seller.publicKey,
    systemProgram: SystemProgram.programId,
  })
  .signers([seller])
  .rpc();

Buy NFT

await program.methods
  .buyNft()
  .accounts({
    listing: listingPda,
    nftMetadata: nftMetadataPda,
    nftMint: nftMint.publicKey,
    sellerNftAccount: sellerNftAta,
    buyerNftAccount: buyerNftAta,
    seller: seller.publicKey,
    buyer: buyer.publicKey,
    tokenProgram: TOKEN_PROGRAM_ID,
    associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
    systemProgram: SystemProgram.programId,
  })
  .signers([buyer])
  .rpc();

Best Practices

NFT = 0 decimals - Always use mint::decimals = 0
Verify ownership - Check token account owner before transfers
Validate creators - Ensure shares add up to 100%
Check expiration - Prevent buying expired listings
Close listings - Reclaim rent after sale/cancel

Don’t skip supply checks - Prevent over-minting
Don’t forget royalties - Track seller fees
Don’t allow 0 price - Validate listing prices


Next: Testing Patterns