erc-721

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

ERC-721 NFT Integration for Scaffold-ETH 2

为Scaffold-ETH 2集成ERC-721 NFT

Prerequisites

前置条件

Check if
./packages/nextjs/scaffold.config.ts
exists directly in the current working directory (do not search subdirectories). If it doesn't exist, this is not a Scaffold-ETH 2 project. Follow the instructions at https://docs.scaffoldeth.io/SKILL.md to scaffold it first. If it exists, continue directly with building.
检查当前工作目录下是否直接存在
./packages/nextjs/scaffold.config.ts
文件(不要搜索子目录)。如果不存在,则这不是一个Scaffold-ETH 2项目,请先按照https://docs.scaffoldeth.io/SKILL.md中的说明搭建项目。如果文件存在,则直接开始构建。

Overview

概述

ERC-721 is the standard interface for non-fungible tokens (NFTs) on Ethereum. This skill covers adding an ERC-721 contract to a Scaffold-ETH 2 project using OpenZeppelin's ERC-721 implementation, along with deployment scripts and a frontend for minting, listing, and transferring NFTs.
For anything not covered here, refer to the OpenZeppelin ERC-721 docs or search the web. This skill focuses on what's hard to discover: SE-2 integration specifics, common pitfalls, and ERC-721 gotchas.
ERC-721是以太坊上非同质化代币(NFT)的标准接口。本技能介绍如何使用OpenZeppelin的ERC-721实现,在Scaffold-ETH 2项目中添加ERC-721合约,同时包含部署脚本以及用于铸造、展示和转账NFT的前端页面。
对于本文未覆盖的内容,请参考OpenZeppelin ERC-721文档或在网络上搜索。本技能重点介绍难以发现的细节:SE-2集成的具体事项、常见陷阱以及ERC-721的注意事项。

Dependencies

依赖项

OpenZeppelin contracts are already included in SE-2's Hardhat and Foundry setups, so no additional dependency installation is needed. If for some reason they're missing:
  • Hardhat:
    @openzeppelin/contracts
    in
    packages/hardhat/package.json
  • Foundry: installed via
    forge install OpenZeppelin/openzeppelin-contracts
    , with remapping
    @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
No new frontend dependencies are required.
SE-2的Hardhat和Foundry环境中已包含OpenZeppelin合约,因此无需安装额外依赖。若因某些原因缺失:
  • Hardhat:在
    packages/hardhat/package.json
    中添加
    @openzeppelin/contracts
  • Foundry:通过
    forge install OpenZeppelin/openzeppelin-contracts
    安装,并添加重映射
    @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
无需新增前端依赖项。

Smart Contract

智能合约

The token contract extends OpenZeppelin's
ERC721
base. Import path:
@openzeppelin/contracts/token/ERC721/ERC721.sol
. The constructor takes a token name and symbol.
Syntax reference for a basic NFT with open minting and IPFS metadata:
solidity
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract MyNFT is ERC721Enumerable {
    uint256 public tokenIdCounter;

    constructor() ERC721("MyNFT", "MNFT") {}

    function mintItem(address to) public returns (uint256) {
        tokenIdCounter++;
        _safeMint(to, tokenIdCounter);
        return tokenIdCounter;
    }

    function tokenURI(uint256 tokenId) public view override returns (string memory) {
        _requireOwned(tokenId);
        return string.concat(_baseURI(), Strings.toString(tokenId));
    }

    function _baseURI() internal pure override returns (string memory) {
        return "ipfs://YourCID/";
    }
}
Adapt the contract based on the user's requirements. Available extensions (all under
@openzeppelin/contracts/token/ERC721/extensions/
):
  • ERC721Enumerable
    : on-chain enumeration of all tokens and per-owner tokens. Enables
    totalSupply()
    ,
    tokenByIndex()
    ,
    tokenOfOwnerByIndex()
    . Convenient but expensive (see gas section below).
  • ERC721URIStorage
    : per-token URI storage via
    _setTokenURI()
    . Emits ERC-4906
    MetadataUpdate
    events in v5.
  • ERC721Burnable
    : lets token owners destroy their NFTs
  • ERC721Pausable
    : admin can freeze all transfers
  • ERC721Votes
    : governance checkpoints, each NFT = 1 vote
  • ERC721Royalty
    : ERC-2981 royalty info (see royalties section below)
  • ERC721Consecutive
    : batch minting during construction (ERC-2309)
