Skip to content

Module 6: DApp 2 - NFT Marketplace

Learning Objectives

By the end of this module, you will:

  • Understand NFT standards on Solana (Metaplex Token Metadata)
  • Create and manage NFTs with Anchor
  • Build a marketplace with listing and buying mechanics
  • Implement royalty enforcement for creators
  • Handle collection verification and metadata

Prerequisites

Complete Module 5: Token & Escrow before starting this module.


Understanding NFTs on Solana

What Makes an NFT?

An NFT (Non-Fungible Token) is a unique digital asset. On Solana, an NFT is simply an SPL token with:

  1. Supply of 1: Only one token exists
  2. 0 decimals: Cannot be divided
  3. Metadata: Name, symbol, image, attributes
┌─────────────────────────────────────────────────────────────┐
│                      NFT Structure                           │
├─────────────────────────────────────────────────────────────┤
│  Mint Account          │  Metadata Account                  │
│  ───────────────────   │  ───────────────────────────────   │
│  supply: 1             │  name: "Cool NFT #1"               │
│  decimals: 0           │  symbol: "COOL"                    │
│  mint_authority: null  │  uri: "https://..."                │
│                        │  creators: [...]                   │
│                        │  seller_fee_basis_points: 500      │
└─────────────────────────────────────────────────────────────┘

Metaplex Token Metadata Standard

Metaplex provides the standard for NFT metadata on Solana. Key accounts:

Account Purpose
Metadata Stores on-chain metadata (name, symbol, URI, creators)
Master Edition Marks as NFT, controls print editions
Edition For print editions (copies of master)
Collection Groups NFTs together

Metadata Account Structure

pub struct Metadata {
    pub key: Key,
    pub update_authority: Pubkey,
    pub mint: Pubkey,
    pub data: Data,
    pub primary_sale_happened: bool,
    pub is_mutable: bool,
    pub edition_nonce: Option<u8>,
    pub token_standard: Option<TokenStandard>,
    pub collection: Option<Collection>,
    pub uses: Option<Uses>,
    pub collection_details: Option<CollectionDetails>,
    pub programmable_config: Option<ProgrammableConfig>,
}

pub struct Data {
    pub name: String,
    pub symbol: String,
    pub uri: String,
    pub seller_fee_basis_points: u16,
    pub creators: Option<Vec<Creator>>,
}

pub struct Creator {
    pub address: Pubkey,
    pub verified: bool,
    pub share: u8,  // Percentage (0-100)
}

Token Standards

Metaplex supports multiple token standards:

Standard Description Use Case
NonFungible Classic NFT (1/1) Art, collectibles
FungibleAsset Fungible with metadata Gaming items
Fungible Standard fungible Tokens
NonFungibleEdition Print edition Limited prints
ProgrammableNonFungible pNFT with rules Royalty enforcement

Creating NFTs with Anchor

Dependencies

Add Metaplex to your Anchor project:

[dependencies]
anchor-lang = { version = "0.30.1", features = ["init-if-needed"] }
anchor-spl = { version = "0.30.1", features = ["token", "metadata"] }
mpl-token-metadata = "4.1.2"

Mint NFT Instruction

use anchor_lang::prelude::*;
use anchor_spl::{
    associated_token::AssociatedToken,
    metadata::{
        create_master_edition_v3, create_metadata_accounts_v3,
        CreateMasterEditionV3, CreateMetadataAccountsV3, Metadata,
    },
    token::{mint_to, Mint, MintTo, Token, TokenAccount},
};
use mpl_token_metadata::types::{Creator, DataV2};

#[derive(Accounts)]
pub struct MintNft<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,

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

    /// The token account to hold the NFT
    #[account(
        init,
        payer = payer,
        associated_token::mint = mint,
        associated_token::authority = payer,
    )]
    pub token_account: Account<'info, TokenAccount>,

    /// CHECK: Metaplex metadata account (created by CPI)
    #[account(mut)]
    pub metadata: UncheckedAccount<'info>,

    /// CHECK: Metaplex master edition account (created by CPI)
    #[account(mut)]
    pub master_edition: UncheckedAccount<'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>,
}

