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:
- Supply of 1: Only one token exists
- 0 decimals: Cannot be divided
- 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.