w3-kitdocs

buy-nft

Demonstrates the NFT purchase flow: approve-then-transfer on EVM (ERC-721), and token account transfer on Solana.

evmsolana

Dependencies

viem@solana/web3.js
buy-nft/evm.tsx
"use client";

import { useAccount, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { useState } from "react";

// ★ ERC-721 approve + transferFrom pattern
// This is the marketplace-agnostic flow: seller approves, buyer calls transferFrom
// Real marketplaces (OpenSea, Blur) wrap this in an escrow/order contract
const erc721Abi = [
  {
    name: "approve",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "to", type: "address" },
      { name: "tokenId", type: "uint256" },
    ],
    outputs: [],
  },
  {
    name: "transferFrom",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "from", type: "address" },
      { name: "to", type: "address" },
      { name: "tokenId", type: "uint256" },
    ],
    outputs: [],
  },
  {
    name: "ownerOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "tokenId", type: "uint256" }],
    outputs: [{ name: "", type: "address" }],
  },
] as const;

// ★ Step 1: Seller approves the buyer (or a marketplace contract) to transfer
export function ApproveNFT() {
  const [contractAddress, setContractAddress] = useState("");
  const [approved, setApproved] = useState("");
  const [tokenId, setTokenId] = useState("");
  const { writeContract, data: hash, isPending } = useWriteContract();
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash });

  return (
    <div>
      <h2>Step 1: Approve NFT Transfer (Seller)</h2>
      <input value={contractAddress} onChange={(e) => setContractAddress(e.target.value)} placeholder="NFT contract (0x...)" />
      <input value={approved} onChange={(e) => setApproved(e.target.value)} placeholder="Buyer or marketplace address (0x...)" />
      <input value={tokenId} onChange={(e) => setTokenId(e.target.value)} placeholder="Token ID" />
      <button
        onClick={() => writeContract({
          address: contractAddress as `0x${string}`,
          abi: erc721Abi,
          functionName: "approve",
          args: [approved as `0x${string}`, BigInt(tokenId)],
        })}
        disabled={isPending}
      >
        {isPending ? "Approving..." : "Approve"}
      </button>
      {isConfirming && <p>Confirming...</p>}
      {isSuccess && <p>Approved! Tx: {hash}</p>}
    </div>
  );
}

// ★ Step 2: Buyer calls transferFrom to claim the NFT
export function BuyNFT() {
  const { address } = useAccount();
  const [contractAddress, setContractAddress] = useState("");
  const [seller, setSeller] = useState("");
  const [tokenId, setTokenId] = useState("");
  const { writeContract, data: hash, isPending } = useWriteContract();
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash });

  return (
    <div>
      <h2>Step 2: Buy NFT (Buyer)</h2>
      <p><em>Requires seller to have approved you first.</em></p>
      <input value={contractAddress} onChange={(e) => setContractAddress(e.target.value)} placeholder="NFT contract (0x...)" />
      <input value={seller} onChange={(e) => setSeller(e.target.value)} placeholder="Seller address (0x...)" />
      <input value={tokenId} onChange={(e) => setTokenId(e.target.value)} placeholder="Token ID" />
      <button
        onClick={() => writeContract({
          address: contractAddress as `0x${string}`,
          abi: erc721Abi,
          functionName: "transferFrom",
          args: [seller as `0x${string}`, address as `0x${string}`, BigInt(tokenId)],
        })}
        disabled={isPending}
      >
        {isPending ? "Buying..." : "Buy (Transfer to Me)"}
      </button>
      {isConfirming && <p>Confirming...</p>}
      {isSuccess && <p>NFT transferred! Tx: {hash}</p>}
    </div>
  );
}

Buy NFT — Learn

How NFT marketplaces work

At the protocol level, buying an NFT is a token transfer. The complexity lies in making that transfer trustless — the buyer shouldn't pay without receiving the NFT, and the seller shouldn't give up the NFT without receiving payment.

Marketplaces solve this with escrow:

Simple (risky): Buyer sends ETH → Seller sends NFT  (two separate txs — seller can ghost)

Marketplace (safe):
  Seller approves marketplace contract
  Buyer calls marketplace.buy() with ETH value
  Contract atomically: transfers ETH to seller + transfers NFT to buyer
  One transaction — either both happen or neither does

EVM: The approve + transferFrom pattern

How approval works

// Seller calls this
function approve(address to, uint256 tokenId) external {
    require(ownerOf(tokenId) == msg.sender);
    _tokenApprovals[tokenId] = to; // Anyone can read this
}

// Marketplace contract calls this
function transferFrom(address from, address to, uint256 tokenId) external {
    require(
        msg.sender == from ||
        msg.sender == _tokenApprovals[tokenId] || // ★ approved party can transfer
        isApprovedForAll(from, msg.sender)
    );
    _transfer(from, to, tokenId);
}

setApprovalForAll

Instead of approving token by token, sellers can approve all their tokens to a marketplace:

nft.setApprovalForAll(marketplaceAddress, true);
// Now the marketplace can transfer any of your NFTs
// This is what "listing on OpenSea" does

This is convenient but grants the marketplace broad authority over your NFTs.

How a marketplace buy works

// Simplified marketplace contract
function buy(address nftContract, uint256 tokenId) external payable {
    Listing memory listing = listings[nftContract][tokenId];
    require(msg.value >= listing.price, "Insufficient payment");

    // Atomic: payment + transfer in one tx
    IERC721(nftContract).transferFrom(listing.seller, msg.sender, tokenId);
    payable(listing.seller).transfer(listing.price);

    emit NFTSold(nftContract, tokenId, listing.seller, msg.sender, listing.price);
}

Royalties

EIP-2981 (EVM)

// On the NFT contract
function royaltyInfo(uint256 tokenId, uint256 salePrice)
    external view returns (address receiver, uint256 royaltyAmount)
{
    return (creatorAddress, salePrice * royaltyBps / 10000);
}

EIP-2981 is informational — it says "here's how much royalty is owed." Enforcement is up to the marketplace. Some marketplaces (like Blur) made royalties optional, causing controversy in the NFT space.

Metaplex royalties (Solana)

{
  "seller_fee_basis_points": 500, // 5% royalty
  "properties": {
    "creators": [{ "address": "CreatorWallet...", "share": 100 }]
  }
}

Similarly, Metaplex royalties are enforced by marketplaces, not the protocol. The Metaplex Programmable NFTs (pNFTs) standard was introduced to allow on-chain royalty enforcement via transfer hooks.

Solana: Escrow-based buying

On Solana, the common pattern is:

1. Seller: deposit NFT into marketplace escrow account
2. Seller: create a listing account with price + terms
3. Buyer: call marketplace.executeSale() with SOL
4. Program: atomically releases NFT to buyer + SOL to seller
5. Program: closes escrow account, returns rent to seller

The NFT physically sits in an escrow token account between listing and sale. This is more capital-efficient than EVM approvals because the NFT is locked — you can't accidentally list the same NFT on two marketplaces.

Auction patterns

Beyond fixed-price sales, common auction types:

TypeHow it worksExample
English (ascending)Bids increase, highest wins at deadlineeBay style
Dutch (descending)Price drops over time, first buyer winsArt Blocks
Reserve priceSale only happens if minimum bid metMost NFT auctions
FCFS (fixed price)First come, first servedMint events

Each requires different smart contract logic, but all build on the same approve/transfer primitives.