pub fn mint_nft(
    ctx: Context<MintNft>,
    name: String,
    symbol: String,
    uri: String,
    seller_fee_basis_points: u16,
) -> Result<()> {
    // 1. Mint one token to the token account
    mint_to(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            MintTo {
                mint: ctx.accounts.mint.to_account_info(),
                to: ctx.accounts.token_account.to_account_info(),
                authority: ctx.accounts.payer.to_account_info(),
            },
        ),
        1, // Mint exactly 1 token
    )?;

    // 2. Create metadata account
    let creators = vec![Creator {
        address: ctx.accounts.payer.key(),
        verified: true,
        share: 100,
    }];

    create_metadata_accounts_v3(
        CpiContext::new(
            ctx.accounts.metadata_program.to_account_info(),
            CreateMetadataAccountsV3 {
                metadata: ctx.accounts.metadata.to_account_info(),
                mint: ctx.accounts.mint.to_account_info(),
                mint_authority: ctx.accounts.payer.to_account_info(),
                payer: ctx.accounts.payer.to_account_info(),
                update_authority: ctx.accounts.payer.to_account_info(),
                system_program: ctx.accounts.system_program.to_account_info(),
                rent: ctx.accounts.rent.to_account_info(),
            },
        ),
        DataV2 {
            name,
            symbol,
            uri,
            seller_fee_basis_points,
            creators: Some(creators),
            collection: None,
            uses: None,
        },
        true,  // is_mutable
        true,  // update_authority_is_signer
        None,  // collection_details
    )?;

    // 3. Create master edition (makes it a true NFT)
    create_master_edition_v3(
        CpiContext::new(
            ctx.accounts.metadata_program.to_account_info(),
            CreateMasterEditionV3 {
                edition: ctx.accounts.master_edition.to_account_info(),
                mint: ctx.accounts.mint.to_account_info(),
                update_authority: ctx.accounts.payer.to_account_info(),
                mint_authority: ctx.accounts.payer.to_account_info(),
                payer: ctx.accounts.payer.to_account_info(),
                metadata: ctx.accounts.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(),
            },
        ),
        Some(0), // max_supply: 0 = no prints allowed
    )?;

    msg!("NFT minted: {}", ctx.accounts.mint.key());
    Ok(())
}

NFT Marketplace Architecture

Core Components

┌─────────────────────────────────────────────────────────────┐
│                    NFT Marketplace                           │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐  │
│  │  Marketplace │    │   Listing    │    │   Escrow     │  │
│  │   Config     │    │   (per NFT)  │    │   Vault      │  │
│  │              │    │              │    │              │  │
│  │ authority    │    │ seller       │    │ Holds NFT    │  │
│  │ fee_bps      │    │ mint         │    │ until sold   │  │
│  │ treasury     │    │ price        │    │ or delisted  │  │
│  └──────────────┘    └──────────────┘    └──────────────┘  │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Listing Lifecycle

┌────────────┐     list()      ┌────────────┐
│  Seller    │ ──────────────▶ │   Listed   │
│  Owns NFT  │                 │  (in vault)│
└────────────┘                 └────────────┘
                    ┌────────────────┼────────────────┐
                    │                │                │
                    ▼ buy()          ▼ delist()       ▼ update_price()
              ┌────────────┐   ┌────────────┐   ┌────────────┐
              │    Sold    │   │  Delisted  │   │   Listed   │
              │ NFT→Buyer  │   │  NFT→Seller│   │ (new price)│
              │ SOL→Seller │   │            │   │            │
              └────────────┘   └────────────┘   └────────────┘

State Accounts

use anchor_lang::prelude::*;

/// Global marketplace configuration
#[account]
#[derive(InitSpace)]
pub struct Marketplace {
    /// Authority that can update marketplace settings
    pub authority: Pubkey,

    /// Fee in basis points (e.g., 250 = 2.5%)
    pub fee_basis_points: u16,

    /// Treasury to receive marketplace fees
    pub treasury: Pubkey,

    /// Total number of listings created
    pub listing_count: u64,

    /// Total volume traded (in lamports)
    pub total_volume: u64,

    /// Bump for PDA
    pub bump: u8,
}

/// Individual NFT listing
#[account]
#[derive(InitSpace)]
pub struct Listing {
    /// Seller's wallet
    pub seller: Pubkey,

    /// NFT mint address
    pub mint: Pubkey,