See OpenZeppelin's ERC-721 extensions for the full list.
代币合约继承自OpenZeppelin的
ERC721
基类。导入路径:
@openzeppelin/contracts/token/ERC721/ERC721.sol
。构造函数接收代币名称和符号。
以下是带有开放铸造功能和IPFS元数据的基础NFT合约语法示例:
solidity
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract MyNFT is ERC721Enumerable {
    uint256 public tokenIdCounter;

    constructor() ERC721("MyNFT", "MNFT") {}

    function mintItem(address to) public returns (uint256) {
        tokenIdCounter++;
        _safeMint(to, tokenIdCounter);
        return tokenIdCounter;
    }

    function tokenURI(uint256 tokenId) public view override returns (string memory) {
        _requireOwned(tokenId);
        return string.concat(_baseURI(), Strings.toString(tokenId));
    }

    function _baseURI() internal pure override returns (string memory) {
        return "ipfs://YourCID/";
    }
}
可根据用户需求调整合约。可用扩展(均位于
@openzeppelin/contracts/token/ERC721/extensions/
路径下):
  • ERC721Enumerable
    :支持链上枚举所有代币以及每个所有者的代币。提供
    totalSupply()
    tokenByIndex()
    tokenOfOwnerByIndex()
    方法。使用便捷但gas成本较高(见下文gas部分)。
  • ERC721URIStorage
    :通过
    _setTokenURI()
    为每个代币存储独立URI。在v5版本中会触发ERC-4906的
    MetadataUpdate
    事件。
  • ERC721Burnable
    :允许代币持有者销毁自己的NFT
  • ERC721Pausable
    :管理员可冻结所有转账操作
  • ERC721Votes
    :治理检查点,每个NFT等同于1票
  • ERC721Royalty
    :ERC-2981版税信息(见下文版税部分)
  • ERC721Consecutive
    :合约构建期间批量铸造(ERC-2309)
完整扩展列表请查看OpenZeppelin的ERC-721扩展文档

OpenZeppelin v5 changes to be aware of

需要注意的OpenZeppelin v5变更

If referencing older tutorials or code, note these breaking changes in OpenZeppelin v5:
  • _beforeTokenTransfer
    and
    _afterTokenTransfer
    hooks are gone.
    Replaced by a single
    _update(address to, uint256 tokenId, address auth)
    override point that handles mint, transfer, and burn.
  • Custom errors replaced revert strings (e.g.
    ERC721NonexistentToken
    ,
    ERC721InsufficientApproval
    )
  • Ownable
    requires explicit owner
    :
    Ownable(msg.sender)
    instead of
    Ownable()
  • ERC721URIStorage
    now emits ERC-4906
    MetadataUpdate
    events when
    _setTokenURI
    is called
