Tutorial

Building an NFT Marketplace on Base L2

A complete guide to building a gas-efficient NFT marketplace using Base L2, featuring smart contracts, frontend integration, and best practices.

Javery
··7 min read
Building an NFT Marketplace on Base L2

Building an NFT marketplace on Base L2 gives you the perfect combination of Ethereum security and low transaction costs. In this tutorial, we'll build a complete marketplace from scratch.

Why Build on Base L2?

Traditional NFT marketplaces on Ethereum mainnet face significant challenges:

  • High gas fees - Listing an NFT can cost $50-200
  • Slow transactions - 12-second block times
  • Poor UX - Users need to confirm multiple expensive transactions

Base L2 solves all of these problems while maintaining Ethereum's security guarantees.

Cost Comparison

On Base L2, listing an NFT costs less than $0.10, compared to $50-200 on Ethereum mainnet. That's a 500x cost reduction!

Architecture Overview

Our marketplace will have three main components:

  1. Smart Contracts - NFT contract + Marketplace contract
  2. Frontend - React app with wagmi for Web3 integration
  3. Backend (optional) - Indexer for marketplace events

Let's start with the smart contracts.

Step 1: NFT Contract

First, we'll create an ERC-721 contract with enumerable extension for easier marketplace integration:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
 
contract BaseNFT is ERC721Enumerable, Ownable {
    uint256 private _tokenIdCounter;
    string private _baseTokenURI;
 
    mapping(uint256 => string) private _tokenURIs;
 
    constructor(
        string memory name,
        string memory symbol,
        string memory baseURI
    ) ERC721(name, symbol) Ownable(msg.sender) {
        _baseTokenURI = baseURI;
    }
 
    function mint(address to, string memory tokenURI) public returns (uint256) {
        uint256 tokenId = _tokenIdCounter++;
        _safeMint(to, tokenId);
        _tokenURIs[tokenId] = tokenURI;
        return tokenId;
    }
 
    function tokenURI(uint256 tokenId)
        public
        view
        override
        returns (string memory)
    {
        require(_ownerOf(tokenId) != address(0), "Token does not exist");
        return string(abi.encodePacked(_baseTokenURI, _tokenURIs[tokenId]));
    }
}

Gas Optimization

Notice we're using a counter instead of totalSupply() for token IDs. This saves gas by avoiding duplicate SLOAD operations.

💡

Step 2: Marketplace Contract

Now for the marketplace logic with listings, offers, and sales:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
 
