DEV Community

Ishan Maity
Ishan Maity

Posted on

From Wallet to Ownership: How NFT Marketplaces Work

Every NFT trade you've ever seen boiled down to a headline — "Bought for X ETH," "Sold for Y" — hides a surprisingly intricate pipeline of cryptography, smart contract logic, off-chain indexing, and UX engineering. If you've only interacted with NFT marketplaces as a user clicking "Connect Wallet" and "Buy Now," this post is going to pull back the curtain.

We're going to walk through the entire lifecycle of an NFT transaction — from the moment a wallet connects to the moment ownership is transferred on-chain — with actual code, architecture diagrams in words, and the engineering tradeoffs that separate a toy marketplace from a production-grade one. This is written for developers who want to build, audit, or simply understand these systems at a deeper level, not for casual collectors.

Table of Contents
What an NFT Marketplace Actually Is
The Core Architecture
Step One: Wallet Connection and Authentication
Step Two: Minting an NFT
Step Three: Metadata and Storage
Step Four: Listing an NFT for Sale
Step Five: The Buy Transaction and Escrow Models
Step Six: Royalties and the EIP-2981 Standard
Security Considerations Every Developer Should Know
Scaling: Layer 2s, Gas Optimization, and Indexing
Closing Thoughts

*1. What an NFT Marketplace Actually Is
*

At its core, an NFT marketplace is three systems glued together:

On-chain layer: Smart contracts that define ownership, enforce transfer rules, and handle payments.
Off-chain layer: A backend and database that index blockchain events, cache metadata, and serve fast queries (because reading directly from a blockchain node for every page load is painfully slow).
Client layer: A frontend that talks to both — reading cached data for speed, and writing directly to the chain when a user takes an action like minting, listing, or buying.

None of these layers work in isolation. A marketplace that only had a smart contract would be unusable (no one wants to query raw contract storage to browse a collection). A marketplace that only had a backend without smart contracts wouldn't actually be decentralized — it would just be a database pretending to sell "ownership."

*2. The Core Architecture
*

A typical production NFT marketplace looks like this:


[ Wallet (MetaMask / WalletConnect) ]
              |
              v
[ Frontend (React / Next.js + ethers.js or viem) ]
              |
     ---------------------
     |                   |
     v                   v
[ Backend API ]     [ Blockchain (via RPC Node) ]
     |                   |
     v                   v
[ Database/Index ]  [ Smart Contracts ]
     ^                   |
     |                   v
[ Event Listener ] <-- [ Emitted Events ]
              |
              v
[ IPFS / Arweave (Metadata & Assets) ]
Enter fullscreen mode Exit fullscreen mode

The event listener (often built with something like The Graph, or a custom indexer using ethers.js contract.on() or a queue-based worker) is the unsung hero here. It listens for Transfer, ItemListed, ItemSold events and syncs them into a queryable database so the frontend never has to hit the chain directly for a listing page.

*3. Step One: Wallet Connection and Authentication
*

Everything begins with a wallet. Unlike traditional auth (email/password, OAuth), Web3 auth is based on cryptographic signatures — proving you control a private key without ever exposing it.

A standard flow using ethers.js:

javascript
import { ethers } from "ethers";

async function connectWallet() {
  if (!window.ethereum) {
    throw new Error("No injected wallet found");
  }

  const provider = new ethers.BrowserProvider(window.ethereum);
  await provider.send("eth_requestAccounts", []);
  const signer = await provider.getSigner();
  const address = await signer.getAddress();

  return { provider, signer, address };
}

Simply connecting a wallet doesn't prove identity though — it just gives you an address. Most production marketplaces implement Sign-In With Ethereum (SIWE) to authenticate the user against their backend session:

javascript
async function signInWithEthereum(signer, address) {
  const nonce = await fetch(`/api/auth/nonce?address=${address}`).then(r => r.text());

  const message = `Sign this message to authenticate.\nNonce: ${nonce}`;
  const signature = await signer.signMessage(message);

  return fetch("/api/auth/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ address, signature, message }),
  });
}

Enter fullscreen mode Exit fullscreen mode

The backend then recovers the signer address from the signature using ethers.verifyMessage() and issues a session token if it matches. This is important: the wallet address is the identity, and the signature is the password — except the password changes every time thanks to the nonce.

*4. Step Two: Minting an NFT
*

Minting is the process of creating a new token on-chain, almost always following the ERC-721 (unique tokens) or ERC-1155 (semi-fungible/multi-edition tokens) standard.

A minimal ERC-721 mint function in Solidity:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

