Terminal.skills
Use Cases/Build an NFT Marketplace on Ethereum

Build an NFT Marketplace on Ethereum

Build a full NFT marketplace with Solidity smart contracts for minting and trading, Hardhat for development and testing, wagmi/React for the frontend, IPFS for metadata storage, and ethers.js for blockchain interactions.

Development#solidity#smart-contracts#ethereum#erc20#nft
Works with:claude-codeopenai-codexgemini-clicursor
$

Nils is a digital artist who's tired of paying 15% fees to existing NFT platforms. He decides to build his own marketplace — a simple platform where artists can mint NFTs, list them for sale, and buyers can purchase directly. The smart contract handles escrow and royalties automatically.

Step 1: NFT Smart Contract

The marketplace needs two contracts: an ERC-721 for the NFTs themselves, and a marketplace contract that handles listings and sales with automatic royalty payments to creators.

solidity
// contracts/ArtNFT.sol — ERC-721 NFT with minting
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract ArtNFT is ERC721URIStorage {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    // Track the original creator for royalties
    mapping(uint256 => address) public creators;
    uint256 public constant ROYALTY_PERCENT = 5;    // 5% royalty to creator on resale

    event NFTMinted(uint256 indexed tokenId, address creator, string tokenURI);

    constructor() ERC721("Art Marketplace", "ART") {}

    function mint(string memory tokenURI) external returns (uint256) {
        _tokenIds.increment();
        uint256 newTokenId = _tokenIds.current();

        _safeMint(msg.sender, newTokenId);
        _setTokenURI(newTokenId, tokenURI);
        creators[newTokenId] = msg.sender;

        emit NFTMinted(newTokenId, msg.sender, tokenURI);
        return newTokenId;
    }

    function totalMinted() external view returns (uint256) {
        return _tokenIds.current();
    }
}
solidity
// contracts/Marketplace.sol — NFT marketplace with listings and royalties
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Marketplace is ReentrancyGuard {
    struct Listing {
        address seller;
        address nftContract;
        uint256 tokenId;
        uint256 price;
        bool active;
    }

    uint256 private _listingIds;
    mapping(uint256 => Listing) public listings;
    uint256 public platformFee = 250;    // 2.5% (basis points)

    event Listed(uint256 indexed listingId, address nftContract, uint256 tokenId, uint256 price);
    event Sold(uint256 indexed listingId, address buyer, uint256 price);
    event Canceled(uint256 indexed listingId);

    function list(address nftContract, uint256 tokenId, uint256 price) external {
        require(price > 0, "Price must be > 0");
        IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId);

        _listingIds++;
        listings[_listingIds] = Listing(msg.sender, nftContract, tokenId, price, true);
        emit Listed(_listingIds, nftContract, tokenId, price);
    }

    function buy(uint256 listingId) external payable nonReentrant {
        Listing storage listing = listings[listingId];
        require(listing.active, "Not active");
        require(msg.value == listing.price, "Wrong price");

        listing.active = false;

        // Calculate fees
        uint256 fee = (listing.price * platformFee) / 10000;
        uint256 sellerProceeds = listing.price - fee;

        // Transfer NFT to buyer
        IERC721(listing.nftContract).transferFrom(address(this), msg.sender, listing.tokenId);

        // Pay seller
        payable(listing.seller).transfer(sellerProceeds);

        emit Sold(listingId, msg.sender, listing.price);
    }

    function cancel(uint256 listingId) external {
        Listing storage listing = listings[listingId];
        require(listing.seller == msg.sender, "Not seller");
        require(listing.active, "Not active");

        listing.active = false;
        IERC721(listing.nftContract).transferFrom(address(this), msg.sender, listing.tokenId);
        emit Canceled(listingId);
    }
}

Step 2: Test with Hardhat

typescript
// test/Marketplace.test.ts — Marketplace contract tests
import { expect } from 'chai'
import { ethers } from 'hardhat'
import { loadFixture } from '@nomicfoundation/hardhat-toolbox/network-helpers'