    /// Listing price in lamports
    pub price: u64,

    /// When the listing was created
    pub created_at: i64,

    /// Associated marketplace
    pub marketplace: Pubkey,

    /// Bump for listing PDA
    pub bump: u8,

    /// Bump for escrow vault PDA
    pub vault_bump: u8,
}

Marketplace Instructions

Initialize Marketplace

#[derive(Accounts)]
pub struct InitializeMarketplace<'info> {
    #[account(
        init,
        payer = authority,
        space = 8 + Marketplace::INIT_SPACE,
        seeds = [b"marketplace", authority.key().as_ref()],
        bump,
    )]
    pub marketplace: Account<'info, Marketplace>,

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

    /// CHECK: Treasury to receive fees
    pub treasury: UncheckedAccount<'info>,

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

pub fn initialize_marketplace(
    ctx: Context<InitializeMarketplace>,
    fee_basis_points: u16,
) -> Result<()> {
    require!(fee_basis_points <= 10000, MarketplaceError::InvalidFee);

    let marketplace = &mut ctx.accounts.marketplace;
    marketplace.authority = ctx.accounts.authority.key();
    marketplace.fee_basis_points = fee_basis_points;
    marketplace.treasury = ctx.accounts.treasury.key();
    marketplace.listing_count = 0;
    marketplace.total_volume = 0;
    marketplace.bump = ctx.bumps.marketplace;

    msg!("Marketplace initialized with {}bps fee", fee_basis_points);
    Ok(())
}

List NFT

use anchor_spl::token::{self, Token, TokenAccount, Transfer};

#[derive(Accounts)]
pub struct ListNft<'info> {
    #[account(
        mut,
        seeds = [b"marketplace", marketplace.authority.as_ref()],
        bump = marketplace.bump,
    )]
    pub marketplace: Account<'info, Marketplace>,

    #[account(
        init,
        payer = seller,
        space = 8 + Listing::INIT_SPACE,
        seeds = [b"listing", marketplace.key().as_ref(), mint.key().as_ref()],
        bump,
    )]
    pub listing: Account<'info, Listing>,

    /// Escrow vault to hold the NFT
    #[account(
        init,
        payer = seller,
        seeds = [b"vault", listing.key().as_ref()],
        bump,
        token::mint = mint,
        token::authority = listing,
    )]
    pub vault: Account<'info, TokenAccount>,

    /// The NFT mint
    pub mint: Account<'info, token::Mint>,

    /// Seller's token account holding the NFT
    #[account(
        mut,
        constraint = seller_token_account.mint == mint.key(),
        constraint = seller_token_account.owner == seller.key(),
        constraint = seller_token_account.amount == 1 @ MarketplaceError::NotOwner,
    )]
    pub seller_token_account: Account<'info, TokenAccount>,

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

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

pub fn list_nft(ctx: Context<ListNft>, price: u64) -> Result<()> {
    require!(price > 0, MarketplaceError::InvalidPrice);

    let marketplace = &mut ctx.accounts.marketplace;
    let listing = &mut ctx.accounts.listing;

    // Initialize listing
    listing.seller = ctx.accounts.seller.key();
    listing.mint = ctx.accounts.mint.key();
    listing.price = price;
    listing.created_at = Clock::get()?.unix_timestamp;
    listing.marketplace = marketplace.key();
    listing.bump = ctx.bumps.listing;
    listing.vault_bump = ctx.bumps.vault;

    // Transfer NFT to vault
    let cpi_accounts = Transfer {
        from: ctx.accounts.seller_token_account.to_account_info(),
        to: ctx.accounts.vault.to_account_info(),
        authority: ctx.accounts.seller.to_account_info(),
    };
    token::transfer(
        CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_accounts),
        1,
    )?;

    marketplace.listing_count += 1;

    emit!(NftListed {
        listing: listing.key(),
        seller: listing.seller,
        mint: listing.mint,
        price,
    });

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

Buy NFT

use anchor_spl::associated_token::AssociatedToken;

