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
Comments
Post a Comment