Building an NFT Marketplace from Scratch

 1) Core Product Decisions


Chain: Start on an EVM chain (Ethereum, Polygon, Base, Arbitrum) → pick testnet first.


NFT standard: ERC-721 (single) and/or ERC-1155 (editions). Add EIP-2981 for royalties.


Trade types: Fixed price (buy now), English auction (highest bid), Offers.


Orderbook:


On-chain (simpler, higher gas), or


Off-chain signed orders (cheaper; verify using EIP-712; settle on-chain).


Storage: Token media + metadata via IPFS/Arweave; pin with Pinata/Web3.Storage.


Wallets: MetaMask + WalletConnect; optional Coinbase Wallet.


Payments: Native token (ETH/MATIC) + optional ERC-20 stablecoins (USDC).


2) Reference Architecture


Smart contracts: NFT contracts (721/1155), Marketplace (list, buy, bid, offer, fees, royalties).


Indexer: The Graph (subgraph) or custom worker (listens to contract events → DB).


Backend API (optional): Node/Nest/FastAPI for user profiles, signed orders, analytics, moderation.


DB: Postgres for listings, offers, profiles, activity; Redis for caching.


Frontend: Next.js/React + Wagmi/viem + RainbowKit; Tailwind for UI.


CDN: Cache images via Cloudflare/R2; normalize metadata on fetch.


3) Smart Contract Design

3.1 Minimal ERC-721 with Royalties (EIP-2981)

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;


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

import "@openzeppelin/contracts/access/Ownable.sol";

import "@openzeppelin/contracts/interfaces/IERC2981.sol";


contract Creators721 is ERC721URIStorage, Ownable, IERC2981 {

    uint96 private constant FEE_DENOM = 10_000; // 100% = 10000 bps

    struct Royalty { address receiver; uint96 bps; }

    mapping(uint256 => Royalty) public royalties;

    uint256 public nextId;


    constructor() ERC721("Creators721","CR721") Ownable(msg.sender) {}


    function mint(address to, string memory tokenURI, address royaltyReceiver, uint96 royaltyBps) external onlyOwner {

        require(royaltyBps <= 1000, "Max 10%");

        uint256 id = ++nextId;

        _safeMint(to, id);

        _setTokenURI(id, tokenURI);

        if (royaltyReceiver != address(0) && royaltyBps > 0) {

            royalties[id] = Royalty(royaltyReceiver, royaltyBps);

        }

    }


    // EIP-2981

    function royaltyInfo(uint256 tokenId, uint256 salePrice) external view returns (address, uint256) {

        Royalty memory r = royalties[tokenId];

        uint256 amount = (salePrice * r.bps) / FEE_DENOM;

        return (r.receiver, amount);

    }


    function supportsInterface(bytes4 iid) public view override(ERC721, IERC165) returns (bool) {

        return iid == type(IERC2981).interfaceId || super.supportsInterface(iid);

    }

}


3.2 Marketplace (fixed price + offers)


Holds listings (seller, nft, price, expiry).


Buys transfer NFT and pay seller – marketplace takes platform fee and pays royalty.


Offers escrow bidder funds until accepted/cancelled.


Emits events for indexing: Listed, Purchase, OfferMade, OfferAccepted, Cancelled.


// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;


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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";


interface IRoyalty {

    function royaltyInfo(uint256 tokenId, uint256 salePrice) external view returns (address, uint256);

}


