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

Handling Frames and Iframes Using Playwright

Cybersecurity Internship Opportunities in Hyderabad for Freshers

Tosca for API Testing: A Step-by-Step Tutorial