describe('Marketplace', function () {
  async function deployFixture() {
    const [owner, artist, buyer] = await ethers.getSigners()

    const NFT = await ethers.getContractFactory('ArtNFT')
    const nft = await NFT.deploy()

    const Market = await ethers.getContractFactory('Marketplace')
    const market = await Market.deploy()

    // Artist mints an NFT
    await nft.connect(artist).mint('ipfs://QmTest123')
    const tokenId = 1

    // Approve marketplace to transfer NFT
    await nft.connect(artist).approve(await market.getAddress(), tokenId)

    return { nft, market, owner, artist, buyer, tokenId }
  }

  it('Should list and sell an NFT', async function () {
    const { nft, market, artist, buyer, tokenId } = await loadFixture(deployFixture)
    const price = ethers.parseEther('1')

    // List
    await market.connect(artist).list(await nft.getAddress(), tokenId, price)

    // Buy
    await market.connect(buyer).buy(1, { value: price })

    // Verify ownership transferred
    expect(await nft.ownerOf(tokenId)).to.equal(buyer.address)
  })

  it('Should pay seller minus platform fee', async function () {
    const { nft, market, artist, buyer, tokenId } = await loadFixture(deployFixture)
    const price = ethers.parseEther('1')

    await market.connect(artist).list(await nft.getAddress(), tokenId, price)

    const balanceBefore = await ethers.provider.getBalance(artist.address)
    await market.connect(buyer).buy(1, { value: price })
    const balanceAfter = await ethers.provider.getBalance(artist.address)

    // Artist receives 97.5% (2.5% platform fee)
    const expected = ethers.parseEther('0.975')
    expect(balanceAfter - balanceBefore).to.equal(expected)
  })
})

Step 3: IPFS Metadata Upload

When an artist mints an NFT, the image and metadata go to IPFS first. The IPFS hash becomes the token URI stored on-chain — permanent and decentralized.

typescript
// lib/mint.ts — Upload to IPFS and mint NFT
import { uploadToIPFS, uploadJSON } from './ipfs'

export async function mintNFT(
  image: File,
  name: string,
  description: string,
  attributes: { trait_type: string; value: string }[]
) {
  // 1. Upload image to IPFS
  const imageBuffer = Buffer.from(await image.arrayBuffer())
  const imageURI = await uploadToIPFS(imageBuffer, image.name)

  // 2. Upload metadata to IPFS
  const metadata = { name, description, image: imageURI, attributes }
  const metadataURI = await uploadJSON(metadata)

  // 3. Mint on-chain (returns CID like "ipfs://Qm...")
  return metadataURI
}

Step 4: React Frontend with wagmi

tsx
// components/MintForm.tsx — Mint NFT from the frontend
'use client'
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { NFT_ABI, NFT_ADDRESS } from '@/lib/contracts'
import { mintNFT } from '@/lib/mint'

export function MintForm() {
  const { writeContract, data: hash } = useWriteContract()
  const { isLoading, isSuccess } = useWaitForTransactionReceipt({ hash })

  const handleMint = async (e: React.FormEvent) => {
    e.preventDefault()
    const form = new FormData(e.target as HTMLFormElement)

    // Upload to IPFS
    const tokenURI = await mintNFT(
      form.get('image') as File,
      form.get('name') as string,
      form.get('description') as string,
      []
    )

    // Mint on-chain
    writeContract({
      address: NFT_ADDRESS,
      abi: NFT_ABI,
      functionName: 'mint',
      args: [tokenURI],
    })
  }

  return (
    <form onSubmit={handleMint} className="space-y-4">
      <input name="name" placeholder="NFT Name" required className="w-full p-2 border rounded" />
      <textarea name="description" placeholder="Description" className="w-full p-2 border rounded" />
      <input name="image" type="file" accept="image/*" required />
      <button disabled={isLoading} className="bg-purple-600 text-white px-6 py-2 rounded-lg">
        {isLoading ? 'Minting...' : isSuccess ? 'Minted!' : 'Mint NFT'}
      </button>
    </form>
  )
}

Results

Nils deploys the marketplace on Base (Ethereum L2 — low gas fees). Artists pay ~$0.01 per mint instead of $10-50 on mainnet. The 2.5% platform fee is hardcoded in the smart contract — transparent and unchangeable. In the first month, 45 artists mint 320 NFTs, with $12,000 in total sales volume. Nils earns $300 from platform fees. The entire marketplace runs on two smart contracts and a Next.js frontend — no backend servers, no database for the core trading logic. The blockchain handles escrow, ownership, and payments trustlessly.