contract SimpleMarketplace is ReentrancyGuard {

    struct Listing { address seller; address nft; uint256 id; uint256 price; uint64  expiry; bool active; }

    struct Offer   { address bidder; uint256 amount; uint64  expiry; bool active; }


    uint96 public constant FEE_DENOM = 10_000;

    uint96 public platformFeeBps;       // e.g., 250 = 2.5%

    address public feeRecipient;

    mapping(bytes32 => Listing) public listings;        // key = keccak(nft,id,seller)

    mapping(bytes32 => Offer)   public offers;          // key = keccak(nft,id,bidder)


    event Listed(address indexed seller, address indexed nft, uint256 indexed id, uint256 price, uint64 expiry);

    event Cancelled(address indexed seller, address indexed nft, uint256 indexed id);

    event Purchase(address indexed buyer, address indexed nft, uint256 indexed id, uint256 price, address seller);

    event OfferMade(address indexed bidder, address indexed nft, uint256 indexed id, uint256 amount, uint64 expiry);

    event OfferCancelled(address indexed bidder, address indexed nft, uint256 indexed id);

    event OfferAccepted(address indexed seller, address indexed nft, uint256 indexed id, address bidder, uint256 amount);


    constructor(uint96 _feeBps, address _feeRecipient) {

        require(_feeBps <= 1000, "fee too high");

        platformFeeBps = _feeBps;

        feeRecipient   = _feeRecipient;

    }


    function _key(address nft, uint256 id, address user) private pure returns (bytes32) {

        return keccak256(abi.encode(nft, id, user));

    }


    // Seller lists token (must approve this contract first)

    function list(address nft, uint256 id, uint256 price, uint64 expiry) external {

        require(IERC721(nft).ownerOf(id) == msg.sender, "not owner");

        bytes32 k = _key(nft, id, msg.sender);

        listings[k] = Listing(msg.sender, nft, id, price, expiry, true);

        emit Listed(msg.sender, nft, id, price, expiry);

    }


    function cancel(address nft, uint256 id) external {

        bytes32 k = _key(nft, id, msg.sender);

        Listing memory L = listings[k];

        require(L.active && L.seller == msg.sender, "not listed");

        delete listings[k];

        emit Cancelled(msg.sender, nft, id);

    }


    // Buy now (native token)

    function buy(address nft, uint256 id, address seller) external payable nonReentrant {

        bytes32 k = _key(nft, id, seller);

        Listing memory L = listings[k];

        require(L.active && L.price == msg.value, "bad price");

        require(L.expiry == 0 || block.timestamp <= L.expiry, "expired");


        // Transfer NFT to buyer

        IERC721(nft).safeTransferFrom(L.seller, msg.sender, id);


        // Fees & royalties

        (address rcv, uint256 roy) = _tryRoyalty(nft, id, msg.value);

        uint256 fee = (msg.value * platformFeeBps) / FEE_DENOM;

        _payout(feeRecipient, fee);

        if (rcv != address(0) && roy > 0) _payout(rcv, roy);

        _payout(L.seller, msg.value - fee - roy);


        delete listings[k];

        emit Purchase(msg.sender, nft, id, msg.value, L.seller);

    }


    // Make offer with native token (escrowed)

    function makeOffer(address nft, uint256 id, uint64 expiry) external payable nonReentrant {

        bytes32 k = _key(nft, id, msg.sender);

        offers[k] = Offer(msg.sender, msg.value, expiry, true);

        emit OfferMade(msg.sender, nft, id, msg.value, expiry);

    }


    function cancelOffer(address nft, uint256 id) external nonReentrant {

        bytes32 k = _key(nft, id, msg.sender);

        Offer memory O = offers[k];

        require(O.active && O.bidder == msg.sender, "no offer");

        delete offers[k];

        _payout(msg.sender, O.amount);

        emit OfferCancelled(msg.sender, nft, id);

    }


    function acceptOffer(address nft, uint256 id, address bidder) external nonReentrant {

        require(IERC721(nft).ownerOf(id) == msg.sender, "not owner");

        bytes32 k = _key(nft, id, bidder);

        Offer memory O = offers[k];

        require(O.active && (O.expiry == 0 || block.timestamp <= O.expiry), "invalid");


        IERC721(nft).safeTransferFrom(msg.sender, bidder, id);


        (address rcv, uint256 roy) = _tryRoyalty(nft, id, O.amount);

        uint256 fee = (O.amount * platformFeeBps) / FEE_DENOM;

        _payout(feeRecipient, fee);

        if (rcv != address(0) && roy > 0) _payout(rcv, roy);

        _payout(msg.sender, O.amount - fee - roy);


        delete offers[k];

        emit OfferAccepted(msg.sender, nft, id, bidder, O.amount);

    }


    function _tryRoyalty(address nft, uint256 id, uint256 salePrice) internal view returns (address, uint256) {

        (bool ok, bytes memory data) = nft.staticcall(

            abi.encodeWithSignature("royaltyInfo(uint256,uint256)", id, salePrice)

        );

        if (ok && data.length == 64) {

            (address r, uint256 a) = abi.decode(data, (address, uint256));

            return (r, a);

        }

        return (address(0), 0);

    }


    function _payout(address to, uint256 amount) private {

        (bool s,) = payable(to).call{value: amount}("");

        require(s, "pay fail");

    }


    receive() external payable {}

}



Notes


For auctions: add bid increments, reserve price, extend-on-snipe (e.g., +5 min if last-minute bid), and a settle() function.


For off-chain orders: store nothing; buyers present a seller-signed listing (EIP-712) to fulfillOrder().


4) Indexing & Data Model


Events to index


NFT: Transfer, Approval.


