WTF Solidity 极简入门:ERC721 专题第 3 讲——ERC721 主合约 6 个状态变量与 28 个函数全解

📅 发布时间:2026/9/15 16:14:46
WTF Solidity 极简入门:ERC721 专题第 3 讲——ERC721 主合约 6 个状态变量与 28 个函数全解
WTF Solidity 极简入门ERC721 专题第 3 讲——ERC721 主合约 6 个状态变量与 28 个函数全解【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity本讲是 WTF Solidity「ERC721 专题」的第三讲完整拆解 ERC721 主合约6 个状态变量_name、_symbol、_owners、_balances、_tokenApprovals、_operatorApprovals与全部 28 个函数查询、授权、转账、铸造、销毁、安全校验与钩子。读完本篇你将能独立读懂任何基于 OpenZeppelin ERC721 实现的 NFT 项目源码并学会如何包装一个属于自己的mint函数来发行 NFT。一、ERC721 主合约的整体架构在专题前两讲中我们已经介绍了 ERC721 主合约引用的相关库Address、Context、Strings和相关接口IERC165、IERC721、IERC721Receiver、IERC721Metadata。本讲终于轮到主合约本身。仓库中的完整实现位于 Topics/ERC721/ERC721.sol基于 OpenZeppelin Contractslast updated v4.5.0并带有全中文注释。它的声明如下contract ERC721 is Context, ERC165, IERC721, IERC721Metadata { using Address for address; using Strings for uint256; ... }从源码结构看主合约通过继承与组合共引用了 7 个文件import ./IERC721.sol; import ./IERC721Receiver.sol; import ./IERC721Metadata.sol; import ./Address.sol; import ./Context.sol; import ./Strings.sol; import ./ERC165.sol;其中IERC721、IERC721Metadata是接口合约声明了对外暴露的函数签名IERC721Metadata还继承了IERC721IERC721Receiver是安全转账的接收方接口ERC165是 EIP-165 接口检测的实现Topics/ERC721/ERC165.solAddress、Context、Strings是三个工具库分别提供合约地址判断isContract()、msg.sender/msg.data的封装_msgSender()/_msgData()、以及uint256转string的toString()。理解这层依赖关系是读懂后面 6 个状态变量和 28 个函数的前提主合约自身只负责状态与流程而把底层能力委托给这些库与父合约。二、6 个状态变量ERC721 全部数据的家底ERC721 主合约的全部状态存储都收敛在 6 个私有变量上见 Topics/ERC721/ERC721.sol#L23-L45// 代币名称 string private _name; // 代币代号 string private _symbol; // tokenId到owner地址Mapping mapping(uint256 address) private _owners; // owner地址到持币数量Mapping mapping(address uint256) private _balances; // tokenId到授权地址Mapping mapping(uint256 address) private _tokenApprovals; // owner地址到是否批量批准Mapping mapping(address mapping(address bool)) private _operatorApprovals;逐一定位如下变量类型作用_namestring代币名称如 WTF Ape_symbolstring代币代号如 WTFAPE_ownersmapping(uint256 address)记录每个tokenId的持有人_balancesmapping(address uint256)记录每个地址持有该系列 NFT 的数量_tokenApprovalsmapping(uint256 address)记录每个tokenId被单独授权给了哪个地址_operatorApprovalsmapping(address mapping(address bool))记录每个owner是否把全部 NFT 批量授权给operator两个 Mapping 需要特别留意的点_owners是存在性的唯一判定依据合约没有单独的_exists状态变量而是通过_owners[tokenId] ! address(0)判断一个 token 是否已铸造。这解释了为什么 0 地址永远不能成为 NFT 持有人否则会破坏存在性判断。批量授权_operatorApprovals风险最高它授权的是这个地址持有的这个系列的全部 NFT被授权的operator可以随意支配你钱包里该系列的所有 NFT而不只是某一个tokenId。这也是钓鱼攻击最常诱导用户调用的函数务必谨慎授权。三、公开查询函数对外只读接口3.1supportsInterface接口检测实现IERC165的接口检测能力Topics/ERC721/ERC721.sol#L60-L65function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { return interfaceId type(IERC721).interfaceId || interfaceId type(IERC721Metadata).interfaceId || super.supportsInterface(interfaceId); }它依次检查IERC721与IERC721Metadata的interfaceId都不匹配时再交给父合约ERC165判断IERC165自身。因此该合约对外宣称支持 3 个接口IERC721、IERC721Metadata、IERC165。市场与钱包如 OpenSea、MetaMask调用supportsInterface来确认合约是否真的符合 ERC721 规范。3.2balanceOf与ownerOf持有人查询function balanceOf(address owner) public view virtual override returns (uint256) { require(owner ! address(0), ERC721: balance query for the zero address); return _balances[owner]; } function ownerOf(uint256 tokenId) public view virtual override returns (address) { address owner _owners[tokenId]; require(owner ! address(0), ERC721: owner query for nonexistent token); return owner; }balanceOf查询某地址持有的 NFT 数量。注意它显式禁止查询 0 地址并在该地址下直接返回_balances[owner]不存在的地址默认余额为 0。ownerOf查询某个tokenId的主人。若_owners[tokenId]为 0 地址说明该 token 不存在直接revert。3.3name、symbol元数据基础信息function name() public view virtual override returns (string memory) { return _name; } function symbol() public view virtual override returns (string memory) { return _symbol; }这两个函数来自IERC721Metadata扩展接口直接返回构造函数设置的名称与代号实现非常简单。3.4tokenURI与_baseURINFT 图片从哪来function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { require(_exists(tokenId), ERC721Metadata: URI query for nonexistent token); string memory baseURI _baseURI(); return bytes(baseURI).length 0 ? string(abi.encodePacked(baseURI, tokenId.toString())) : ; } function _baseURI() internal view virtual returns (string memory) { return ; }tokenURI是 OpenSea、MetaMask 等平台展示 NFT 图片所调用的函数。它的逻辑是先校验 token 存在取_baseURI()作为基础路径若基础路径非空则用abi.encodePacked拼接baseURI tokenId.toString()这里用到了专题第一讲介绍过的Strings.toString库见 Topics/ERC721/1_related_libraries/readme.md否则返回空字符串。_baseURI()默认返回空字符串刻意设计成internal virtual供子合约重写。NFT 项目通常在此返回自己的元数据域名例如https://ipfs.io/ipfs/Qm.../从而让所有 token 自动拼出完整 URI无需逐个存储。四、授权机制单币授权与批量授权ERC721 的授权分两层单币授权approve与批量授权setApprovalForAll分别对应状态变量_tokenApprovals与_operatorApprovals。4.1 公开授权接口function approve(address to, uint256 tokenId) public virtual override { address owner ERC721.ownerOf(tokenId); require(to ! owner, ERC721: approval to current owner); require( _msgSender() owner || isApprovedForAll(owner, _msgSender()), ERC721: approve caller is not owner nor approved for all ); _approve(to, tokenId); } function getApproved(uint256 tokenId) public view virtual override returns (address) { require(_exists(tokenId), ERC721: approved query for nonexistent token); return _tokenApprovals[tokenId]; } function setApprovalForAll(address operator, bool approved) public virtual override { _setApprovalForAll(_msgSender(), operator, approved); } function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) { return _operatorApprovals[owner][operator]; }approve(to, tokenId)把某个tokenId的操作权授予to。前置条件to不能是当前 owner调用者必须是 owner 本人或已被 owner 批量授权的 operator注意这里通过_msgSender()而非裸用msg.sender以兼容 GSN 等元交易场景。getApproved(tokenId)查询某个 token 当前的授权地址。setApprovalForAll(operator, approved)把自己的全部 NFT 授权给operator授权人固定为_msgSender()。isApprovedForAll(owner, operator)查询 owner 是否对 operator 做过批量授权。4.2 内部授权实现function _approve(address to, uint256 tokenId) internal virtual { _tokenApprovals[tokenId] to; emit Approval(ERC721.ownerOf(tokenId), to, tokenId); } function _setApprovalForAll( address owner, address operator, bool approved ) internal virtual { require(owner ! operator, ERC721: approve to caller); _operatorApprovals[owner][operator] approved; emit ApprovalForAll(owner, operator, approved); }_approve直接写_tokenApprovals[tokenId] to并释放Approval事件。因为一个 token 同一时刻只有一个授权地址把to设为 0 地址即等价于清除授权——这正是转账与销毁函数内部清空授权的惯用手段。_setApprovalForAll禁止 owner 授权给自己owner ! operator写入二维 Mapping 并释放ApprovalForAll事件。五、转账函数普通转账与安全转账5.1 公开转账入口function transferFrom( address from, address to, uint256 tokenId ) public virtual override { require(_isApprovedOrOwner(_msgSender(), tokenId), ERC721: transfer caller is not owner nor approved); _transfer(from, to, tokenId); } function safeTransferFrom( address from, address to, uint256 tokenId ) public virtual override { safeTransferFrom(from, to, tokenId, ); } function safeTransferFrom( address from, address to, uint256 tokenId, bytes memory _data ) public virtual override { require(_isApprovedOrOwner(_msgSender(), tokenId), ERC721: transfer caller is not owner nor approved); _safeTransfer(from, to, tokenId, _data); }两者唯一的差别在于transferFrom非安全转账不检查接收方若to是合约且未实现接收接口token 会永久锁死在合约里官方注释明确不推荐使用safeTransferFrom安全转账两个重载版本一个带_data一个不带不带的重载在内部以空bytes调用带_data的版本。它在转账后会调用_checkOnERC721Received防止 NFT 转入黑洞。5.2_isApprovedOrOwner转账资格判定function _isApprovedOrOwner(address spender, uint256 tokenId) internal view virtual returns (bool) { require(_exists(tokenId), ERC721: operator query for nonexistent token); address owner ERC721.ownerOf(tokenId); return (spender owner || getApproved(tokenId) spender || isApprovedForAll(owner, spender)); }判定条件三选一spender就是 owner、被单币授权getApproved、或被批量授权isApprovedForAll。注意它首先校验 token 存在否则会revert。5.3_safeTransfer与_transfer内部流转function _safeTransfer( address from, address to, uint256 tokenId, bytes memory _data ) internal virtual { _transfer(from, to, tokenId); require(_checkOnERC721Received(from, to, tokenId, _data), ERC721: transfer to non ERC721Receiver implementer); } function _transfer( address from, address to, uint256 tokenId ) internal virtual { require(ERC721.ownerOf(tokenId) from, ERC721: transfer from incorrect owner); require(to ! address(0), ERC721: transfer to the zero address); _beforeTokenTransfer(from, to, tokenId); // 清空授权 _approve(address(0), tokenId); _balances[from] - 1; _balances[to] 1; _owners[tokenId] to; emit Transfer(from, to, tokenId); _afterTokenTransfer(from, to, tokenId); }_transfer是核心的记账函数完整走完一条转账生命周期校验from确实是当前 owner、to非 0 地址调用_beforeTokenTransfer钩子转账前可重写通过_approve(address(0), tokenId)顺带清空单币授权避免转走之后旧授权仍有效同步更新_balances[from]、_balances[to]与_owners[tokenId]三个状态释放Transfer(from, to, tokenId)事件调用_afterTokenTransfer钩子转账后可重写。_safeTransfer则在_transfer之后追加_checkOnERC721Received检查构成安全二字的核心。六、铸造与销毁NFT 的出生与死亡6.1_mint基础铸造function _mint(address to, uint256 tokenId) internal virtual { require(to ! address(0), ERC721: mint to the zero address); require(!_exists(tokenId), ERC721: token already minted); _beforeTokenTransfer(address(0), to, tokenId); _balances[to] 1; _owners[tokenId] to; emit Transfer(address(0), to, tokenId); _afterTokenTransfer(address(0), to, tokenId); }_mint是internal函数外部不可直接调用。它校验目标地址非 0、token 尚未铸造然后更新余额与 owner 状态并释放Transfer(address(0), to, tokenId)——from为 0 地址正是铸造事件的链上标志链下索引器据此识别新铸造的 NFT。6.2_safeMint安全铸造两个重载function _safeMint(address to, uint256 tokenId) internal virtual { _safeMint(to, tokenId, ); } function _safeMint( address to, uint256 tokenId, bytes memory _data ) internal virtual { _mint(to, tokenId); require( _checkOnERC721Received(address(0), to, tokenId, _data), ERC721: transfer to non ERC721Receiver implementer ); }safe版本在_mint之后追加对接收方的接口检查from传 0 地址。NFT 项目对外发行的mint函数本质上就是把_safeMint包装一层加上白名单、价格、数量上限等业务逻辑后再调用它。仓库中的 WTFApe.sol 就是这样一个教学示例它继承了仓库自研的 ERC721 实现34_ERC721/ERC721.sol在公开函数里完成铸造。6.3_burn销毁function _burn(uint256 tokenId) internal virtual { address owner ERC721.ownerOf(tokenId); _beforeTokenTransfer(owner, address(0), tokenId); // 清空授权 _approve(address(0), tokenId); _balances[owner] - 1; delete _owners[tokenId]; emit Transfer(owner, address(0), tokenId); _afterTokenTransfer(owner, address(0), tokenId); }销毁是铸造的镜像操作取到 owner先清空授权再递减余额、delete掉_owners[tokenId]token 从此不存在最后释放Transfer(owner, address(0), tokenId)。to为 0 地址即销毁事件。6.4_exists存在性查询function _exists(uint256 tokenId) internal view virtual returns (bool) { return _owners[tokenId] ! address(0); }一切token 是否存在的判断都收敛到这里_owners[tokenId] ! address(0)。铸造使其变为true销毁delete使其回到false。ownerOf、getApproved、tokenURI、_isApprovedOrOwner都在入口处调用它做前置校验。七、_checkOnERC721Received防黑洞的最后一道防线function _checkOnERC721Received( address from, address to, uint256 tokenId, bytes memory _data ) private returns (bool) { if (to.isContract()) { try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, _data) returns (bytes4 retval) { return retval IERC721Receiver.onERC721Received.selector; } catch (bytes memory reason) { if (reason.length 0) { revert(ERC721: transfer to non ERC721Receiver implementer); } else { assembly { revert(add(32, reason), mload(reason)) } } } } else { return true; } }它只在safeTransferFrom/_safeMint中被调用逻辑分三层对应源码 Topics/ERC721/ERC721.sol#L436-L457接收方是 EOA直接返回true普通地址永远可以安全接收 NFT接收方是合约且实现了IERC721Receiver调用其onERC721Received校验返回值是否等于魔数IERC721Receiver.onERC721Received.selector0x150b7a02相等才放行接收方是合约但调用失败若回滚原因非空用assembly原样转发原始错误信息若为空说明对方根本没实现该接口则回滚ERC721: transfer to non ERC721Receiver implementer。其中to.isContract()正是专题第一讲介绍的Address库函数它利用account.code.length 0判断地址是否为合约见 Topics/ERC721/1_related_libraries/readme.md。任何想把 NFT 直接转账到交易所合约、市场合约的操作都会在这里被拦截回滚从而避免资产锁死。八、两个扩展钩子_beforeTokenTransfer与_afterTokenTransferfunction _beforeTokenTransfer( address from, address to, uint256 tokenId ) internal virtual {} function _afterTokenTransfer( address from, address to, uint256 tokenId ) internal virtual {}这两个函数默认空实现但被_mint、_burn、_transfer三个核心流程强制调用分别位于状态变更前后。它们是 OpenZeppelin 留给子合约的扩展点重写_beforeTokenTransfer可以做转账前校验如被冻结的 token 禁止交易重写_afterTokenTransfer可以做转账后的副作用如同步记录质押信息、更新 Enumerable 索引。以仓库中的 BAYC.sol完整合约源码为例无聊猿就是在标准 ERC721 之上重写了_baseURI返回其 IPFS 基础路径、添加了公开mint包装与发售参数从而在不改动任何核心记账逻辑的情况下完成商业化发行。这正是标准 ERC721 主合约开箱即用、按需扩展的设计哲学。九、函数全景总览类别函数可见性核心作用构造constructorpublic设置_name与_symbol接口检测supportsInterfacepublic view声明支持 IERC721 / IERC721Metadata / IERC165查询balanceOfpublic view查询地址持仓量查询ownerOfpublic view查询 token 持有人元数据name/symbolpublic view查询名称与代号元数据tokenURI/_baseURIpublic view / internal view拼接 metadata 链接授权approve/getApprovedpublic单币授权与查询授权setApprovalForAll/isApprovedForAllpublic批量授权与查询转账transferFrompublic非安全转账转账safeTransferFrom两个重载public安全转账内部_safeTransfer/_transferinternal安全/普通流转核心内部_exists/_isApprovedOrOwnerinternal view存在性与权限判定铸造_safeMint两个重载/_mintinternal安全/普通铸造销毁_burninternal销毁 token授权_approve/_setApprovalForAllinternal授权状态写入安全_checkOnERC721Receivedprivate合约接收方接口校验钩子_beforeTokenTransfer/_afterTokenTransferinternal virtual转账前后扩展点十、总结与下一步本讲完整介绍了 ERC721 主合约的全部家底6 个状态变量负责记账持有人、余额、单币授权、批量授权28 个函数含两个safeTransferFrom重载与两个_safeMint重载分别承担对外交互与内部流转。核心设计可以浓缩为三点存在性 _owners[tokenId] ! 0没有额外标记全靠 0 地址约定安全转账靠_checkOnERC721Receivedsafe系列函数在转账/铸造后强制校验合约接收方virtual钩子与函数是扩展点_baseURI、_beforeTokenTransfer、_afterTokenTransfer默认空实现NFT 项目只需重写它们并包装公开mint即可在不动核心逻辑的前提下发行自己的 NFT。有了这套标准NFT 项目方只需要把mint函数包装一下价格、白名单、数量上限等业务逻辑就可以发行 NFT 了。仓库中的 WTFApe.sol 与 BAYC.sol 正是标准合约 业务包装的活教材。下一讲专题第 4 讲对应 BAYC.sol 与第 5 讲 Loot将介绍最火的 NFT 项目在标准 ERC721 合约上做了哪些改动敬请继续阅读 专题首页相关文档 温习接口基础。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考