contract NFTMarketplace is ReentrancyGuard {
    struct Listing {
        address seller;
        address nftContract;
        uint256 tokenId;
        uint256 price;
        bool active;
    }
 
    // Listing ID counter
    uint256 private _listingIdCounter;
 
    // Mapping from listing ID to Listing
    mapping(uint256 => Listing) public listings;
 
    // Platform fee (in basis points, 250 = 2.5%)
    uint256 public platformFee = 250;
    address public feeRecipient;
 
    event Listed(
        uint256 indexed listingId,
        address indexed seller,
        address indexed nftContract,
        uint256 tokenId,
        uint256 price
    );
 
    event Sold(
        uint256 indexed listingId,
        address indexed buyer,
        uint256 price
    );
 
    event ListingCancelled(uint256 indexed listingId);
 
    constructor(address _feeRecipient) {
        feeRecipient = _feeRecipient;
    }
 
    function createListing(
        address nftContract,
        uint256 tokenId,
        uint256 price
    ) external returns (uint256) {
        require(price > 0, "Price must be greater than 0");
 
        IERC721 nft = IERC721(nftContract);
        require(nft.ownerOf(tokenId) == msg.sender, "Not token owner");
        require(
            nft.isApprovedForAll(msg.sender, address(this)) ||
            nft.getApproved(tokenId) == address(this),
            "Marketplace not approved"
        );
 
        uint256 listingId = _listingIdCounter++;
 
        listings[listingId] = Listing({
            seller: msg.sender,
            nftContract: nftContract,
            tokenId: tokenId,
            price: price,
            active: true
        });
 
        emit Listed(listingId, msg.sender, nftContract, tokenId, price);
        return listingId;
    }
 
    function buyListing(uint256 listingId)
        external
        payable
        nonReentrant
    {
        Listing storage listing = listings[listingId];
        require(listing.active, "Listing not active");
        require(msg.value >= listing.price, "Insufficient payment");
 
        listing.active = false;
 
        // Calculate platform fee
        uint256 fee = (listing.price * platformFee) / 10000;
        uint256 sellerAmount = listing.price - fee;
 
        // Transfer NFT to buyer
        IERC721(listing.nftContract).safeTransferFrom(
            listing.seller,
            msg.sender,
            listing.tokenId
        );
 
        // Transfer funds
        (bool feeSuccess, ) = feeRecipient.call{value: fee}("");
        require(feeSuccess, "Fee transfer failed");
 
        (bool sellerSuccess, ) = listing.seller.call{value: sellerAmount}("");
        require(sellerSuccess, "Seller payment failed");
 
        // Refund excess payment
        if (msg.value > listing.price) {
            (bool refundSuccess, ) = msg.sender.call{
                value: msg.value - listing.price
            }("");
            require(refundSuccess, "Refund failed");
        }
 
        emit Sold(listingId, msg.sender, listing.price);
    }
 
    function cancelListing(uint256 listingId) external {
        Listing storage listing = listings[listingId];
        require(listing.seller == msg.sender, "Not seller");
        require(listing.active, "Listing not active");
 
        listing.active = false;
        emit ListingCancelled(listingId);
    }
}

The highlighted lines show our gas-optimized ID counter and efficient listing storage.

Step 3: Frontend Integration

Let's build a React component to interact with our marketplace:

'use client';
 
import { useReadContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther, formatEther } from 'viem';
import { useState } from 'react';
 
const MARKETPLACE_ADDRESS = '0x...'; // Your deployed address
const MARKETPLACE_ABI = [...]; // Your ABI
 
export function MarketplaceListing({ listingId }: { listingId: number }) {
  const [isPurchasing, setIsPurchasing] = useState(false);
 
  // Read listing data
  const { data: listing } = useReadContract({
    address: MARKETPLACE_ADDRESS,
    abi: MARKETPLACE_ABI,
    functionName: 'listings',
    args: [BigInt(listingId)],
  });
 
  // Write contract for buying
  const { writeContract, data: hash } = useWriteContract();
 
  const { isLoading: isConfirming } = useWaitForTransactionReceipt({
    hash,
  });
 
  const handleBuy = async () => {
    if (!listing) return;
 
    setIsPurchasing(true);
    try {
      await writeContract({
        address: MARKETPLACE_ADDRESS,
        abi: MARKETPLACE_ABI,
        functionName: 'buyListing',
        args: [BigInt(listingId)],
        value: listing.price,
      });
    } catch (error) {
      console.error('Purchase failed:', error);
    } finally {
      setIsPurchasing(false);
    }
  };
 
  if (!listing || !listing.active) {
    return <div>Listing not found or inactive</div>;
  }
 
  return (
    <div className="rounded-lg border p-6">
      <h3 className="mb-4 text-xl font-bold">NFT #{listing.tokenId.toString()}</h3>
      <p className="mb-4 text-2xl font-bold">
        {formatEther(listing.price)} ETH
      </p>
      <button
        onClick={handleBuy}
        disabled={isPurchasing || isConfirming}
        className="rounded bg-blue-500 px-6 py-2 text-white hover:bg-blue-600 disabled:opacity-50"
      >
        {isPurchasing || isConfirming ? 'Processing...' : 'Buy Now'}
      </button>
    </div>
  );
}

OnBase Stream

Real-time on-chain data streaming and event monitoring

Monitor Your Marketplace
🌊

Step 4: Deployment