若参考旧教程或代码,请注意OpenZeppelin v5中的以下破坏性变更:
  • _beforeTokenTransfer
    _afterTokenTransfer
    钩子已移除
    ,替换为单个
    _update(address to, uint256 tokenId, address auth)
    重写点,处理铸造、转账和销毁操作。
  • 自定义错误替代了回退字符串(例如
    ERC721NonexistentToken
    ERC721InsufficientApproval
  • Ownable
    需要显式指定所有者
    :使用
    Ownable(msg.sender)
    而非
    Ownable()
  • **
    ERC721URIStorage
    **现在在调用
    _setTokenURI
    时会触发ERC-4906的
    MetadataUpdate
    事件

Metadata: The Part Most People Get Wrong

元数据:多数人容易出错的部分

The metadata JSON schema

元数据JSON schema

ERC-721 metadata follows a standard JSON structure returned by
tokenURI()
:
json
{
  "name": "My NFT #1",
  "description": "Description of the NFT",
  "image": "ipfs://QmImageCID",
  "attributes": [
    { "trait_type": "Color", "value": "Blue" },
    { "trait_type": "Rarity", "value": "Rare" }
  ]
}
The
attributes
array is not in the EIP but is the de facto standard used by OpenSea, Blur, and every other marketplace. Without it, traits won't display.
ERC-721元数据遵循
tokenURI()
返回的标准JSON结构:
json
{
  "name": "My NFT #1",
  "description": "Description of the NFT",
  "image": "ipfs://QmImageCID",
  "attributes": [
    { "trait_type": "Color", "value": "Blue" },
    { "trait_type": "Rarity", "value": "Rare" }
  ]
}
attributes
数组并未在EIP中定义,但却是OpenSea、Blur等所有NFT市场采用的事实标准。缺少该数组,NFT的属性将无法正常显示。

On-chain vs off-chain metadata

链上元数据 vs 链下元数据

FactorOn-chain (base64/SVG)Off-chain (IPFS/Arweave)
PermanencePermanent as long as Ethereum existsDepends on pinning/persistence
Gas costVery expensive (~128KB payload ceiling)Cheap (just store a URI string)
MutabilityImmutable once deployedCan disappear if unpinned
Best forSmall collections, generative artLarge collections, rich media
因素链上(base64/SVG)链下(IPFS/Arweave)
持久性只要以太坊存在就永久保存取决于固定/持久化方式
Gas成本非常高(约128KB payload上限)低廉(仅存储URI字符串)
可变性部署后不可变若未固定则可能消失
适用场景小型集合、生成艺术大型集合、富媒体内容

IPFS gotchas

IPFS的注意事项

About 20% of sampled NFTs have broken or expired metadata links. Common causes:
  • Unpinned data gets garbage collected. IPFS nodes drop data nobody is actively pinning. If the original pinner stops, the data vanishes.
  • Gateway URLs vs protocol URIs. Use
    ipfs://QmCID
    (content-addressed, portable) not
    https://gateway.pinata.cloud/ipfs/QmCID
    (depends on one gateway staying up).
  • Base URI must end with
    /
    .
    OpenZeppelin's
    tokenURI()
    concatenates
    _baseURI() + tokenId.toString()
    . If the base URI is
    ipfs://QmCID
    without a trailing slash, token 42 becomes
    ipfs://QmCID42
    instead of
    ipfs://QmCID/42
    .
  • File naming. If using base URI + token ID, metadata files must be named
    0
    ,
    1
    ,
    2
    etc. (no
    .json
    extension) unless you override
    tokenURI()
    to append it.
For permanent storage, consider Arweave or a paid IPFS pinning service (Pinata, Filebase).
约20%的抽样NFT存在元数据链接损坏或过期的问题,常见原因包括:
  • 未固定的数据会被垃圾回收:IPFS节点会删除无人主动固定的数据。若原始固定者停止操作,数据将消失。
  • 网关URL vs 协议URI:使用
    ipfs://QmCID
    (内容寻址,可移植)而非
    https://gateway.pinata.cloud/ipfs/QmCID
    (依赖单个网关的可用性)。
  • Base URI必须以
    /
    结尾
    :OpenZeppelin的
    tokenURI()
    会将
    _baseURI() + tokenId.toString()
    拼接。若Base URI为
    ipfs://QmCID
    且无尾部斜杠,代币42的URI会变成
    ipfs://QmCID42
    而非
    ipfs://QmCID/42
  • 文件命名:若使用Base URI + 代币ID的方式,元数据文件必须命名为
    0
    1
    2
    等(无
    .json
    后缀),除非重写
    tokenURI()
    方法来添加后缀。
如需永久存储,可考虑Arweave或付费IPFS固定服务(如Pinata、Filebase)。

ERC721Enumerable: Convenient but Expensive

ERC721Enumerable:便捷但成本高昂

ERC721Enumerable maintains four additional data structures that get updated on every mint and transfer. Concrete gas comparison:
  • Minting 5 tokens with ERC721Enumerable: ~566,000 gas
  • Minting 5 tokens with ERC721A: ~104,000 gas (5.5x cheaper)
When to use it: Small collections, learning/demos, when you need on-chain enumeration without an indexer.
When to skip it: Large collections (1k+ tokens), gas-sensitive mints. Use a simple counter for
totalSupply()
and index token ownership off-chain using
Transfer
events (via a subgraph or Ponder, both available as SE-2 skills).
ERC721Enumerable维护四个额外的数据结构,每次铸造和转账时都会更新。具体gas成本对比:
  • 使用ERC721Enumerable铸造5个代币:约566,000 gas
  • 使用ERC721A铸造5个代币:约104,000 gas(便宜5.5倍)
适用场景:小型集合、学习/演示、无需索引器即可实现链上枚举的场景。
不适用场景:大型集合(1000+代币)、对gas成本敏感的铸造。可使用简单计数器实现
totalSupply()
,并通过
Transfer
事件在链下索引代币所有权(可使用SE-2技能中的subgraph或Ponder)。

ERC721A as an alternative

替代方案:ERC721A

ERC721A by Azuki makes batch minting cost nearly the same as minting a single token. A 10-token mint costs ~110,000 gas vs ~1,100,000+ with ERC721Enumerable. It works by lazily initializing ownership: only the first token in a batch gets an ownership record, and later tokens infer ownership by scanning backwards.
Trade-offs:
  • Requires sequential token IDs (no random IDs)
  • First transfer after a batch mint is more expensive (must initialize ownership)
  • Not an OpenZeppelin extension; separate dependency from
    erc721a
    npm package
Azuki开发的ERC721A使批量铸造的成本几乎与铸造单个代币相同。铸造10个代币约需110,000 gas,而使用ERC721Enumerable则需1,100,000+ gas。它通过延迟初始化所有权实现优化:批量铸造中仅第一个代币会创建所有权记录,后续代币通过反向扫描推断所有权。
权衡点:
  • 要求代币ID连续(无随机ID)
  • 批量铸造后的首次转账成本更高(需初始化所有权)
  • 并非OpenZeppelin扩展,需从
    erc721a
    npm包单独安装依赖

Security: The Reentrancy You Didn't Expect

安全性:意想不到的重入漏洞

_safeMint
and
safeTransferFrom
call external code

_safeMint
safeTransferFrom
会调用外部代码

Both
_safeMint
and
safeTransferFrom
invoke
onERC721Received()
on the recipient if it's a contract. This is an external call that happens after the token has been minted/transferred, creating a reentrancy vector.
Real exploit (HypeBears, Feb 2022): The contract tracked per-address minting limits but updated state after
_safeMint
. An attacker's
onERC721Received
callback called
mintNFT
again before the limit was recorded, bypassing the per-address cap entirely.
solidity
// VULNERABLE: state update after _safeMint
function mintNFT() public {
    require(!addressMinted[msg.sender], "Already minted");
    _safeMint(msg.sender, tokenId);          // calls onERC721Received on attacker
    addressMinted[msg.sender] = true;         // too late, attacker already re-entered
}

// SAFE: state update before _safeMint
function mintNFT() public {
    require(!addressMinted[msg.sender], "Already minted");
    addressMinted[msg.sender] = true;         // update state first
    _safeMint(msg.sender, tokenId);
}
Mitigations: Update state before
_safeMint
/
safeTransferFrom
(checks-effects-interactions pattern), or use OpenZeppelin's
ReentrancyGuard
(
nonReentrant
modifier).
_safeMint
safeTransferFrom
若接收方为合约,会调用其
onERC721Received()
方法。这是一个外部调用,发生在代币铸造/转账完成后,会产生重入风险。
真实攻击案例(HypeBears,2022年2月):合约跟踪每个地址的铸造上限,但在
_safeMint
之后才更新状态。攻击者的
onERC721Received
回调在状态更新前再次调用
mintNFT
,完全绕过了地址铸造上限。
solidity
// 存在漏洞:状态更新在_safeMint之后
function mintNFT() public {
    require(!addressMinted[msg.sender], "Already minted");
    _safeMint(msg.sender, tokenId);          // 调用攻击者的onERC721Received
    addressMinted[msg.sender] = true;         // 为时已晚,攻击者已重入
}

// 安全写法:先更新状态
function mintNFT() public {
    require(!addressMinted[msg.sender], "Already minted");
    addressMinted[msg.sender] = true;         // 先更新状态
    _safeMint(msg.sender, tokenId);
}
缓解措施:在
_safeMint
/
safeTransferFrom
之前更新状态(检查-效果-交互模式),或使用OpenZeppelin的
ReentrancyGuard
nonReentrant
修饰器)。

