WTF-Solidity 深度解析:ERC721 非同质化代币标准——从 EIP/ERC 接口体系到 NFT 发行实战
WTF-Solidity 深度解析:ERC721 非同质化代币标准——从 EIP/ERC 接口体系到 NFT 发行实战
导读
BTC、ETH 属于同质化代币,矿出的第一枚 BTC 与第一万枚 BTC 完全等价;但现实世界中有大量独一无二的资产——房产、古董、虚拟艺术品,无法用同质化代币表达。为此以太坊社区通过 EIP-721 提出 ERC721 标准,用于表示非同质化代币(NFT)。本篇以 WTF-Solidity 仓库第 34 讲(葡萄牙语版位于 Languages/pt-br/34_ERC721/readme.md)为骨架,结合仓库内 ERC721.sol 等源码,逐层拆解 ERC721 的接口体系与主合约实现,并完整演示在 Remix 中发行一枚属于自己的 NFT。读完你将掌握:EIP 与 ERC 的关系、ERC165 接口自检机制、IERC721/IERC721Receiver/IERC721Metadata 三大接口、ERC721 主合约的存储布局与 17+ 个函数的实现原理,以及 0x80ac58cd 等接口 ID 的来历。
EIP 与 ERC:先理清两个概念
本节标题是 ERC721,正文却提到了 EIP721,二者是什么关系?
- EIP(Ethereum Improvement Proposals,以太坊改进提案):由以太坊开发者社区提出的改进提案,是一系列编号文档,类似于互联网上的 IETF RFC。一个 EIP 可以覆盖以太坊生态的任意领域,例如新特性、代币标准(
ERC20、ERC721)、域名注册(ERC26、ERC13)、URI(ERC67)、库/包格式(EIP82)、钱包格式(EIP75、EIP85)等。 - ERC(Ethereum Request for Comments):是 EIP 中负责定义应用层标准的一类,如
ERC20、ERC223、ERC721、ERC777,它们是以太坊生态发展的重要推动力。
因此结论是:EIP 包含 ERC。理解这一点后,下文解释为什么讲 ERC721 之前要先讲 ERC165。
ERC165:合约接口自检机制
通过 ERC165 标准,一个智能合约可以声明自己支持哪些接口,供其他合约查询校验。简而言之,ERC165 用于验证某个合约是否支持 ERC721、ERC1155 等接口。
IERC165 接口只声明了一个函数 supportsInterface,它接收待验证的 interfaceId,若合约实现了该接口则返回 true:
interface IERC165 {
/**
* @dev Retorna true se o contrato implementar o `interfaceId` consultado
*/
function supportsInterface(bytes4 interfaceId) external view returns (bool);
}
仓库中的完整声明见 IERC165.sol。ERC721 主合约对 supportsInterface() 的实现如下:
function supportsInterface(bytes4 interfaceId)
external
pure
override
returns (bool)
{
return
interfaceId == type(IERC721).interfaceId ||
interfaceId == type(IERC165).interfaceId ||
interfaceId == type(IERC721Metadata).interfaceId;
}
当被查询的 interfaceId 是 IERC721、IERC165 或 IERC721Metadata 时返回 true,否则返回 false。对比仓库源码 ERC721.sol 可见,主合约实现比文档示例多了一项 IERC721Metadata 的检查,因为主合约同时实现了元数据接口。
IERC721:ERC721 的核心接口
IERC721 是 ERC721 标准的接口定义,规定了 ERC721 必须实现的基础函数。它使用 tokenId 来表示某个特定的非同质化代币,授权与转移都必须指定 tokenId(而 ERC20 只需指定数量)。完整定义见 IERC721.sol:
interface IERC721 is IERC165 {
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
function balanceOf(address owner) external view returns (uint256 balance);
function ownerOf(uint256 tokenId) external view returns (address owner);
function safeTransferFrom(
address from,
address to,
uint256 tokenId,
bytes calldata data
) external;
function safeTransferFrom(
address from,
address to,
uint256 tokenId
) external;
function transferFrom(
address from,
address to,
uint256 tokenId
) external;
function approve(address to, uint256 tokenId) external;
function setApprovalForAll(address operator, bool _approved) external;
function getApproved(uint256 tokenId) external view returns (address operator);
function isApprovedForAll(address owner, address operator) external view returns (bool);
}
注意:IERC721 is IERC165,即 IERC721 继承自 IERC165,任何实现 IERC721 的合约都必须同时实现 supportsInterface。
三个事件(Events)
- Transfer:转移代币时触发,记录发送方
from、接收方to与tokenId。from为address(0)表示铸造,to为address(0)表示销毁。 - Approval:授权某地址操作某一
tokenId时触发,记录所有者owner、被授权地址approved与tokenId。 - ApprovalForAll:批量授权时触发,记录所有者
owner、操作者operator与授权开关approved(true开启 /false关闭)。
九个函数
balanceOf:返回某地址持有的 NFT 数量。ownerOf:返回某tokenId的所有者地址。transferFrom:普通转移,指定from、to与tokenId。safeTransferFrom:安全转移(若接收方是合约,必须实现ERC721Receiver接口),指定from、to、tokenId。approve:授权另一地址使用自己的 NFT,指定被授权地址与tokenId。getApproved:查询某tokenId被授权给了哪个地址。setApprovalForAll:批量授权,允许operator管理某所有者的全部 NFT。isApprovedForAll:查询某所有者是否将全部 NFT 授权给了另一地址。safeTransferFrom(重载):带data参数的safeTransferFrom重载版本。
IERC721Receiver:防止 NFT 永久丢失的安全阀门
如果接收方是合约且没有实现 ERC721 相关函数,转账过去的 NFT 将永远无法再转出,直接"丢失"。为避免误转,ERC721 提供了 safeTransferFrom(),要求目标合约必须实现 IERC721Receiver 接口才能接收 ERC721,否则整个转账 revert。该接口只有一个函数 onERC721Received(),完整声明见 IERC721Receiver.sol:
// Interface do receptor ERC721: o contrato deve implementar esta interface para receber ERC721 por transferência segura
interface IERC721Receiver {
function onERC721Received(
address operator,
address from,
uint tokenId,
bytes calldata data
) external returns (bytes4);
}
ERC721 主合约通过 _checkOnERC721Received 确保目标合约实现了 onERC721Received()(并要求返回值等于 onERC721Received.selector):
function _checkOnERC721Received(
address from,
address to,
uint tokenId,
bytes memory _data
) private returns (bool) {
if (to.isContract()) {
return
IERC721Receiver(to).onERC721Received(
msg.sender,
from,
tokenId,
_data
) == IERC721Receiver.onERC721Received.selector;
} else {
return true;
}
}
对应的源码在 ERC721.sol。其中 to.isContract() 来自仓库自带的 Address.sol 工具库,其核心实现利用 EVM 汇编指令 extcodesize 检查地址上是否存在合约代码:
library Address {
function isContract(address account) internal view returns (bool) {
uint size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
}
IERC721Metadata:元数据扩展接口
IERC721Metadata 是 ERC721 的扩展接口,定义了三个查询元数据的常用函数(见 IERC721Metadata.sol):
name():返回代币名称。symbol():返回代币符号。tokenURI():根据tokenId查询元数据 URL,这是ERC721特有的函数。
interface IERC721Metadata is IERC721 {
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function tokenURI(uint256 tokenId) external view returns (string memory);
}
主合约中的 tokenURI() 实现采用"baseURI + tokenId"拼接策略(ERC721.sol):
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
require(_owners[tokenId] != address(0), "Token Not Exist");
string memory baseURI = _baseURI();
return bytes(baseURI).length > 0 ? string(abi.encodePacked(baseURI, tokenId.toString())) : "";
}
function _baseURI() internal view virtual returns (string memory) {
return "";
}
数字转字符串依赖仓库自带的 String.sol(移植自 OpenZeppelin Contracts v4.7.0 的 Strings 库),其中的 toString(uint256) 逐位取模把十进制数字写入 bytes 缓冲后再转换为 string。_baseURI() 被声明为 virtual,子合约可以覆写,例如 BAYC 的 baseURI 形如 ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/。
ERC721 主合约:状态变量与函数全景
主合约 ERC721 is IERC721, IERC721Metadata 实现了 IERC721、IERC165、IERC721Metadata 三组接口定义的全部功能。它包含 4 个状态变量和一系列函数,实现非常精简,每个函数的功能都写在代码注释中(完整实现见 ERC721.sol)。
四个状态变量
// Token nome
string public override name;
// Token código
string public override symbol;
// Mapeamento do tokenId para o endereço do proprietário
mapping(uint => address) private _owners;
// mapeamento da quantidade de posições de 'address' para a quantidade de posições em '持仓数量'
mapping(address => uint) private _balances;
// Mapeamento de autorização do tokenID para o endereço autorizado
mapping(uint => address) private _tokenApprovals;
// Mapeamento de autorização em lote do endereço do proprietário para o endereço do operador.
mapping(address => mapping(address => bool)) private _operatorApprovals;
| 状态变量 | 类型 | 作用 |
|---|---|---|
name / symbol |
string |
代币名称与符号,由构造函数初始化 |
_owners |
mapping(uint => address) |
tokenId 到所有者地址的映射 |
_balances |
mapping(address => uint) |
地址到持有数量的映射 |
_tokenApprovals |
mapping(uint => address) |
tokenId 到被授权地址的映射(单项授权) |
_operatorApprovals |
mapping(address => mapping(address => bool)) |
所有者到操作者的批量授权开关 |
构造函数仅做一件事:
constructor(string memory name_, string memory symbol_) {
name = name_;
symbol = symbol_;
}
授权相关函数
setApprovalForAll(operator, approved):写入_operatorApprovals[msg.sender][operator]并发出ApprovalForAll事件。_approve(owner, to, tokenId):私有函数,写入_tokenApprovals[tokenId] = to并发出Approval事件。approve(to, tokenId):公开授权入口,校验msg.sender是所有者或已被批量授权,然后调用_approve。getApproved(tokenId):读取_tokenApprovals,先校验 token 存在。_isApprovedOrOwner(owner, spender, tokenId):判断spender是否是所有者、单 token 被授权人或批量操作者,是转移类函数的前置校验。
转移相关函数
function _transfer(
address owner,
address from,
address to,
uint tokenId
) private {
require(from == owner, "not owner");
require(to != address(0), "transfer to the zero address");
_approve(owner, address(0), tokenId);
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
}
_transfer 内部会先调用 _approve(owner, address(0), tokenId) 清空授权(防止旧授权悬空)。transferFrom 与 safeTransferFrom 都先经过 _isApprovedOrOwner 校验,再分别调用 _transfer 或 _safeTransfer。_safeTransfer 在 _transfer 之后还会调用 _checkOnERC721Received 做安全校验;无参版 safeTransferFrom 是四参版的重载,默认传入空 data。
铸造与销毁
function _mint(address to, uint tokenId) internal virtual {
require(to != address(0), "mint to zero address");
require(_owners[tokenId] == address(0), "token already minted");
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(address(0), to, tokenId);
}
_mint 被声明为 internal virtual,本身不设权限,任何内部调用方都能铸造;文档与源码注释都强调:开发者应覆写它并添加自己的铸造条件(如白名单、价格、总量上限)。主合约还提供了 _burn 用于销毁(_owners 使用 delete 清零、余额减一,并发出 Transfer 到零地址)。
实战:在 Remix 上发行你自己的 NFT
仓库提供了发行示例合约 WTFApe.sol,它继承自 ERC721 并暴露公开的 mint:
contract WTFApe is ERC721{
constructor(string memory name_, string memory symbol_) ERC721(name_, symbol_){
}
//O baseURI do BAYC é ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/
function _baseURI() internal pure override returns (string memory) {
//QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/
}
// Função de construção
function mint(address to, uint tokenId) external {
require(tokenId >= 0 && tokenId < MAX_APES, "tokenId out of range");
_mint(to, tokenId);
}
}
操作步骤如下:
- 编译:在 Remix 中按正确顺序编译
ERC721与WTFApe合约(ERC721.sol通过 import 引入五个依赖文件,见 ERC721.sol)。 - 部署:点击 deploy 按钮,构造函数参数
name_与symbol_都填入"WTF",点击transact完成部署。部署成功后合约面板会列出全部公开函数与事件,合约详情与部署界面见下图。
- 铸造(mint):在
mint函数区域点击右侧下拉按钮,输入你的账户地址与tokenId(例如0),点击mint即可把编号为 0 的 "WTF" NFT 铸造给自己。打开右侧调试(Debug)面板可以看到本次交易的详细日志,其中四项关键信息是:- 事件
Transfer - 铸造来源地址
0x0000000000000000000000000000000000000000(零地址,代表"无中生有") - 接收地址
0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 tokenId为0
- 事件
- 验证余额:调用
balanceOf(),输入你的地址,返回1,说明铸造成功。 - 验证所有权:调用
ownerOf(),输入tokenId(0),返回你的账户地址,查询结果正确。
说明:仓库内 WTFApe.sol 的
_baseURI()函数体为空(baseURI 被注释掉),mint()中还引用了未定义的常量MAX_APES,因此该文件带有教程占位痕迹、未必能直接编译运行;实际练习中建议自行实现_baseURI()并补充总量校验。另外,文档代码示例使用pragma solidity ^0.8.21,而仓库内实际源码文件为^0.8.34,二者均为 0.8 系列,接口与语法保持一致。
深入解析:ERC165 与 ERC721 的协作原理
为什么本讲从 ERC165 讲起而非直接讲 ERC721?因为 ERC721 的整个安全体系建立在"接口自检"之上。
回顾前面的内容:为防止 NFT 被转给无法处理它的合约,目标合约必须实现 ERC721TokenReceiver 接口:
interface ERC721TokenReceiver {
function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes _data) external returns(bytes4);
}
在编程世界里,无论是 Java 的 interface、Rust 的 trait,还是 Solidity 的接口,含义一致:接口是一组行为(在 Solidity 中等价于一组函数选择器),某类型实现了接口,就表明它具备该功能。因此当合约实现 onERC721Received 时,等于声明"我有管理 NFT 的能力"。而 ERC721 正是通过 ERC165 来校验目标合约是否真的实现了该函数。
那么 ERC165 究竟做了什么?它是一套"声明合约实现了哪些接口"的技术标准。当一个合约要与另一合约交互时,可以先用 ERC165 查询对方是否具备所需能力。以 ERC721 为例,验证某合约是否为 ERC721 的标准流程是:第一步,验证它实现了 ERC165;第二步,验证它实现了目标接口(这里是 IERC721)。
IERC721 的 ERC165 接口 ID 是 0x80ac58cd,它由 EIP-165 规定的公式计算而来——对该接口全部函数选择器(如 ERC721.Transfer.selector、ERC721.Approval.selector … ERC721.isApprovedForAll.selector)逐个 keccak256 后做异或,再取 bytes4:
bytes4(keccak256(ERC721.Transfer.selector) ^ keccak256(ERC721.Approval.selector) ^ ... ^ keccak256(ERC721.isApprovedForAll.selector))
因此,当某合约实现了 ERC165,且向其 supportsInterface(0x80ac58cd) 返回 true,即可判定它是 ERC721。常见接口 ID 速查:
| 接口 ID | 对应接口 |
|---|---|
0x01ffc9a7 |
ERC165 |
0x80ac58cd |
ERC721 |
0x5b5e139f |
ERC721Metadata |
0x780e9d63 |
ERC721Enumerable |
由于 supportsInterface 声明为 virtual,合约可以继承主合约并扩展新的接口。例如实现 ERC721Enumerable 后重写 supportsInterface:
function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) {
return
interfaceId == 0x01ffc9a7 || // ERC165 Interface ID for ERC165
interfaceId == 0x80ac58cd || // ERC165 Interface ID for ERC721
interfaceId == 0x5b5e139f || // ERC165 Interface ID for ERC721Metadata
interfaceId == 0x780e9d63; // ERC165 Interface ID for ERC721Enumerable
}
优雅、简洁、可扩展性极强,这正是 ERC165 先于 ERC721 讲解的原因。
结语
本讲系统介绍了 ERC721 标准、其接口体系与主合约实现(源码中带有完整注释,位于 Languages/pt-br/34_ERC721 目录),并使用 ERC721 免费铸造了一枚名为 "WTF APE" 的 NFT(元数据直接沿用 BAYC 的 baseURI)。ERC721 标准仍在持续演进,目前最流行的方向是:ERC721Enumerable(提升 NFT 的可发现性)与 ERC721A(降低批量铸造的 gas 成本)。理解本节的四层接口(IERC165 → IERC721 → IERC721Metadata/IERC721Receiver)与接口自检机制,是后续阅读 OpenZeppelin 生产级实现、编写安全 NFT 合约的基础。