#[derive(Accounts)]
pub struct BuyNft<'info> {
    #[account(
        mut,
        seeds = [b"marketplace", marketplace.authority.as_ref()],
        bump = marketplace.bump,
    )]
    pub marketplace: Account<'info, Marketplace>,

    #[account(
        mut,
        seeds = [b"listing", marketplace.key().as_ref(), mint.key().as_ref()],
        bump = listing.bump,
        close = seller,
    )]
    pub listing: Account<'info, Listing>,

    #[account(
        mut,
        seeds = [b"vault", listing.key().as_ref()],
        bump = listing.vault_bump,
    )]
    pub vault: Account<'info, TokenAccount>,

    pub mint: Account<'info, token::Mint>,

    /// CHECK: Metadata account for royalty info
    pub metadata: UncheckedAccount<'info>,

    /// Buyer's token account (created if needed)
    #[account(
        init_if_needed,
        payer = buyer,
        associated_token::mint = mint,
        associated_token::authority = buyer,
    )]
    pub buyer_token_account: Account<'info, TokenAccount>,

    /// Seller receives payment
    /// CHECK: Validated by listing.seller
    #[account(
        mut,
        constraint = seller.key() == listing.seller @ MarketplaceError::InvalidSeller,
    )]
    pub seller: UncheckedAccount<'info>,

    /// Marketplace treasury receives fee
    /// CHECK: Validated by marketplace.treasury
    #[account(
        mut,
        constraint = treasury.key() == marketplace.treasury @ MarketplaceError::InvalidTreasury,
    )]
    pub treasury: UncheckedAccount<'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(ctx: Context<BuyNft>) -> Result<()> {
    let listing = &ctx.accounts.listing;
    let marketplace = &mut ctx.accounts.marketplace;
    let price = listing.price;

    // Prevent buying your own listing
    require!(
        ctx.accounts.buyer.key() != listing.seller,
        MarketplaceError::CannotBuyOwnListing
    );

    // Calculate fees
    let marketplace_fee = price
        .checked_mul(marketplace.fee_basis_points as u64)
        .ok_or(MarketplaceError::Overflow)?
        .checked_div(10000)
        .ok_or(MarketplaceError::Overflow)?;

    // Get royalty info from metadata (simplified)
    let royalty_fee = calculate_royalty(&ctx.accounts.metadata, price)?;

    let seller_amount = price
        .checked_sub(marketplace_fee)
        .ok_or(MarketplaceError::Overflow)?
        .checked_sub(royalty_fee)
        .ok_or(MarketplaceError::Overflow)?;

    // Transfer SOL: buyer -> seller
    anchor_lang::system_program::transfer(
        CpiContext::new(
            ctx.accounts.system_program.to_account_info(),
            anchor_lang::system_program::Transfer {
                from: ctx.accounts.buyer.to_account_info(),
                to: ctx.accounts.seller.to_account_info(),
            },
        ),
        seller_amount,
    )?;

    // Transfer SOL: buyer -> treasury (marketplace fee)
    if marketplace_fee > 0 {
        anchor_lang::system_program::transfer(
            CpiContext::new(
                ctx.accounts.system_program.to_account_info(),
                anchor_lang::system_program::Transfer {
                    from: ctx.accounts.buyer.to_account_info(),
                    to: ctx.accounts.treasury.to_account_info(),
                },
            ),
            marketplace_fee,
        )?;
    }

    // Transfer NFT: vault -> buyer
    let listing_key = listing.key();
    let seeds = &[
        b"listing",
        marketplace.key().as_ref(),
        listing.mint.as_ref(),
        &[listing.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    token::transfer(
        CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.vault.to_account_info(),
                to: ctx.accounts.buyer_token_account.to_account_info(),
                authority: ctx.accounts.listing.to_account_info(),
            },
            signer_seeds,
        ),
        1,
    )?;

    // Close vault
    token::close_account(CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        token::CloseAccount {
            account: ctx.accounts.vault.to_account_info(),
            destination: ctx.accounts.seller.to_account_info(),
            authority: ctx.accounts.listing.to_account_info(),
        },
        signer_seeds,
    ))?;

    // Update marketplace stats
    marketplace.total_volume = marketplace
        .total_volume
        .checked_add(price)
        .ok_or(MarketplaceError::Overflow)?;

    emit!(NftSold {
        listing: listing_key,
        seller: listing.seller,
        buyer: ctx.accounts.buyer.key(),
        mint: listing.mint,
        price,
        marketplace_fee,
        royalty_fee,
    });

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