contract MarketplaceNFT is ERC721, Ownable {
    uint256 private _nextTokenId;

    constructor() ERC721("MarketplaceNFT", "MNFT") Ownable(msg.sender) {}

    function mint(address to, string memory uri) external returns (uint256) {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
        return tokenId;
    }
}

Enter fullscreen mode Exit fullscreen mode

Note two things developers often overlook:

_safeMint vs _mint: _safeMint checks that the recipient (if a contract) implements onERC721Received, preventing tokens from getting stuck forever in a contract that can't handle them.
Gas cost of minting: On mainnet Ethereum, minting can cost real money per transaction. This is exactly why most marketplaces today mint on Layer 2 networks or use lazy minting (covered below).
Lazy Minting

Lazy minting defers the actual on-chain mint until the first sale, using off-chain signed vouchers. The creator signs a message describing the NFT (metadata, price, royalty), and the contract only mints when a buyer submits that signed voucher along with payment:

solidity
function redeem(
    address creator,
    uint256 tokenId,
    string memory uri,
    bytes memory signature
) external payable {
    address signer = _verify(creator, tokenId, uri, signature);
    require(signer == creator, "Invalid signature");

    _safeMint(msg.sender, tokenId);
    _setTokenURI(tokenId, uri);
    payable(creator).transfer(msg.value);
}

Enter fullscreen mode Exit fullscreen mode

This means creators can list thousands of NFTs without ever paying gas — the buyer's transaction pays for the mint.

*5. Step Three: Metadata and Storage
*

An NFT token itself is just a tokenId and an owner address on-chain. The actual image, name, description, and attributes live in metadata, typically a JSON file:


json
{
  "name": "Cosmic Drifter #042",
  "description": "A wanderer of the void.",
  "image": "ipfs://Qm.../042.png",
  "attributes": [
    { "trait_type": "Background", "value": "Nebula Purple" },
    { "trait_type": "Rarity", "value": "Legendary" }
  ]
}

Storing this on centralized servers defeats the point of "true ownership"  if the server goes down, the NFT effectively becomes a pointer to nothing. That's why most serious marketplaces pin metadata and assets to IPFS or Arweave, content-addressed storage systems where the file's address is derived from its own hash, making it tamper-evident.

A typical upload flow using an IPFS pinning service SDK:

javascript
async function uploadMetadata(imageFile, attributes) {
  const imageCid = await ipfsClient.add(imageFile);

  const metadata = {
    name: "Cosmic Drifter #042",
    image: `ipfs://${imageCid}`,
    attributes,
  };


  const metadataCid = await ipfsClient.add(JSON.stringify(metadata));
  return `ipfs://${metadataCid}`;
}

Enter fullscreen mode Exit fullscreen mode

That resulting URI is what gets passed into _setTokenURI() during minting.

*6. Step Four: Listing an NFT for Sale
*

Listing is where a lot of marketplaces diverge architecturally. There are two dominant models:

Model A: On-chain Listings

The NFT is transferred (or approved) to a marketplace contract that holds it in escrow until sold.

solidity
function listItem(address nftContract, uint256 tokenId, uint256 price) external {
    IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId);
    listings[nftContract][tokenId] = Listing(msg.sender, price);
    emit ItemListed(msg.sender, nftContract, tokenId, price);
}
Enter fullscreen mode Exit fullscreen mode

Simple and trust-minimized, but it locks the NFT — the seller can't use it elsewhere (staking, lending, displaying in another dApp) while it's listed.

Model B: Off-chain Order Signing (used by most major marketplaces)

The seller signs an "order" off-chain describing what they'll sell and at what price. No transaction, no gas, no locking. The order is stored in a backend/database, and the actual transfer only happens atomically when a buyer fulfills it.

javascript
const domain = {
  name: "MarketplaceExchange",
  version: "1",
  chainId: 1,
  verifyingContract: exchangeAddress,
};

const types = {
  Order: [
    { name: "seller", type: "address" },
    { name: "nftContract", type: "address" },
    { name: "tokenId", type: "uint256" },
    { name: "price", type: "uint256" },
    { name: "expiry", type: "uint256" },
  ],
};

const order = {
  seller: address,
  nftContract: nftAddress,
  tokenId: 42,
  price: ethers.parseEther("0.5"),
  expiry: Math.floor(Date.now() / 1000) + 86400,
};

Enter fullscreen mode Exit fullscreen mode

const signature = await signer.signTypedData(domain, types, order);

This uses EIP-712 typed data signing, which is what gives users a readable, structured confirmation in their wallet instead of a wall of hex — a major UX win, and the standard behind most modern marketplace order books.

*7. Step Five: The Buy Transaction and Escrow Models
*