setApprovalForAll
is a dangerous permission

setApprovalForAll
是危险权限

setApprovalForAll(operator, true)
grants an operator control over all of an owner's NFTs in that collection. Phishing attacks trick users into signing this for malicious operators. Once approved, the attacker can transfer away every NFT the victim owns. Most marketplaces require
setApprovalForAll
to list NFTs, which is why phishing is so effective.
setApprovalForAll(operator, true)
会授予操作员控制该所有者在该集合中所有NFT的权限。钓鱼攻击会诱使用户为恶意操作员签署此权限。一旦授权,攻击者可转走受害者拥有的所有NFT。大多数市场要求
setApprovalForAll
才能上架NFT,这也是钓鱼攻击如此有效的原因。

Flash loan governance attacks

闪电贷治理攻击

NFTs used for governance (each NFT = 1 vote) can be manipulated via flash loans: borrow NFTs, vote, return them. Use
ERC721Votes
with checkpoints and voting delays rather than raw
balanceOf()
for governance.
用于治理的NFT(每个NFT等同于1票)可能被闪电贷操纵:借入NFT、投票、归还。治理应使用带检查点和投票延迟的
ERC721Votes
,而非直接使用
balanceOf()

Royalties (ERC-2981)

版税(ERC-2981)

ERC-2981 defines a standard
royaltyInfo(tokenId, salePrice)
function that returns the royalty receiver and amount. OpenZeppelin provides
ERC721Royalty
to implement this.
The critical thing to know: ERC-2981 is advisory, not enforceable. The standard provides an interface for querying royalty info, but nothing forces marketplaces to honor it. Anyone can transfer an NFT via
transferFrom
without paying royalties.
Current marketplace stance:
  • OpenSea: ended mandatory enforcement Aug 2023. Added ERC-721C support Apr 2024 for opt-in on-chain enforcement.
  • Blur: enforces only a 0.5% minimum on most collections.