fn calculate_royalty(_metadata: &UncheckedAccount, price: u64) -> Result<u64> {
    // In production, parse the metadata account to get seller_fee_basis_points
    // Assuming 5% royalty (500 basis points) as default
    let royalty_bps: u64 = 500;

    let royalty = price
        .checked_mul(royalty_bps)
        .ok_or(MarketplaceError::Overflow)?
        .checked_div(10000)
        .ok_or(MarketplaceError::Overflow)?;

    Ok(royalty)
}

Delist NFT

#[derive(Accounts)]
pub struct DelistNft<'info> {
    #[account(
        seeds = [b"marketplace", marketplace.authority.as_ref()],
        bump = marketplace.bump,
    )]
    pub marketplace: Account<'info, Marketplace>,

    #[account(
        mut,
        seeds = [b"listing", marketplace.key().as_ref(), mint.key().as_ref()],
        bump = listing.bump,
        constraint = listing.seller == seller.key() @ MarketplaceError::NotOwner,
        close = seller,
    )]
    pub listing: Account<'info, Listing>,

    #[account(
        mut,
        seeds = [b"vault", listing.key().as_ref()],
        bump = listing.vault_bump,
    )]
    pub vault: Account<'info, TokenAccount>,

    pub mint: Account<'info, token::Mint>,

    /// Seller's token account to receive NFT back
    #[account(
        mut,
        constraint = seller_token_account.mint == mint.key(),
        constraint = seller_token_account.owner == seller.key(),
    )]
    pub seller_token_account: Account<'info, TokenAccount>,

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

    pub token_program: Program<'info, Token>,
}

pub fn delist_nft(ctx: Context<DelistNft>) -> Result<()> {
    let listing = &ctx.accounts.listing;
    let marketplace = &ctx.accounts.marketplace;

    // Transfer NFT back to seller
    let seeds = &[
        b"listing",
        marketplace.key().as_ref(),
        listing.mint.as_ref(),
        &[listing.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    token::transfer(
        CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.vault.to_account_info(),
                to: ctx.accounts.seller_token_account.to_account_info(),
                authority: ctx.accounts.listing.to_account_info(),
            },
            signer_seeds,
        ),
        1,
    )?;

    // Close vault
    token::close_account(CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        token::CloseAccount {
            account: ctx.accounts.vault.to_account_info(),
            destination: ctx.accounts.seller.to_account_info(),
            authority: ctx.accounts.listing.to_account_info(),
        },
        signer_seeds,
    ))?;

    emit!(NftDelisted {
        listing: listing.key(),
        seller: listing.seller,
        mint: listing.mint,
    });

    msg!("NFT delisted");
    Ok(())
}

Error and Event Definitions

use anchor_lang::prelude::*;

#[error_code]
pub enum MarketplaceError {
    #[msg("Invalid marketplace fee (must be <= 10000 basis points)")]
    InvalidFee,

    #[msg("Invalid listing price (must be > 0)")]
    InvalidPrice,

    #[msg("Not the owner of this NFT")]
    NotOwner,

    #[msg("Cannot buy your own listing")]
    CannotBuyOwnListing,

    #[msg("Invalid seller account")]
    InvalidSeller,

    #[msg("Invalid treasury account")]
    InvalidTreasury,

    #[msg("Numerical overflow")]
    Overflow,
}

#[event]
pub struct NftListed {
    pub listing: Pubkey,
    pub seller: Pubkey,
    pub mint: Pubkey,
    pub price: u64,
}

#[event]
pub struct NftSold {
    pub listing: Pubkey,
    pub seller: Pubkey,
    pub buyer: Pubkey,
    pub mint: Pubkey,
    pub price: u64,
    pub marketplace_fee: u64,
    pub royalty_fee: u64,
}

#[event]
pub struct NftDelisted {
    pub listing: Pubkey,
    pub seller: Pubkey,
    pub mint: Pubkey,
}

Summary

Topic Key Concepts
NFT Structure Mint + Metadata + Master Edition
Metaplex Token Metadata standard, creators, royalties
Marketplace Listings, escrow vaults, fees
Royalties seller_fee_basis_points, creator shares
Collections Grouping, verification

Next Steps

Continue to Module 7: DeFi AMM to build an automated market maker.


Previous: Token & Escrow Next: DeFi AMM