Marketplace: Listed, Purchase, OfferMade, OfferAccepted, Cancelled.


DB tables (example)


users(id, address, username, bio, avatar_url, created_at)


nfts(contract, token_id, owner, metadata_url, name, image_url, attributes jsonb)


listings(id, contract, token_id, seller, price_wei, expiry, status)


offers(id, contract, token_id, bidder, amount_wei, expiry, status)


sales(id, contract, token_id, buyer, seller, tx_hash, price_wei, timestamp)


The Graph: define subgraph.yaml, schema.graphql, map handlers for events to entities.


5) Frontend (Next.js + Wagmi + viem)


Connect wallet (RainbowKit).


Views: Explore, Token detail, List/sell, Offers, Profile, Create (minting).


Flows:


List for sale: call approve() on NFT → list() on marketplace.


Buy now: buy() with exact msg.value.


Make offer: makeOffer() sends value; cancel/accept later.


Metadata handling: fetch tokenURI → resolve ipfs:// to gateway; cache.


6) Minting & Metadata


Off-chain JSON (standard OpenSea metadata):


{

  "name": "Galactic Cat #1",

  "description": "A cosmic collectible.",

  "image": "ipfs://<CID>/image.png",

  "attributes": [{"trait_type":"Background","value":"Nebula"}]

}



Lazy minting: creator signs a mint voucher (EIP-712); marketplace mints at purchase to save gas until sold.


7) Fees, Royalties, Compliance


Platform fee: basis points to treasury; make it configurable (owner/governance).


Royalties: EIP-2981 returns suggestions; many markets honor them voluntarily—document policy.


Compliance:


Content moderation (hash lists/flags).


Creator verification / spam prevention.


Jurisdictional KYC/AML if enabling fiat ramps (get legal advice).


8) Security Checklist


Use OpenZeppelin libraries; lock compiler (pragma ^0.8.24 with exact version in config).


Protect functions: reentrancy guards, checks-effects-interactions, safe ETH sends.


Validate inputs (prices > 0, expiries sane).


Handle ERC-721/1155 safe transfer hooks.


Beware signature replay (include chainId, contract, nonce, expiry in EIP-712).


Add pausable/circuit breaker for emergencies.


Unit + property tests (Foundry/Hardhat). Fuzz edge cases (expiry, partial fills, double-spend).


9) Gas & UX Optimizations


Batch listing/accepting where possible.


Off-chain orders with on-chain settlement.


Compress metadata (webp), defer high-res previews.


Use multicall for approvals + actions in one click.


10) Local Dev → Testnet → Mainnet


Local


Hardhat/Foundry; fork a public RPC for realistic tokens.


Script deploys; snapshot & reset for repeatability.


Testnets


Sepolia (Ethereum), Amoy (Polygon), Base Sepolia, Arbitrum Sepolia.


Faucet tokens; test volume, edge cases, indexer correctness.


Mainnet Readiness


External audit (at least one).


Bug bounty (Immunefi, Hats).


Observability: block listeners, error alerts, anomaly detection (wash trading, floor dumps).


11) Example Hardhat Tasks (outline)


deploy:nft, deploy:market


mint:nft --to 0x... --uri ipfs://CID/1.json


list --nft 0x... --id 1 --price 0.25 --expiry +7d


buy --nft 0x... --id 1 --seller 0x...


12) Nice-to-Have Features


Collections & stats (floor, volume, owners).


Filters (traits, price range).


Auctions with anti-sniping extension.


Bundles (sell multiple items together).


Creator storefronts and allowlists.


Social features: follows, comments, activity feed.


Mobile-friendly PWA.


Quick Build Stack (battle-tested)


Contracts: Solidity + Foundry; OpenZeppelin.


Indexer: The Graph (Hosted/Subgraph Studio).


Backend: Node (NestJS), Postgres + Prisma.


Frontend: Next.js, Wagmi/viem, RainbowKit, Tailwind.


If you want, I can:


turn this into a clickable implementation checklist,


scaffold a basic Next.js + Wagmi app with ready pages, or


extend the marketplace contract for auctions and EIP-712 signed listings.

Learn Blockchain Course in Hyderabad

Read More

Flash Loan Arbitrage: Real-World Examples

Flash Loan Arbitrage: Real-World Examples

Time Locks and Vesting in Smart Contracts

Voting Mechanisms in DAOs


Comments

Popular posts from this blog

Working with Cookies and Local Storage in Playwright

Playwright with Docker: Complete Setup

Handling Frames and Iframes Using Playwright