WTF-Solidity 深度解析:ERC721 非同质化代币标准——从 EIP/ERC 接口体系到 NFT 发行实战

原创2026-09-15 16:58:411,455 阅读
文章标签:示例工程区块链

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);
    }
}

操作步骤如下:

  1. 编译:在 Remix 中按正确顺序编译 ERC721 与 WTFApe 合约(ERC721.sol 通过 import 引入五个依赖文件,见 ERC721.sol)。
  2. 部署:点击 deploy 按钮,构造函数参数 name_ 与 symbol_ 都填入 "WTF",点击 transact 完成部署。部署成功后合约面板会列出全部公开函数与事件,合约详情与部署界面见下图。

WTFApe 合约的元数据详情(名称 WTF、符号 WTF)

Remix 中部署 WTFApe 合约并传入构造函数参数 WTF / WTF

  1. 铸造(mint):在 mint 函数区域点击右侧下拉按钮,输入你的账户地址与 tokenId(例如 0),点击 mint 即可把编号为 0 的 "WTF" NFT 铸造给自己。打开右侧调试(Debug)面板可以看到本次交易的详细日志,其中四项关键信息是:
    • 事件 Transfer
    • 铸造来源地址 0x0000000000000000000000000000000000000000(零地址,代表"无中生有")
    • 接收地址 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
    • tokenId 为 0

铸造 WTF NFT 时的交易日志:Transfer 事件、零地址来源、接收地址与 tokenId 0

  1. 验证余额:调用 balanceOf(),输入你的地址,返回 1,说明铸造成功。
  2. 验证所有权:调用 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 合约的基础。

登录后查看全文
WTF-Solidity