ERC-721C by Limit Break attempted to solve this by restricting transfers to whitelisted operator contracts. Adoption is growing but not universal.
ERC-2981定义了标准的
royaltyInfo(tokenId, salePrice)
方法,返回版税接收方和金额。OpenZeppelin提供
ERC721Royalty
来实现此标准。
关键须知:ERC-2981为建议性标准,不具备强制性。该标准仅提供查询版税信息的接口,并无机制强制市场遵守。任何人都可通过
transferFrom
转账NFT而无需支付版税。
当前市场立场:
  • OpenSea:2023年8月结束强制版税 enforcement,2024年4月添加ERC-721C支持以实现链上可选强制版税。
  • Blur:仅对大多数集合强制收取0.5%的最低版税。
Limit Break开发的ERC-721C试图通过限制转账至白名单操作员合约来解决此问题。该标准的采用率正在增长,但尚未普及。

Soulbound Tokens (ERC-5192)

灵魂绑定代币(ERC-5192)

For non-transferable NFTs (credentials, memberships, achievements), ERC-5192 adds a minimal
locked(tokenId)
interface. In OpenZeppelin v5, the simplest approach is overriding
_update
:
solidity
function _update(address to, uint256 tokenId, address auth)
    internal override returns (address)
{
    address from = super._update(to, tokenId, auth);
    require(from == address(0) || to == address(0), "Non-transferable");
    return from;
}
对于不可转账的NFT(如凭证、会员资格、成就),ERC-5192添加了极简的
locked(tokenId)
接口。在OpenZeppelin v5中,最简单的实现方式是重写
_update
方法:
solidity
function _update(address to, uint256 tokenId, address auth)
    internal override returns (address)
{
    address from = super._update(to, tokenId, auth);
    require(from == address(0) || to == address(0), "Non-transferable");
    return from;
}

Well-Known NFT Contracts (Ethereum Mainnet)

知名NFT合约(以太坊主网)

CollectionAddressNotes
CryptoPunks (original)
0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB
NOT ERC-721. Pre-dates the standard (June 2017). Custom contract with its own marketplace built in. Had a critical bug where sale ETH was credited to the buyer, not the seller.
Wrapped CryptoPunks
0xb7F7F6C52F2e2fdb1963Eab30438024864c313F6
ERC-721 wrapper around original punks
Bored Ape Yacht Club
0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D
10,000 apes, standard ERC-721
Azuki
0xED5AF388653567Af2F388E6224dC7C4b3241C544
Uses ERC721A for gas-optimized batch minting
Pudgy Penguins
0xBd3531dA5CF5857e7CfAA92426877b022e612cf8
8,888 penguins
集合地址说明
CryptoPunks(原版)
0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB
非ERC-721标准。早于该标准(2017年6月)。自定义合约,内置市场。曾存在严重漏洞:销售ETH会转入买家而非卖家账户。
Wrapped CryptoPunks
0xb7F7F6C52F2e2fdb1963Eab30438024864c313F6
原版CryptoPunks的ERC-721包装合约
Bored Ape Yacht Club
0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D
10,000只猿猴,标准ERC-721合约
Azuki
0xED5AF388653567Af2F388E6224dC7C4b3241C544
使用ERC721A实现gas优化的批量铸造
Pudgy Penguins
0xBd3531dA5CF5857e7CfAA92426877b022e612cf8
8,888只企鹅