ERC-721 (NFTs)
ERC-721 is the standard interface for non-fungible tokens (NFTs): unique, indivisible tokens with provable ownership. Almost every NFT project on Ethereum implements it via OpenZeppelin’s battle-tested base contract — rolling your own is asking for an audit finding.
OpenZeppelin, minting, metadata, royalties
EXAMPLE
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
// 1) Bare-bones collection
contract MyNFT is ERC721, ERC721Enumerable, ERC721URIStorage, ERC2981, Ownable {
uint256 public nextTokenId;
uint256 public constant MAX_SUPPLY = 10_000;
uint256 public mintPrice = 0.05 ether;
string private _baseTokenURI;
bytes32 public allowlistRoot;
mapping(address => uint256) public minted;
constructor(string memory baseURI, address royaltyReceiver, uint96 royaltyBps)
ERC721("MyCollection", "MYC")
Ownable(msg.sender)
{
_baseTokenURI = baseURI;
_setDefaultRoyalty(royaltyReceiver, royaltyBps); // EIP-2981 royalties
}
// ─── MINTING ───────────────────────────────────────────────
function publicMint(uint256 qty) external payable {
require(qty > 0 && qty <= 5, "qty");
require(nextTokenId + qty <= MAX_SUPPLY, "sold out");
require(msg.value >= mintPrice * qty, "insufficient eth");
for (uint256 i; i < qty; ++i) {
unchecked { _safeMint(msg.sender, nextTokenId++); }
}
}
function allowlistMint(uint256 qty, bytes32[] calldata proof) external payable {
require(qty > 0 && qty + minted[msg.sender] <= 2, "limit per wallet");
require(nextTokenId + qty <= MAX_SUPPLY, "sold out");
require(msg.value >= (mintPrice * qty) / 2, "insufficient eth");
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
require(MerkleProof.verify(proof, allowlistRoot, leaf), "not on allowlist");
minted[msg.sender] += qty;
for (uint256 i; i < qty; ++i) {
unchecked { _safeMint(msg.sender, nextTokenId++); }
}
}
function ownerMint(address to, uint256 qty) external onlyOwner {
require(nextTokenId + qty <= MAX_SUPPLY, "sold out");
for (uint256 i; i < qty; ++i) {
unchecked { _safeMint(to, nextTokenId++); }
}
}
// ─── ADMIN ─────────────────────────────────────────────────
function setBaseURI(string calldata baseURI) external onlyOwner { _baseTokenURI = baseURI; }
function setAllowlistRoot(bytes32 root) external onlyOwner { allowlistRoot = root; }
function setMintPrice(uint256 price) external onlyOwner { mintPrice = price; }
function setDefaultRoyalty(address receiver, uint96 bps) external onlyOwner {
_setDefaultRoyalty(receiver, bps);
}
function withdraw() external onlyOwner {
(bool ok, ) = msg.sender.call{value: address(this).balance}("");
require(ok, "withdraw failed");
}
// ─── INTERNAL OVERRIDES ────────────────────────────────────
function _baseURI() internal view override returns (string memory) { return _baseTokenURI; }
// Required overrides — ERC721 extensions stack on each other
function _update(address to, uint256 tokenId, address auth)
internal override(ERC721, ERC721Enumerable) returns (address)
{ return super._update(to, tokenId, auth); }
function _increaseBalance(address account, uint128 value)
internal override(ERC721, ERC721Enumerable)
{ super._increaseBalance(account, value); }
function tokenURI(uint256 tokenId)
public view override(ERC721, ERC721URIStorage) returns (string memory)
{ return super.tokenURI(tokenId); }
function supportsInterface(bytes4 iid)
public view override(ERC721, ERC721Enumerable, ERC721URIStorage, ERC2981) returns (bool)
{ return super.supportsInterface(iid); }
}
// 2) Metadata — JSON shape (off-chain)
// {
// "name": "My NFT #42",
// "description": "A unique token from My Collection.",
// "image": "ipfs://Qm…/42.png",
// "attributes": [
// { "trait_type": "Color", "value": "Blue" },
// { "trait_type": "Edition", "value": 42, "max_value": 10000 }
// ]
// }
//
// Where the metadata LIVES is the security story:
// • IPFS (CID-addressed) + pin via multiple providers — immutable but availability-dependent
// • Arweave — permanent storage; one-time fee
// • Centralised server (yourdomain.com/tokens/42.json) — convenient but reversible
//
// Pattern: deploy with a placeholder baseURI; flip to real CID after reveal.
// 3) tokenURI vs baseURI
// • ERC721 default: tokenURI = baseURI + tokenId
// baseURI = 'ipfs://Qm…/' -> tokenURI(42) = 'ipfs://Qm…/42'
// • ERC721URIStorage: per-token URIs (use _setTokenURI in mint) — for one-of-one collections
// 4) Royalties — EIP-2981
// Marketplaces (OpenSea, LooksRare, Magic Eden) call royaltyInfo(tokenId, salePrice) on the contract.
// Set default royalty in the constructor; override per-token if needed:
// _setTokenRoyalty(tokenId, receiver, royaltyBps);
// Note: Royalty enforcement is voluntary; some marketplaces ignore it. Don't rely on it for primary revenue.
// 5) Common minting patterns
// • Public mint with price + max-per-wallet
// • Allowlist via Merkle proof (gas-efficient vs storing addresses on-chain)
// • Dutch auction — declining price over time
// • Free claim for snapshot of holders
// • Free mint with payable createBatch by the deployer
//
// Choose ONE; mixing increases complexity and attack surface.
// 6) Avoid these mistakes
// • _mint instead of _safeMint — won't notify ERC721Receivers; OK for EOAs only
// • Storing massive on-chain JSON metadata — use IPFS
// • Setting royalty bps > 1000 — most marketplaces cap it; review their rules
// • Forgetting onlyOwner on admin functions — anyone can change baseURI
// • call{value: amount}('') without checking success in payable functions
// • Reentrancy: any external call after state changes; for safer mint, use a ReentrancyGuard if you transfer ETH back
// • Using tx.origin for auth — always msg.sender
// • Spending gas on enumerable when you don't need it (totalSupply, tokenByIndex) — drop Enumerable to save gas
// 7) Reveal pattern
// • Deploy with placeholder URI ('ipfs://Qm…/unrevealed.json')
// • Compute reveal hash off-chain; commit on-chain
// • After mint complete, set baseURI to the real CID
// • Optional: shuffle on-chain with VRF (Chainlink) so the trait distribution can't be picked
// 8) Frontend integration (ethers v6)
import { ethers } from 'ethers';
import abi from './MyNFT.abi.json';
const provider = new ethers.BrowserProvider((window).ethereum);
const signer = await provider.getSigner();
const nft = new ethers.Contract(addr, abi, signer);
await nft.publicMint(2, { value: ethers.parseEther('0.10') });
const balance = await nft.balanceOf(await signer.getAddress());
const tokenId = await nft.tokenOfOwnerByIndex(await signer.getAddress(), 0);
const uri = await nft.tokenURI(tokenId);
const meta = await fetch(uri.replace('ipfs://', 'https://ipfs.io/ipfs/')).then((r) => r.json());
// 9) Trading marketplaces
// • OpenSea, LooksRare, Magic Eden, Blur — read tokenURI + royaltyInfo
// • For your collection to appear automatically, ensure standard ERC721 + ERC165 interface support
// • Verify the contract on Etherscan (or your chain explorer); upload sources after deploy
// 10) Gas optimisation
// • Skip Enumerable if you don't need on-chain enumeration (saves 50-200k gas per mint)
// • Use ERC721A for batch-mint efficiency (Azuki's implementation, very gas-cheap on bulk mint)
// • Use Solady or Solmate for minimal-overhead implementations
// • Pack state into one slot when possible (address + small uints)
// • Use unchecked blocks for counters that can't overflow
// 11) Cross-chain NFTs
// • Mint on L2 (Base, Arbitrum, Optimism) — much cheaper for users
// • LayerZero / Hyperlane for cross-chain transfers
// • Pay attention to bridging — every chain hop is an attack surface
// 12) Audits + security
// • OpenZeppelin's audited contracts are the safe baseline
// • Run Slither + Echidna for static + property testing
// • Use Foundry for fuzzing critical functions
// • Consider a formal audit for any contract with > 100 ETH potential exposure
// • Bug bounty (Immunefi, Code4rena) before public launch
// 13) Common bugs
// • Public mint without per-wallet cap → one bot drains the entire supply
// • Allowlist Merkle root not refreshed → can't add new allowees later (deploy a new contract or use a settable root)
// • baseURI set to a centralised server → if it goes down, all metadata 404s
// • Hard-coded gas limits in withdraw() → fails on contracts with complex receive logic; use call with no gas cap
// • Forgetting _safeMint — minting to a contract that can't receive ERC721 burns the token
// • Mintable by anyone via missing onlyOwner — instant supply takeover
// • _setTokenRoyalty called on a non-existent token — silently noops; verify after deployment
// • Reentrancy in mint when refunding ETH → use ReentrancyGuard or send refunds last
Why it matters
Stand on OpenZeppelin’s shoulders — don’t hand-roll ERC721. Add per-wallet caps, allowlist via Merkle proofs, EIP-2981 royalties, and a reveal pattern with a settable baseURI. Use IPFS or Arweave for metadata, audit anything serious, and lean on ERC721A or Solady when gas matters — bulk mints on the vanilla implementation get expensive fast.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract MyNFT is ERC721 {
uint256 private _next;
constructor() ERC721("MyNFT", "MNFT") {}
function mint(address to) external returns (uint256 id) {
id = ++_next; _safeMint(to, id);
}
}
Try it Yourself »
Discussion
Loading…