Web3 Development
How to Build an NFT Marketplace from Scratch
TL;DR
I have built NFT marketplace contracts for clients across Ethereum, Arbitrum, and Base. This guide covers the entire stack: a Solidity marketplace contract that handles fixed-price listings and English auctions, ERC-2981 royalty enforcement on every sale, a React frontend using wagmi and viem for wallet interactions, and IPFS for decentralized metadata storage. Every code example comes from real production contracts I have shipped. You will learn the architecture decisions, the security patterns that prevent exploits, and the testing strategy I run before mainnet deployment. No toy examples — this is the same process I follow when a client hires me to build a marketplace through my services.
Architecture Overview
Before writing any Solidity, you need to understand how the pieces fit together. An NFT marketplace is not one contract — it is a system of contracts and off-chain services working together.
Here is the architecture I use for production marketplace builds:
contracts/
core/
NFTMarketplace.sol # Listing, buying, bidding logic
RoyaltyEngine.sol # ERC-2981 royalty lookups and splits
interfaces/
INFTMarketplace.sol # External interface
IRoyaltyEngine.sol # Royalty interface
libraries/
OrderLib.sol # Listing/bid data structures
TransferLib.sol # Safe ETH and NFT transfers
frontend/
hooks/
useListNFT.ts # Listing transaction hook
useBuyNFT.ts # Purchase transaction hook
usePlaceBid.ts # Bidding transaction hook
components/
ListingCard.tsx # NFT listing display
BidPanel.tsx # Auction bidding UI
CreateListing.tsx # Listing creation form
services/
ipfs/
pinMetadata.ts # Pin JSON metadata to IPFS
pinImage.ts # Pin images to IPFS via Pinata
indexer/
subgraph.yaml # The Graph subgraph configThe marketplace contract is the core. It holds no NFTs — sellers approve the marketplace to transfer their NFTs, and the contract executes atomic swaps when a buyer pays. This is a non-custodial design, which means sellers keep their NFTs in their own wallets until the moment of sale.
The royalty engine queries ERC-2981 on the NFT contract to determine creator royalties. If the NFT contract does not implement ERC-2981, the marketplace falls back to zero royalties. Some marketplaces maintain their own royalty registry as a fallback, but I prefer on-chain standards because they are enforceable across every marketplace that respects the interface.
The frontend uses wagmi for wallet connection and contract interactions, with viem under the hood for ABI encoding and transaction building. The Graph indexes on-chain events so the frontend can query listings, sales history, and bid activity without scanning every block.
IPFS stores the NFT metadata and images. I use Pinata as the pinning service because their API is reliable and they offer dedicated gateways, but any IPFS pinning service works. The metadata follows the OpenSea metadata standard, which has become the de facto specification across all marketplaces.
Why this architecture? Because it separates concerns cleanly. The marketplace contract does not care what the NFT looks like or where its metadata lives. The frontend does not care how royalties are calculated. The IPFS layer does not care about on-chain state. Each piece can be upgraded, replaced, or extended independently.
The Marketplace Smart Contract
Here is the full marketplace contract. I will walk through every function afterward, but I want you to see the complete picture first.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {IERC2981} from "@openzeppelin/contracts/interfaces/IERC2981.sol";
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract NFTMarketplace is ReentrancyGuard, Ownable {
// ═══════════════════════════════════════════
// Types
// ═══════════════════════════════════════════
enum ListingType {
FixedPrice,
Auction
}
struct Listing {
address seller;
address nftContract;
uint256 tokenId;
uint256 price;
ListingType listingType;
uint256 auctionEndTime;
bool active;
}
struct Bid {
address bidder;
uint256 amount;
}
// ═══════════════════════════════════════════
// State
// ═══════════════════════════════════════════
uint256 private _listingCounter;
uint256 public platformFeeBps;
address public feeRecipient;
mapping(uint256 => Listing) public listings;
mapping(uint256 => Bid) public highestBids;
mapping(address => uint256) public pendingWithdrawals;
// ═══════════════════════════════════════════
// Events
// ═══════════════════════════════════════════
event Listed(
uint256 indexed listingId,
address indexed seller,
address indexed nftContract,
uint256 tokenId,
uint256 price,
ListingType listingType,
uint256 auctionEndTime
);
event Sale(
uint256 indexed listingId,
address indexed buyer,
uint256 price
);
event BidPlaced(
uint256 indexed listingId,
address indexed bidder,
uint256 amount
);
event AuctionSettled(
uint256 indexed listingId,
address indexed winner,
uint256 amount
);
event ListingCancelled(uint256 indexed listingId);
// ═══════════════════════════════════════════
// Errors
// ═══════════════════════════════════════════
error NotOwnerOfToken();
error NotApproved();
error ListingNotActive();
error InsufficientPayment();
error NotFixedPrice();
error NotAuction();
error AuctionNotEnded();
error AuctionAlreadyEnded();
error BidTooLow();
error NotSeller();
error NoBidsPlaced();
error TransferFailed();
error InvalidFeeBps();
error AuctionHasBids();
// ═══════════════════════════════════════════
// Constructor
// ═══════════════════════════════════════════
constructor(
uint256 _platformFeeBps,
address _feeRecipient
) Ownable(msg.sender) {
if (_platformFeeBps > 1000) revert InvalidFeeBps();
platformFeeBps = _platformFeeBps;
feeRecipient = _feeRecipient;
}
// ═══════════════════════════════════════════
// Listing Logic
// ═══════════════════════════════════════════
function createListing(
address nftContract,
uint256 tokenId,
uint256 price,
ListingType listingType,
uint256 auctionDuration
) external returns (uint256 listingId) {
if (IERC721(nftContract).ownerOf(tokenId) != msg.sender) {
revert NotOwnerOfToken();
}
if (!IERC721(nftContract).isApprovedForAll(msg.sender, address(this))) {
revert NotApproved();
}
listingId = _listingCounter++;
uint256 endTime = listingType == ListingType.Auction
? block.timestamp + auctionDuration
: 0;
listings[listingId] = Listing({
seller: msg.sender,
nftContract: nftContract,
tokenId: tokenId,
price: price,
listingType: listingType,
auctionEndTime: endTime,
active: true
});
emit Listed(
listingId,
msg.sender,
nftContract,
tokenId,
price,
listingType,
endTime
);
}
function cancelListing(uint256 listingId) external {
Listing storage listing = listings[listingId];
if (!listing.active) revert ListingNotActive();
if (listing.seller != msg.sender) revert NotSeller();
if (listing.listingType == ListingType.Auction) {
if (highestBids[listingId].amount > 0) {
revert AuctionHasBids();
}
}
listing.active = false;
emit ListingCancelled(listingId);
}
// ═══════════════════════════════════════════
// Buying Logic (Fixed Price)
// ═══════════════════════════════════════════
function buyNFT(
uint256 listingId
) external payable nonReentrant {
Listing storage listing = listings[listingId];
if (!listing.active) revert ListingNotActive();
if (listing.listingType != ListingType.FixedPrice) {
revert NotFixedPrice();
}
if (msg.value < listing.price) revert InsufficientPayment();
listing.active = false;
_executeSale(
listing.nftContract,
listing.tokenId,
listing.seller,
msg.sender,
listing.price
);
emit Sale(listingId, msg.sender, listing.price);
}
// ═══════════════════════════════════════════
// Bidding Logic (Auction)
// ═══════════════════════════════════════════
function placeBid(
uint256 listingId
) external payable nonReentrant {
Listing storage listing = listings[listingId];
if (!listing.active) revert ListingNotActive();
if (listing.listingType != ListingType.Auction) {
revert NotAuction();
}
if (block.timestamp >= listing.auctionEndTime) {
revert AuctionAlreadyEnded();
}
Bid storage currentBid = highestBids[listingId];
uint256 minBid = currentBid.amount == 0
? listing.price
: currentBid.amount + (currentBid.amount / 20); // 5% minimum increment
if (msg.value < minBid) revert BidTooLow();
// Refund previous bidder via pull pattern
if (currentBid.bidder != address(0)) {
pendingWithdrawals[currentBid.bidder] += currentBid.amount;
}
highestBids[listingId] = Bid({
bidder: msg.sender,
amount: msg.value
});
emit BidPlaced(listingId, msg.sender, msg.value);
}
function settleAuction(
uint256 listingId
) external nonReentrant {
Listing storage listing = listings[listingId];
if (!listing.active) revert ListingNotActive();
if (listing.listingType != ListingType.Auction) {
revert NotAuction();
}
if (block.timestamp < listing.auctionEndTime) {
revert AuctionNotEnded();
}
Bid memory winningBid = highestBids[listingId];
if (winningBid.bidder == address(0)) revert NoBidsPlaced();
listing.active = false;
_executeSale(
listing.nftContract,
listing.tokenId,
listing.seller,
winningBid.bidder,
winningBid.amount
);
emit AuctionSettled(
listingId,
winningBid.bidder,
winningBid.amount
);
}
function withdraw() external nonReentrant {
uint256 amount = pendingWithdrawals[msg.sender];
if (amount == 0) revert InsufficientPayment();
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = payable(msg.sender).call{value: amount}("");
if (!success) revert TransferFailed();
}
// ═══════════════════════════════════════════
// Internal: Sale Execution with Royalties
// ═══════════════════════════════════════════
function _executeSale(
address nftContract,
uint256 tokenId,
address seller,
address buyer,
uint256 salePrice
) internal {
// 1. Calculate platform fee
uint256 platformFee = (salePrice * platformFeeBps) / 10_000;
// 2. Calculate royalty (ERC-2981)
uint256 royaltyAmount;
address royaltyReceiver;
if (IERC165(nftContract).supportsInterface(type(IERC2981).interfaceId)) {
(royaltyReceiver, royaltyAmount) = IERC2981(nftContract)
.royaltyInfo(tokenId, salePrice);
}
// 3. Calculate seller proceeds
uint256 sellerProceeds = salePrice - platformFee - royaltyAmount;
// 4. Transfer NFT to buyer
IERC721(nftContract).safeTransferFrom(seller, buyer, tokenId);
// 5. Distribute payments
if (platformFee > 0) {
(bool feeSuccess, ) = payable(feeRecipient).call{
value: platformFee
}("");
if (!feeSuccess) revert TransferFailed();
}
if (royaltyAmount > 0 && royaltyReceiver != address(0)) {
(bool royaltySuccess, ) = payable(royaltyReceiver).call{
value: royaltyAmount
}("");
if (!royaltySuccess) revert TransferFailed();
}
(bool sellerSuccess, ) = payable(seller).call{
value: sellerProceeds
}("");
if (!sellerSuccess) revert TransferFailed();
}
// ═══════════════════════════════════════════
// Admin
// ═══════════════════════════════════════════
function updatePlatformFee(uint256 newFeeBps) external onlyOwner {
if (newFeeBps > 1000) revert InvalidFeeBps();
platformFeeBps = newFeeBps;
}
function updateFeeRecipient(address newRecipient) external onlyOwner {
feeRecipient = newRecipient;
}
}That is roughly 250 lines. Let me break down the design decisions.
Listing NFTs
The createListing function handles both fixed-price listings and auctions through a single entry point. The seller specifies the listing type, and the contract branches logic accordingly.
Two critical checks happen before a listing is created:
- Ownership verification. The contract calls
ownerOfon the NFT contract to confirm the seller actually owns the token. Without this check, anyone could list NFTs they do not own. - Approval verification. The contract checks
isApprovedForAllto confirm the marketplace has permission to transfer the NFT when a sale occurs. I useisApprovedForAllinstead ofgetApprovedbecause it covers all tokens in a single approval transaction, which is better UX.
Notice that the contract does not escrow the NFT. The seller keeps the NFT in their wallet, and the marketplace only transfers it at the moment of sale. This is the non-custodial pattern, and it is important for two reasons: sellers can still use their NFTs (display them, use them in games) while they are listed, and the marketplace contract holds no assets that could be drained in an exploit.
The listing counter uses a simple incrementing ID. Some marketplace contracts use a hash of the listing parameters as the ID, but I have found that sequential IDs are simpler to index with The Graph and easier for users to reference in support requests.
For auctions, the auctionDuration parameter is added to block.timestamp to calculate the end time. I have seen contracts use a fixed end time instead of a duration, which is error-prone because the seller might set a time in the past by mistake. Duration-based calculation prevents this entirely.
// Listing creation in the frontend
const { writeContract } = useWriteContract();
function handleCreateListing(
nftContract: Address,
tokenId: bigint,
priceInEth: string,
isAuction: boolean,
durationHours: number
) {
writeContract({
address: MARKETPLACE_ADDRESS,
abi: marketplaceAbi,
functionName: "createListing",
args: [
nftContract,
tokenId,
parseEther(priceInEth),
isAuction ? 1 : 0,
isAuction ? BigInt(durationHours * 3600) : 0n,
],
});
}Cancellation is straightforward, but notice the guard on auctions: once a bid has been placed, the seller cannot cancel. This protects bidders from sellers who cancel when the price does not go high enough. Some marketplaces allow auction cancellation with a penalty, but I have found that a hard no-cancel-after-bid rule creates more trust in the platform.
Buying and Bidding
The buying and bidding functions are the heart of the marketplace. They handle two fundamentally different sale mechanisms, and getting them wrong is where most marketplace exploits happen.
Fixed-price buying is the simpler path. The buyer sends ETH equal to or greater than the listing price, and the contract executes an atomic swap: NFT goes to buyer, ETH goes to seller (minus fees and royalties). The nonReentrant modifier is essential here because _executeSale makes multiple external calls.
The function marks the listing as inactive *before* making any external calls. This is the Checks-Effects-Interactions pattern, and violating it is the single most common source of reentrancy bugs in marketplace contracts. I have audited marketplace code where the listing was deactivated after the ETH transfer, which allowed a malicious seller contract to re-enter and drain the marketplace.
Auction bidding is more complex. The key design decisions:
- Minimum bid increment. I enforce a 5% minimum increment over the current highest bid. Without this, someone could outbid by 1 wei infinitely, wasting gas and griefing the auction. The 5% number comes from traditional auction houses and works well in practice.
- Pull-based refunds. When a new bid outbids the previous one, I do not immediately refund the previous bidder. Instead, I credit their
pendingWithdrawalsbalance and let them withdraw manually. This is the pull pattern, and it prevents a critical attack vector: if the previous bidder is a contract that reverts on receive, a push-based refund would permanently brick the auction (nobody could ever place a higher bid).
// Pull pattern — previous bidder withdraws manually
if (currentBid.bidder != address(0)) {
pendingWithdrawals[currentBid.bidder] += currentBid.amount;
}- Settlement is a separate function. Anyone can call
settleAuctionafter the auction ends — the seller, the winner, or a third party. This prevents the situation where a seller never settles because they are unhappy with the final price. In production, I usually set up a keeper bot that callssettleAuctionautomatically when the block timestamp exceeds the end time.
- No bid extension. Some marketplaces extend the auction if a bid comes in during the last few minutes (anti-sniping). I have not included it here to keep the contract focused, but in production I add a simple rule: if a bid comes in during the last 10 minutes, extend the end time by 10 minutes. Here is how that looks:
// Anti-sniping extension (add to placeBid)
uint256 EXTENSION_WINDOW = 10 minutes;
if (listing.auctionEndTime - block.timestamp < EXTENSION_WINDOW) {
listing.auctionEndTime = block.timestamp + EXTENSION_WINDOW;
}Royalty Enforcement
Royalty enforcement is one of the most debated topics in the NFT space. The short version: ERC-2981 is the on-chain standard for royalty info, but it is only enforceable if the marketplace chooses to respect it.
My position is simple: marketplaces that enforce royalties attract better creators, and better creators attract more buyers. Every marketplace I have built enforces ERC-2981 by default.
Here is how the royalty lookup works inside _executeSale:
// Check if the NFT contract supports ERC-2981
if (
IERC165(nftContract).supportsInterface(type(IERC2981).interfaceId)
) {
(royaltyReceiver, royaltyAmount) = IERC2981(nftContract).royaltyInfo(
tokenId,
salePrice
);
}The function first checks if the NFT contract implements the IERC2981 interface using ERC-165 interface detection. If it does, it calls royaltyInfo with the token ID and sale price to get the royalty recipient and amount.
A few things to watch for in production:
Royalty percentage caps. The ERC-2981 standard does not cap royalty percentages. A malicious NFT contract could return a 100% royalty, leaving the seller with nothing. I cap royalties at 10% in production:
// Cap royalties at 10%
uint256 maxRoyalty = salePrice / 10;
if (royaltyAmount > maxRoyalty) {
royaltyAmount = maxRoyalty;
}Royalty receiver validation. If royaltyReceiver is address(0), sending ETH to it burns the funds permanently. The contract already checks for this, but it is worth calling out because I have seen this bug in deployed marketplaces.
Gas considerations. The supportsInterface call and royaltyInfo call add roughly 5,000-8,000 gas to each sale. This is negligible on L2s but can add up on L1 Ethereum. For L1 deployments, I cache the interface support check in a mapping after the first lookup.
The payment split order matters for security. I always pay the platform fee first, then the royalty, then the seller. The seller gets whatever remains. This ensures that platform fees and creator royalties are always paid, even if the arithmetic leaves less for the seller than expected.
Sale Price: 1.0 ETH
Platform Fee (2.5%): 0.025 ETH -> Platform
Royalty (5%): 0.05 ETH -> Creator
Seller Proceeds: 0.925 ETH -> SellerFrontend with React and wagmi
The frontend connects users to the marketplace contract using wagmi for wallet management and viem for low-level Ethereum interactions. Here is the hook-based architecture I use.
First, the core marketplace hooks:
// hooks/useListNFT.ts
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseEther } from "viem";
import { marketplaceAbi, MARKETPLACE_ADDRESS } from "@/lib/contracts";
interface ListNFTParams {
nftContract: `0x${string}`;
tokenId: bigint;
priceEth: string;
isAuction: boolean;
durationHours?: number;
}
export function useListNFT() {
const { writeContract, data: hash, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } =
useWaitForTransactionReceipt({ hash });
function listNFT({
nftContract,
tokenId,
priceEth,
isAuction,
durationHours = 24,
}: ListNFTParams) {
writeContract({
address: MARKETPLACE_ADDRESS,
abi: marketplaceAbi,
functionName: "createListing",
args: [
nftContract,
tokenId,
parseEther(priceEth),
isAuction ? 1 : 0,
isAuction ? BigInt(durationHours * 3600) : 0n,
],
});
}
return { listNFT, isPending, isConfirming, isSuccess, hash };
}// hooks/useBuyNFT.ts
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseEther } from "viem";
import { marketplaceAbi, MARKETPLACE_ADDRESS } from "@/lib/contracts";
export function useBuyNFT() {
const { writeContract, data: hash, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } =
useWaitForTransactionReceipt({ hash });
function buyNFT(listingId: bigint, priceEth: string) {
writeContract({
address: MARKETPLACE_ADDRESS,
abi: marketplaceAbi,
functionName: "buyNFT",
args: [listingId],
value: parseEther(priceEth),
});
}
return { buyNFT, isPending, isConfirming, isSuccess, hash };
}// hooks/usePlaceBid.ts
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseEther } from "viem";
import { marketplaceAbi, MARKETPLACE_ADDRESS } from "@/lib/contracts";
export function usePlaceBid() {
const { writeContract, data: hash, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } =
useWaitForTransactionReceipt({ hash });
function placeBid(listingId: bigint, bidEth: string) {
writeContract({
address: MARKETPLACE_ADDRESS,
abi: marketplaceAbi,
functionName: "placeBid",
args: [listingId],
value: parseEther(bidEth),
});
}
return { placeBid, isPending, isConfirming, isSuccess, hash };
}Now the listing card component that ties it all together:
// components/ListingCard.tsx
"use client";
import { formatEther } from "viem";
import { useAccount } from "wagmi";
import { useBuyNFT } from "@/hooks/useBuyNFT";
import { usePlaceBid } from "@/hooks/usePlaceBid";
interface Listing {
id: bigint;
seller: `0x${string}`;
nftContract: `0x${string}`;
tokenId: bigint;
price: bigint;
listingType: number;
auctionEndTime: bigint;
active: boolean;
imageUrl: string;
name: string;
}
export function ListingCard({ listing }: { listing: Listing }) {
const { address } = useAccount();
const { buyNFT, isPending: isBuying } = useBuyNFT();
const { placeBid, isPending: isBidding } = usePlaceBid();
const isAuction = listing.listingType === 1;
const isOwner = address === listing.seller;
const auctionEnded =
isAuction && Date.now() / 1000 > Number(listing.auctionEndTime);
function handleBuy() {
buyNFT(listing.id, formatEther(listing.price));
}
function handleBid() {
const bidAmount = prompt("Enter bid amount in ETH:");
if (!bidAmount) return;
placeBid(listing.id, bidAmount);
}
return (
<div className="rounded-xl border border-white/10 bg-surface p-4">
<img
src={listing.imageUrl}
alt={listing.name}
className="aspect-square w-full rounded-lg object-cover"
/>
<div className="mt-4 space-y-2">
<h3 className="text-lg font-semibold text-white">
{listing.name}
</h3>
<p className="text-sm text-secondary">
{isAuction ? "Current bid" : "Price"}:{" "}
<span className="font-mono text-primary">
{formatEther(listing.price)} ETH
</span>
</p>
{isAuction && !auctionEnded && (
<p className="text-xs text-muted">
Ends:{" "}
{new Date(
Number(listing.auctionEndTime) * 1000
).toLocaleString()}
</p>
)}
{!isOwner && listing.active && (
<button
onClick={isAuction ? handleBid : handleBuy}
disabled={isBuying || isBidding || auctionEnded}
className="w-full rounded-lg bg-primary px-4 py-2 font-semibold
text-black transition-colors hover:bg-primary/90
disabled:cursor-not-allowed disabled:opacity-50"
>
{isBuying || isBidding
? "Confirming..."
: isAuction
? "Place Bid"
: "Buy Now"}
</button>
)}
</div>
</div>
);
}The wagmi configuration for the app:
// lib/wagmi.ts
import { http, createConfig } from "wagmi";
import { mainnet, arbitrum, base } from "wagmi/chains";
import { connectorsForWallets } from "@rainbow-me/rainbowkit";
import {
metaMaskWallet,
coinbaseWallet,
walletConnectWallet,
} from "@rainbow-me/rainbowkit/wallets";
const connectors = connectorsForWallets(
[
{
groupName: "Popular",
wallets: [metaMaskWallet, coinbaseWallet, walletConnectWallet],
},
],
{
appName: "NFT Marketplace",
projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!,
}
);
export const config = createConfig({
connectors,
chains: [mainnet, arbitrum, base],
transports: {
[mainnet.id]: http(),
[arbitrum.id]: http(),
[base.id]: http(),
},
});A few frontend patterns I always follow:
Transaction state management. Every write operation goes through three states: pending (waiting for wallet signature), confirming (waiting for on-chain confirmation), and success/error. The hooks above expose isPending, isConfirming, and isSuccess so the UI can show appropriate feedback at each stage. Never leave users staring at a button wondering if something happened.
Optimistic updates with revalidation. After a successful purchase, I invalidate the listing query cache immediately so the UI updates without waiting for the next poll. If you are using The Graph, this means re-fetching the subgraph query. If you are using direct RPC reads, invalidate the wagmi query cache.
Error handling. wagmi surfaces contract reverts as typed errors when you provide the ABI. I map custom error names to user-friendly messages. "InsufficientPayment" becomes "Your payment is less than the listing price." "AuctionAlreadyEnded" becomes "This auction has ended."
IPFS Metadata Storage
Every NFT has metadata — the name, description, image, attributes, and any other properties that define what the token represents. This metadata needs to live somewhere permanent and decentralized. IPFS is the standard choice.
Here is the metadata pinning service I use with Pinata:
// services/ipfs/pinMetadata.ts
interface NFTMetadata {
name: string;
description: string;
image: string; // IPFS CID of the image
external_url?: string;
attributes: Array<{
trait_type: string;
value: string | number;
display_type?: string;
}>;
}
const PINATA_JWT = process.env.PINATA_JWT!;
const PINATA_GATEWAY = process.env.PINATA_GATEWAY!;
export async function pinMetadataToIPFS(
metadata: NFTMetadata
): Promise<string> {
const response = await fetch(
"https://api.pinata.cloud/pinning/pinJSONToIPFS",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${PINATA_JWT}`,
},
body: JSON.stringify({
pinataContent: metadata,
pinataMetadata: {
name: `${metadata.name}-metadata`,
},
}),
}
);
if (!response.ok) {
throw new Error(`Pinata upload failed: ${response.statusText}`);
}
const { IpfsHash } = await response.json();
return `ipfs://${IpfsHash}`;
}
export async function pinImageToIPFS(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
formData.append(
"pinataMetadata",
JSON.stringify({ name: file.name })
);
const response = await fetch(
"https://api.pinata.cloud/pinning/pinFileToIPFS",
{
method: "POST",
headers: {
Authorization: `Bearer ${PINATA_JWT}`,
},
body: formData,
}
);
if (!response.ok) {
throw new Error(`Image upload failed: ${response.statusText}`);
}
const { IpfsHash } = await response.json();
return `ipfs://${IpfsHash}`;
}
export function ipfsToHttp(ipfsUri: string): string {
const cid = ipfsUri.replace("ipfs://", "");
return `https://${PINATA_GATEWAY}/ipfs/${cid}`;
}The metadata format follows the widely-adopted OpenSea metadata standard:
{
"name": "Cosmic Voyager #42",
"description": "A generative art piece from the Cosmic Voyager collection.",
"image": "ipfs://QmX9Y8z7W6V5U4T3S2R1Q0P...",
"external_url": "https://example.com/nft/42",
"attributes": [
{
"trait_type": "Background",
"value": "Deep Space"
},
{
"trait_type": "Rarity Score",
"value": 87,
"display_type": "number"
},
{
"trait_type": "Generation",
"value": "Genesis",
"display_type": "string"
}
]
}A few IPFS best practices from production experience:
Always pin to multiple services. Pinata is reliable, but I also pin to Infura IPFS and run my own IPFS node for critical collections. If your pinning service goes down, unpinned content can disappear from the network.
Use dedicated gateways. The public ipfs.io gateway is slow and rate-limited. Pinata's dedicated gateways serve content in under 200ms. For the marketplace frontend, always resolve IPFS URIs through your dedicated gateway.
Store the IPFS CID on-chain, not the gateway URL. The NFT contract should store ipfs://QmX9Y8z7..., not https://gateway.pinata.cloud/ipfs/QmX9Y8z7.... Gateway URLs can change. IPFS CIDs are permanent content-addressed hashes. The frontend converts CIDs to gateway URLs at render time using the ipfsToHttp helper above.
Image optimization. Before pinning images to IPFS, resize and compress them. I store the original at full resolution and generate a 512x512 thumbnail for marketplace listings. The full image loads when the user clicks into the detail view. This cuts page load times dramatically for gallery views with dozens of NFTs.
Security Considerations
Security in a marketplace contract is not optional — it is the entire foundation. One exploitable bug and your users lose real money. Here is the security checklist I run on every marketplace deployment.
Reentrancy protection. The nonReentrant modifier from OpenZeppelin is on every function that transfers ETH or NFTs. But the modifier alone is not enough. I also follow CEI (Checks-Effects-Interactions) religiously: all state changes happen before any external calls. In the buyNFT function, listing.active = false is set before _executeSale makes any transfers.
Pull-based refunds. In the auction bidding logic, outbid users are not refunded immediately. Their funds are credited to pendingWithdrawals, and they call withdraw() to claim. This prevents the denial-of-service attack where a contract bidder reverts on receive, bricking the entire auction.
Approval checks at listing time. The contract verifies that the marketplace is approved to transfer the NFT when the listing is created. But approval can be revoked between listing and sale. In production, I add an approval check in buyNFT as well:
// Double-check approval at purchase time
if (
!IERC721(listing.nftContract).isApprovedForAll(
listing.seller,
address(this)
)
) {
revert NotApproved();
}Royalty amount validation. As mentioned in the royalty section, malicious NFT contracts can report arbitrarily high royalties. Always cap royalty amounts to prevent sellers from receiving zero proceeds.
Integer overflow protection. Solidity 0.8.x has built-in overflow checks, so this is handled by the compiler. But be careful with unchecked blocks — I only use them for loop counters and incrementing IDs, never for financial arithmetic.
Front-running protection. On L1 Ethereum, MEV bots can see pending transactions and front-run purchases. For high-value NFTs, consider implementing a commit-reveal scheme where buyers commit to a purchase in one transaction and reveal in a second. On L2s like Arbitrum and Base, sequencer ordering makes front-running much harder, so this is less of a concern.
Signature-based listings (advanced). For gas optimization, you can move listings off-chain entirely. Sellers sign an EIP-712 typed message containing the listing parameters, and the marketplace only executes an on-chain transaction when a buyer purchases. This is the approach OpenSea's Seaport uses. It saves sellers the gas cost of creating on-chain listings but adds complexity around signature invalidation and replay protection.
Emergency pause. Every production marketplace I deploy includes a pause mechanism. If an exploit is discovered, the owner can pause all marketplace functions immediately. OpenZeppelin's Pausable contract makes this trivial:
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
// Add `whenNotPaused` to createListing, buyNFT, placeBid, settleAuction
function buyNFT(uint256 listingId) external payable nonReentrant whenNotPaused {
// ...
}Testing the Full Flow
Testing a marketplace contract requires more than unit tests on individual functions. You need to test the full lifecycle: mint NFT, approve marketplace, list, buy/bid, verify royalty payments, verify seller proceeds.
Here is the Foundry test suite I use:
// test/NFTMarketplace.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {NFTMarketplace} from "../src/NFTMarketplace.sol";
import {MockNFT} from "./mocks/MockNFT.sol";
contract NFTMarketplaceTest is Test {
NFTMarketplace marketplace;
MockNFT nft;
address seller = makeAddr("seller");
address buyer = makeAddr("buyer");
address bidder1 = makeAddr("bidder1");
address bidder2 = makeAddr("bidder2");
address feeRecipient = makeAddr("feeRecipient");
address royaltyReceiver = makeAddr("royaltyReceiver");
uint256 constant PLATFORM_FEE_BPS = 250; // 2.5%
uint256 constant ROYALTY_BPS = 500; // 5%
function setUp() public {
marketplace = new NFTMarketplace(PLATFORM_FEE_BPS, feeRecipient);
nft = new MockNFT(royaltyReceiver, ROYALTY_BPS);
// Mint NFT to seller
nft.mint(seller, 1);
// Seller approves marketplace
vm.prank(seller);
nft.setApprovalForAll(address(marketplace), true);
// Fund accounts
vm.deal(buyer, 100 ether);
vm.deal(bidder1, 100 ether);
vm.deal(bidder2, 100 ether);
}
function test_FixedPriceSale() public {
// Seller lists NFT
vm.prank(seller);
uint256 listingId = marketplace.createListing(
address(nft), 1, 1 ether, NFTMarketplace.ListingType.FixedPrice, 0
);
uint256 sellerBalBefore = seller.balance;
// Buyer purchases
vm.prank(buyer);
marketplace.buyNFT{value: 1 ether}(listingId);
// Verify NFT transferred
assertEq(nft.ownerOf(1), buyer);
// Verify payment split
// Platform fee: 1 ETH * 250 / 10000 = 0.025 ETH
// Royalty: 1 ETH * 500 / 10000 = 0.05 ETH
// Seller: 1 - 0.025 - 0.05 = 0.925 ETH
assertEq(feeRecipient.balance, 0.025 ether);
assertEq(royaltyReceiver.balance, 0.05 ether);
assertEq(seller.balance - sellerBalBefore, 0.925 ether);
}
function test_AuctionFlow() public {
// Seller creates auction
vm.prank(seller);
uint256 listingId = marketplace.createListing(
address(nft),
1,
0.5 ether, // Starting price
NFTMarketplace.ListingType.Auction,
1 days
);
// Bidder1 places bid
vm.prank(bidder1);
marketplace.placeBid{value: 0.5 ether}(listingId);
// Bidder2 outbids (must be >= 5% higher)
vm.prank(bidder2);
marketplace.placeBid{value: 0.6 ether}(listingId);
// Bidder1 can withdraw their outbid amount
assertEq(marketplace.pendingWithdrawals(bidder1), 0.5 ether);
// Fast forward past auction end
vm.warp(block.timestamp + 1 days + 1);
// Settle auction
marketplace.settleAuction(listingId);
// Verify NFT goes to winning bidder
assertEq(nft.ownerOf(1), bidder2);
}
function test_RevertBuyInactiveList() public {
vm.prank(seller);
uint256 listingId = marketplace.createListing(
address(nft), 1, 1 ether, NFTMarketplace.ListingType.FixedPrice, 0
);
// Cancel listing
vm.prank(seller);
marketplace.cancelListing(listingId);
// Attempt to buy cancelled listing
vm.prank(buyer);
vm.expectRevert(NFTMarketplace.ListingNotActive.selector);
marketplace.buyNFT{value: 1 ether}(listingId);
}
function testFuzz_BidIncrements(uint256 firstBid, uint256 secondBid) public {
firstBid = bound(firstBid, 0.5 ether, 50 ether);
uint256 minSecondBid = firstBid + (firstBid / 20);
secondBid = bound(secondBid, minSecondBid, 100 ether);
vm.prank(seller);
uint256 listingId = marketplace.createListing(
address(nft),
1,
0.5 ether,
NFTMarketplace.ListingType.Auction,
1 days
);
vm.prank(bidder1);
marketplace.placeBid{value: firstBid}(listingId);
vm.prank(bidder2);
marketplace.placeBid{value: secondBid}(listingId);
(, uint256 amount) = marketplace.highestBids(listingId);
assertEq(amount, secondBid);
}
}The mock NFT contract for testing:
// test/mocks/MockNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol";
contract MockNFT is ERC721, ERC2981 {
constructor(
address royaltyReceiver,
uint96 royaltyBps
) ERC721("MockNFT", "MNFT") {
_setDefaultRoyalty(royaltyReceiver, royaltyBps);
}
function mint(address to, uint256 tokenId) external {
_mint(to, tokenId);
}
function supportsInterface(
bytes4 interfaceId
) public view override(ERC721, ERC2981) returns (bool) {
return super.supportsInterface(interfaceId);
}
}Run the tests with Foundry:
forge test --match-contract NFTMarketplaceTest -vvvThe fuzz test on bid increments is critical. It generates thousands of random bid amounts and verifies the 5% increment rule holds for all of them. In production, I run fuzz tests with at least 10,000 iterations (FOUNDRY_FUZZ_RUNS=10000) before any deployment.
I also write invariant tests that verify system-level properties:
- The sum of all pending withdrawals plus contract balance minus active bid amounts must always equal zero (no ETH is created or destroyed).
- A listing can only transition from active to inactive, never back.
- The highest bid for any listing is always greater than or equal to the starting price.
Production Deployment
Deploying a marketplace to mainnet is a process, not an event. Here is my deployment checklist.
Pre-deployment:
- All tests pass with
forge test --fuzz-runs 10000. - Gas benchmarks are within budget:
forge test --gas-report. - Contract size is under 24KB:
forge build --sizes. - Static analysis with Slither:
slither src/NFTMarketplace.sol. - At least one internal audit by a second pair of eyes.
- Testnet deployment on Sepolia with real user flows tested.
Deployment script:
// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Script} from "forge-std/Script.sol";
import {NFTMarketplace} from "../src/NFTMarketplace.sol";
contract DeployMarketplace is Script {
function run() external {
uint256 deployerKey = vm.envUint("DEPLOYER_PRIVATE_KEY");
address feeRecipient = vm.envAddress("FEE_RECIPIENT");
uint256 feeBps = vm.envUint("PLATFORM_FEE_BPS");
vm.startBroadcast(deployerKey);
NFTMarketplace marketplace = new NFTMarketplace(
feeBps,
feeRecipient
);
vm.stopBroadcast();
}
}# Deploy to Arbitrum
forge script script/Deploy.s.sol \
--rpc-url $ARBITRUM_RPC_URL \
--broadcast \
--verify \
--etherscan-api-key $ARBISCAN_API_KEYPost-deployment:
- Verify the contract on Etherscan/Arbiscan/Basescan.
- Transfer ownership to a multi-sig (Gnosis Safe) for admin functions.
- Set up monitoring with Tenderly or OpenZeppelin Defender for unusual activity.
- Deploy The Graph subgraph to index marketplace events.
- Configure the frontend with the deployed contract address.
Which chain? For new marketplaces in 2024, I recommend Arbitrum or Base. Gas costs are 10-50x cheaper than L1 Ethereum, settlement inherits Ethereum's security, and both chains have strong NFT ecosystems. I deploy to L1 only when the client specifically requires it, usually for high-value 1/1 art.
Key Takeaways
- Non-custodial design. The marketplace never holds NFTs. Sellers keep their tokens until the moment of sale. This reduces the attack surface dramatically.
- Pull-based refunds. Never push ETH to previous bidders. Use the withdrawal pattern to prevent denial-of-service attacks from contract bidders that revert on receive.
- CEI pattern, always. Update state before making external calls. The
listing.active = falseline must come before any ETH transfers or NFT transfers.
- ERC-2981 royalties with caps. Enforce creator royalties on-chain, but cap them at 10% to prevent malicious NFT contracts from draining sellers.
- Fuzz test financial logic. Bid increments, royalty calculations, and payment splits must be fuzz-tested with thousands of random inputs. Happy-path-only tests miss edge cases that cost real money.
- Deploy to L2 first. Arbitrum and Base give you Ethereum-grade security at a fraction of the gas cost. Save L1 for specific use cases.
- Pause mechanism. Every production marketplace needs an emergency pause. If an exploit is discovered, you need to stop all operations immediately.
- IPFS with multiple pins. Never rely on a single pinning service. Pin to Pinata, Infura, and your own node. Use content-addressed
ipfs://URIs on-chain, not gateway URLs.
Building an NFT marketplace is one of the most rewarding full-stack Web3 projects because it touches every layer — smart contract engineering, frontend development, decentralized storage, and protocol economics. If you are planning a marketplace and want production-grade engineering from someone who has shipped them, check out my Web3 development services.
*Uvin Vindula is a Web3 and AI engineer based between Sri Lanka and the UK. He builds production smart contracts, DeFi protocols, and NFT platforms through iamuvin.com↗. Follow his work at @IAMUVIN↗.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.