When a buyer clicks "Buy," the frontend submits the seller's signed order to the exchange contract. The contract verifies the signature, checks the NFT approval is still valid, then atomically swaps NFT for payment in a single transaction:

solidity
function fulfillOrder(
    Order calldata order,
    bytes calldata signature
) external payable {
    require(block.timestamp <= order.expiry, "Order expired");
    require(msg.value >= order.price, "Insufficient payment");

    address recoveredSigner = _recoverSigner(order, signature);
    require(recoveredSigner == order.seller, "Invalid signature");

    IERC721(order.nftContract).transferFrom(order.seller, msg.sender, order.tokenId);
    payable(order.seller).transfer(order.price);

    emit OrderFulfilled(order.seller, msg.sender, order.tokenId);
}
Enter fullscreen mode Exit fullscreen mode

Atomicity here is everything — either both the payment and the NFT transfer succeed, or the entire transaction reverts. There's no scenario where a buyer pays and doesn't receive the NFT, because the EVM guarantees the whole function executes or none of it does.

*8. Step Six: Royalties and the EIP-2981 Standard
*

Creator royalties were one of the most contentious engineering and economic problems in NFT history, since royalties aren't enforced by Ethereum itself — they're a social/technical convention that marketplaces choose to honor.

EIP-2981 standardizes how a contract reports its royalty info so any compliant marketplace can read and pay it automatically:


solidity
function royaltyInfo(uint256 tokenId, uint256 salePrice)
    external
    view
    returns (address receiver, uint256 royaltyAmount)
{
    receiver = creator;
    royaltyAmount = (salePrice * royaltyBps) / 10000;
}

Enter fullscreen mode Exit fullscreen mode

A well-built exchange contract queries this during fulfillOrder() and splits payment accordingly before transferring the remainder to the seller. Marketplaces that skip this check are exactly why "royalty-optional" trading became a major debate in the space — it's a contract-level choice, not a blockchain-level guarantee.

*9. Security Considerations Every Developer Should Know
*

If you're building in this space, here are the failure modes that have burned real projects:

Reentrancy on payment transfer: Always follow checks-effects-interactions, or use ReentrancyGuard from OpenZeppelin when transferring ETH after external calls.
Signature replay: Nonces and expiry timestamps in every signed order prevent an old signature from being reused after cancellation.
Approval scope creep: setApprovalForAll grants blanket access to a user's entire collection. Phishing sites frequently trick users into signing this, then drain wallets. Always prefer per-token approve() where feasible, and clearly surface approval scope in your UI.
Fake collections / metadata spoofing: Verify contract addresses against a canonical registry rather than trusting names or symbols alone, since anyone can deploy a contract called "BoredApe."
Front-running: Public mempools mean a pending "buy" transaction can be seen and copied by bots. Private RPCs or commit-reveal schemes mitigate this for high-value drops.

*10. Scaling: Layer 2s, Gas Optimization, and Indexing
*

Ethereum mainnet gas costs make high-frequency marketplace activity impractical, which is why most production marketplaces today operate on Layer 2 rollups or side chains, offering sub-cent transaction fees while inheriting (or approximating) mainnet security.

On the indexing side, relying on eth_getLogs for every page load doesn't scale. Production systems typically run a dedicated indexing layer — whether a custom event-listening worker writing into Postgres, or a subgraph-style service — so the frontend queries a fast, purpose-built database instead of a blockchain node for every listing page, search, or filter.

Gas optimization patterns worth internalizing as a Solidity developer:

Pack struct variables to minimize storage slots.
Use calldata instead of memory for function parameters that aren't modified.
Batch operations (e.g., batch minting via ERC-1155) instead of looping individual transactions.
Emit events instead of storing redundant data on-chain when the backend can reconstruct state from logs.

*11. Closing Thoughts
*

Building an NFT marketplace is deceptively deep engineering work. It sits at the intersection of cryptography, distributed systems, decentralized storage, and frontend UX — and getting any one layer wrong (a bad approval flow, an unverified signature, a centralized metadata server) undermines the entire premise of "ownership" that these platforms are built to deliver.

For teams that don't want to reinvent this stack from scratch — smart contract architecture, escrow logic, royalty enforcement, IPFS pipelines, and scalable indexing — this is exactly the kind of full-stack Web3 build that a specialized development partner like Devtechnosys takes on end-to-end, from smart contract audits to production-grade marketplace infrastructure. Whether you're prototyping your first ERC-721 drop or architecting a multi-chain trading platform, having engineers who've already solved these problems saves months of trial and error.

If you're building in this space, I'd love to hear what marketplace architecture choices you've made — on-chain listings vs. off-chain order books, which L2 you settled on, and how you're handling royalty enforcement. Drop your thoughts below.

Top comments (0)