Deploy to Base Goerli testnet first:

# Using Hardhat
npx hardhat run scripts/deploy.ts --network base-goerli
 
# Or Foundry
forge create --rpc-url https://goerli.base.org \
  --private-key $PRIVATE_KEY \
  src/NFTMarketplace.sol:NFTMarketplace

Test First!

Always deploy to Base Goerli testnet first. Use the Base faucet to get test ETH for deployment and testing.

⚠️

Step 5: Event Indexing

For a production marketplace, you'll want to index events for faster queries:

import { createPublicClient, http, parseAbiItem } from 'viem';
import { base } from 'viem/chains';
 
const client = createPublicClient({
  chain: base,
  transport: http(),
});
 
async function indexListings() {
  const logs = await client.getLogs({
    address: MARKETPLACE_ADDRESS,
    event: parseAbiItem('event Listed(uint256 indexed listingId, address indexed seller, address indexed nftContract, uint256 tokenId, uint256 price)'),
    fromBlock: 'earliest',
    toBlock: 'latest',
  });
 
  // Store in database
  for (const log of logs) {
    const { listingId, seller, nftContract, tokenId, price } = log.args;
    // await db.listings.create({ ... });
  }
}

Or use OnBase Stream for real-time indexing without the infrastructure overhead:

import { createStream } from '@onbase/stream';
 
const stream = createStream({
  apiKey: process.env.ONBASE_API_KEY,
  network: 'base',
});
 
stream.on('event', {
  address: MARKETPLACE_ADDRESS,
  event: 'Listed',
  callback: async (event) => {
    // Real-time listing updates!
    await updateDatabase(event.args);
  },
});

Testing Your Marketplace

import { expect } from 'chai';
import { ethers } from 'hardhat';
 
describe('NFTMarketplace', function () {
  it('Should create and fulfill a listing', async function () {
    const [seller, buyer] = await ethers.getSigners();
 
    // Deploy contracts
    const NFT = await ethers.getContractFactory('BaseNFT');
    const nft = await NFT.deploy('Test', 'TEST', 'ipfs://');
 
    const Marketplace = await ethers.getContractFactory('NFTMarketplace');
    const marketplace = await Marketplace.deploy(seller.address);
 
    // Mint NFT
    await nft.mint(seller.address, 'token1');
 
    // Approve marketplace
    await nft.connect(seller).setApprovalForAll(marketplace.address, true);
 
    // Create listing
    const price = ethers.utils.parseEther('1.0');
    await marketplace.connect(seller).createListing(nft.address, 0, price);
 
    // Buy listing
    await marketplace.connect(buyer).buyListing(0, { value: price });
 
    // Verify ownership transfer
    expect(await nft.ownerOf(0)).to.equal(buyer.address);
  });
});

Production Considerations

Security

  • ReentrancyGuard on all payable functions
  • Approval checks before transfers
  • Input validation on all parameters
  • ⚠️ Consider adding pause mechanism for emergencies
  • ⚠️ Implement offer expiration to prevent stale listings

Gas Optimization

We've already implemented several optimizations:

  1. Using counters instead of array length
  2. Packing struct variables efficiently
  3. Using call instead of transfer for ETH
  4. Batch operations where possible

User Experience

  • Add IPFS integration for metadata
  • Implement lazy minting to save gas
  • Support ERC-20 tokens as payment
  • Add royalty support (EIP-2981)

Next Steps

This is a basic marketplace. In production, you'd want to add features like auctions, offers, bundles, and more sophisticated fee structures.

ℹ️

Deployment Checklist

Before going to mainnet:

  • Full test suite coverage
  • Security audit completed
  • Gas optimization review
  • Frontend thoroughly tested
  • Backup seller recovery mechanism
  • Emergency pause functionality
  • Fee recipient configurable
  • Events indexed and queryable

OnBase Launch

Deploy and manage your smart contracts with confidence

Deploy Your Marketplace
🚀

Happy building! 🚀

Share:

Related Posts