NFT marketplaces look complex from the outside. They're really just a set of well-understood contracts. Here's the full implementation.
The Contracts
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract ArtNFT is ERC721URIStorage, Ownable {
uint256 private _tokenIds;
uint96 public royaltyBps; // Basis points (250 = 2.5%)
address public royaltyRecipient;
mapping(uint256 => address) public creators;
event Minted(uint256 indexed tokenId, address indexed creator, string tokenURI);
constructor(address owner, uint96 _royaltyBps)
ERC721("Art NFT", "ART")
Ownable(owner)
{
royaltyBps = _royaltyBps;
royaltyRecipient = owner;
}
function mint(string calldata tokenURI) external returns (uint256) {
_tokenIds++;
uint256 tokenId = _tokenIds;
_mint(msg.sender, tokenId);
_setTokenURI(tokenId, tokenURI);
creators[tokenId] = msg.sender;
emit Minted(tokenId, msg.sender, tokenURI);
return tokenId;
}
// EIP-2981 royalty standard
function royaltyInfo(uint256, uint256 salePrice)
external view returns (address, uint256)
{
return (royaltyRecipient, (salePrice * royaltyBps) / 10000);
}
}
The Marketplace Contract
contract Marketplace {
struct Listing {
address seller;
address nftContract;
uint256 tokenId;
uint256 price;
bool active;
}
mapping(bytes32 => Listing) public listings;
uint256 public platformFeeBps = 250; // 2.5% platform fee
address public feeRecipient;
event Listed(bytes32 indexed listingId, address indexed seller, address nftContract, uint256 tokenId, uint256 price);
event Sold(bytes32 indexed listingId, address indexed buyer, uint256 price);
event Cancelled(bytes32 indexed listingId);
constructor(address _feeRecipient) {
feeRecipient = _feeRecipient;
}
function list(address nftContract, uint256 tokenId, uint256 price) external {
require(price > 0, "Price must be > 0");
IERC721 nft = IERC721(nftContract);
require(nft.ownerOf(tokenId) == msg.sender, "Not owner");
require(
nft.isApprovedForAll(msg.sender, address(this)) ||
nft.getApproved(tokenId) == address(this),
"Marketplace not approved"
);
bytes32 listingId = keccak256(abi.encodePacked(nftContract, tokenId, msg.sender));
listings[listingId] = Listing({
seller: msg.sender,
nftContract: nftContract,
tokenId: tokenId,
price: price,
active: true
});
emit Listed(listingId, msg.sender, nftContract, tokenId, price);
}
function buy(bytes32 listingId) external payable nonReentrant {
Listing storage listing = listings[listingId];
require(listing.active, "Not active");
require(msg.value == listing.price, "Wrong price");
listing.active = false;
uint256 platformFee = (msg.value * platformFeeBps) / 10000;
// Check for EIP-2981 royalties
uint256 royaltyAmount = 0;
try IERC2981(listing.nftContract).royaltyInfo(listing.tokenId, msg.value) returns (
address royaltyRecipient, uint256 royaltyValue
) {
royaltyAmount = royaltyValue;
payable(royaltyRecipient).transfer(royaltyAmount);
} catch {}
uint256 sellerProceeds = msg.value - platformFee - royaltyAmount;
payable(feeRecipient).transfer(platformFee);
payable(listing.seller).transfer(sellerProceeds);
IERC721(listing.nftContract).safeTransferFrom(
listing.seller, msg.sender, listing.tokenId
);
emit Sold(listingId, msg.sender, msg.value);
}
function cancel(bytes32 listingId) external {
Listing storage listing = listings[listingId];
require(listing.seller == msg.sender, "Not seller");
require(listing.active, "Not active");
listing.active = false;
emit Cancelled(listingId);
}
}
Next.js Frontend
'use client'
import { useReadContract, useWriteContract } from 'wagmi'
import { parseEther, formatEther } from 'viem'
const MARKETPLACE_ADDRESS = '0x...' as const
const NFT_ADDRESS = '0x...' as const
export function ListingCard({ listingId }: { listingId: `0x${string}` }) {
const { data: listing } = useReadContract({
address: MARKETPLACE_ADDRESS,
abi: marketplaceAbi,
functionName: 'listings',
args: [listingId]
})
const { writeContract, isPending } = useWriteContract()
const buy = () => {
if (!listing) return
writeContract({
address: MARKETPLACE_ADDRESS,
abi: marketplaceAbi,
functionName: 'buy',
args: [listingId],
value: listing.price
})
}
if (!listing?.active) return null
return (
<div className="card">
<NFTImage tokenId={listing.tokenId} contract={listing.nftContract} />
<p className="price">{formatEther(listing.price)} ETH</p>
<button onClick={buy} disabled={isPending}>
{isPending ? 'Buying...' : 'Buy Now'}
</button>
</div>
)
}
Minting with IPFS Metadata
import { ThirdwebStorage } from "@thirdweb-dev/storage"
const storage = new ThirdwebStorage()
async function mintWithMetadata(
name: string,
description: string,
imageFile: File
): Promise<string> {
// Upload image to IPFS
const imageUri = await storage.upload(imageFile)
// Upload metadata JSON to IPFS
const metadataUri = await storage.upload({
name,
description,
image: imageUri,
attributes: [
{ trait_type: "Artist", value: "My Name" }
]
})
// Mint with IPFS URI
const { writeContract } = useWriteContract()
writeContract({
address: NFT_ADDRESS,
abi: nftAbi,
functionName: 'mint',
args: [metadataUri]
})
return metadataUri
}
NFT marketplaces at their core are simple: an NFT contract, a marketplace contract that holds approvals and handles payment splitting, and a frontend to interact with them. The complexity comes from UX polish and handling edge cases (failed transfers, royalty calculations, gas estimation).