ERC721(或 ERC-721)是不可替代代币(NFT)中最广泛使用的 Ethereum 标准。它将一个唯一的数字与一个 Ethereum 地址关联起来,从而表示该地址拥有这个唯一的数字——即 NFT。
确实不乏涵盖这一著名代币设计的教程,然而,我们发现许多开发者,即便是经验丰富的开发者,对该规范也缺乏完整的理解——有时甚至对安全问题也不甚了解。因此,我们在此记录了该标准,并重点强调了经验丰富的开发者容易忽略的领域。
文末提供了练习题,以测试鲜为人知的边缘案例。
目录
- 是什么让 NFT 独一无二?
- 所有权与
ownerOf函数 - 铸造过程
- 使用
transferFrom转移 NFT - 理解
balanceOf函数 - 无限授权:
setApprovalForAll和isApprovedForAll函数 - 特定授权:
approve和getApproved函数 - 在没有可枚举扩展的情况下识别拥有的 NFT
- 安全转移:
safeTransferFrom、_safeMint以及onERC721Received函数 - 带数据参数的
safeTransferFrom及其存在的原因 - 实际用例与效率 _safeMint和safeTransferFrom与_mint和transferFrom的 Gas 考量burn函数与 NFT 销毁- ERC721 实现
- 测试你的知识
是什么让 NFT 独一无二?
NFT 由三个值(chain id、contract address、id)唯一标识。
拥有一个 NFT 意味着拥有存储在特定 EVM 链上 ERC721 合约中的一个 uint256。
我们将深入探讨构成 ERC721 规范并促进其行为的函数,包括核心函数和辅助函数。它们是:
ownerOf:所有权映射- mint:代币创建
transferFrom:转移所有权balanceOf:所有权计数setApprovalForAll&isApprovedForAll:委派转移权approve&getApproved:单个 NFT 授权机制safeTransferFrom&_safeMint:安全转移函数burn:NFT 销毁
所有权与 ERC721 的 ownerOf 函数
所有权只是一个映射:ownerOf(uint256 id)
在其核心,ERC721 只是一个从 uint256(NFT 的 id)到所有者地址的映射。尽管有关于 NFT 的种种炒作,它们不过是被美化的哈希映射(hash maps)。“拥有”一个 NFT 意味着存在一个映射,该映射以特定的 id 为键,以你的地址为值。仅此而已。
规范要求一个公共函数,在给定 id 的情况下,返回所有者的地址。
为了简单起见,我们将使用公共变量而不是公共函数。在外部,交互是相同的。
contract ERC721 {
mapping(uint256 => address) public ownerOf;
}
函数(或公共映射)ownerOf 接收 NFT 的 id,并返回拥有该 NFT 的地址。
使用 mint 函数的铸造过程
由于映射的默认值为 0,默认情况下,零地址(zero address)“拥有”所有 NFT,但这并不是我们通常理解的方式。如果 ownerOf 返回零地址,我们就认为该 NFT 不存在。铸造(Minting)是代币产生的方式。
Mint 不是 ERC721 规范的一部分,它留给用户去定义 NFT 是如何被铸造的。 规范并没有要求 NFT 必须按照 0、1、2、3 等顺序铸造。我们可以基于将区块号与某人地址哈希等方式为其铸造 NFT。在以下实现中,只要某个 id 之前未被铸造过,任何人都可以铸造任何 id。
contract ERC721 {
mapping(uint256 id => address owner) public ownerOf;
event Transfer(address indexed from, address indexed to, uint256 indexed id);
function mint(address recipient, uint256 id) public {
require(ownerOf[id] == address(0), "already minted");
ownerOf[id] = recipient;
emit Transfer(address(0), recipient, id);
}
}
有一个从 address(0) 发送到接收者的 Transfer 事件似乎有些滑稽,但这就是规范。
使用 ERC721 的 transferFrom 转移 NFT
自然地,我们需要一种将 NFT 移动到另一个地址的方法。函数 transferFrom 实现了这一点。
contract ERC721 {
mapping(uint256 id => address owner) public ownerOf;
event Transfer(address indexed from, address indexed to, uint256 indexed id);
// mint hidden for readability
function transferFrom(address from, address to, uint256 id) external payable {
require(ownerOf[id] == msg.sender, "not allowed to transfer");
ownerOf[id] = to;
emit Transfer(from, to, id);
}
}
transferFrom 是 payable 的似乎有些奇怪,但这正是 EIP 721 规范所规定的。据推测,这是为了允许需要支付 Ether 以获取已铸造的 NFT 的应用程序。许多实现并没有遵循规范的这一部分,并且这个功能也很少被使用。
另外,如果我们只允许 msg.sender 成为 from,为什么还要保留 from 字段呢?当我们谈论授权(approvals)时,将会解答这个问题。就目前而言,很明显,所有者应该能够转移他们拥有的 id。
理解 ERC721 的 balanceOf 函数
ERC721 规范要求我们按合约跟踪一个地址拥有多少个 NFT。
ERC721 拥有一个映射 mapping(address owner => uint256 balances) balanceOf。
我们极简版的 NFT 现在具有如下代码所示的功能。
需要强调的是,balanceOf 只说明一个地址拥有多少个 NFT,并没有说明是哪些。我们需要更新那些会导致余额发生变化的函数,当然就是 mint 和 transfer。我们更新这些函数的地方已被突出显示。

另一个警告是:所有者可以随意转移 NFT,因此在智能合约中依赖 balanceOf 做出决策时应非常小心。不要把 balanceOf() 当作一个静态值,因为如果在交易过程中所有者从另一个地址向自己转移 NFT,或者将 NFT 转移给他们拥有的另一个地址,他们就可以操纵 balanceOf() 函数,从而导致其值发生改变。
无限授权:ERC721 的 setApprovalForAll 和 isApprovedForAll 函数
ERC721 规范允许 NFT 所有者在不将 NFT 转移给对方的情况下,将 NFT 的控制权交给另一个地址。实现这一点的第一种机制是使用 setApprovalForAll() 函数。顾名思义,它允许另一个地址代表所有者转移 NFT。这适用于该地址拥有的任何 NFT。 它的对应函数 isApprovedForAll() 会检查一个称为 operator 的特定地址是否已获得所有者的授权委派。
一个 owner 可以有多个 operator。这是同一个 NFT 可以在多个 NFT 市场上出售的一种机制。如果这些市场获得了所有者地址的授权,只要买家支付了正确金额的 Ether,市场就可以将其转移给买家。

TransferFrom 现在允许所有者和拥有 _approvedForAll 的地址去转移代币。
使用 ERC721 approve 和 getApproved 函数进行特定 token id 授权
除了授权另一个地址能够转移你拥有的每一个 NFT,你还可以针对单个 id 授权他们,这通常更安全。这是被放入公共映射 getApproved() 中的。
与 isApprovedForAll 不同,针对一个 NFT 的授权与所有者的地址无关,它仅与该 id 相关联。
在转移之后,新所有者可能不希望其他人对该 id 拥有授权。因此,需要更新 transferFrom 函数以清除该授权。
approve 的一个限制是每个 id 只能授权给一个地址。如果我们想要授权给多个地址,在转移期间删除它们所有的成本将非常高昂。
请注意,如果一个地址是 approvedForAll,那么它能够就其作为 operator 代理的地址所拥有的 id 去 approve 另一个地址。setApprovalForAll() 函数没有任何改变。

在转移之后,授权会被清除,因为通常来说,新所有者不会希望先前的地址对该 id 拥有授权。
我们几乎已经完成了实现 ERC721 规范要求的每一个函数。剩下的函数需要多得多的文档说明。
在没有可枚举扩展的情况下识别拥有的 NFT
确定拥有的 id 列表
使用上述方法,是否有有效的方法来确定一个地址拥有哪些 NFT?
并没有。
balanceOf 函数只告诉我们一个地址拥有多少个 NFT,而 ownerOf 只告诉我们特定 id 是谁拥有的。从理论上讲,我们可以遍历所有 id 来弄清楚某个特定地址拥有哪些 id,但这并不高效。
如果没有可枚举扩展,纯粹在链上就没有高效的方法来确定一个地址拥有哪些 NFT。
我们稍后将讨论可枚举扩展,但是如果没有它,我们该如何进行呢?
如果合约需要知道 0xc0ffee… 拥有 id 5、7 和 21,解决办法是 告诉 合约 0xc0ffee… 拥有这些 id,然后合约会验证这是否确实属实。
function checkOwnership(uint256[] calldata ids, address claimedOwner) public {
for (uint256 i = 0; i < ids.length; i++) {
require(nft.ownerOf(ids[i]) == claimedOwner, "not the claimed owner");
}
// rest of the logic
}
但是我们如何在链下高效地确定 0xc0ffee… 拥有 5、7 和 21 呢?我们可以遍历所有 id 并调用 ownerOf(),但这会让我们的 RPC 提供商发财。
解析 ERC721 事件
下面是一些使用 web3 js 追踪一个地址拥有哪些 NFT 的示例代码。请注意,该代码会从第 0 个区块开始扫描 事件(events),这并不高效。你应该选择一个更合理的近期值。
gist.github.com/RareSkills/5d60ad42cdd81b6e136605a832ba59ee
安全转移:safeTransferFrom、_safeMint 以及 onERC721Received 函数
safeTransferFrom 和 _safeMint 的意图是为了处理 NFT 卡在合约中的问题。如果将 NFT 转移到一个没有能力自己调用 transferFrom 的合约中,那么 NFT 将被“锁定”在该合约中,等同于被销毁。
为了防止这种情况发生,ERC-721 只希望转移给具有一定机制以便能在以后将 NFT 转移出去的合约。如果一个合约具有一个返回魔法 bytes4 值 0x150b7a02 的 onERC721Received() 函数,那么它就被标记为能够“处理”NFT。这是下面所示的 onERC721Received() 的 函数选择器(function selector)。(函数选择器是 Solidity 对函数的内部标识符)。
interface IERC721Receiver {
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4);
}
以下是一个使用该接口的合约最小化示例:
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract MinimaExample is IERC721Receiver {
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
return IERC721Receiver.onERC721Received.selector; // returns 0x150b7a02
}
}
safeTransferFrom 的行为与 transferFrom 完全相同。在底层,它调用 transferFrom 然后 检查接收地址是否为智能合约。
- 如果不是,则不执行任何额外步骤
- 如果是
- 它尝试在接收 NFT 的合约上调用带有上述参数的
onERC721Received()函数 - 如果函数调用发生回滚(reverts)或未返回 0x150b7a02,则交易回滚
- 它尝试在接收 NFT 的合约上调用带有上述参数的
为什么要检查函数选择器?
仅仅检查 onERC721Received() 是否未回滚,不足以确定合约是否能够妥善处理 ERC721 代币。
如果将 NFT 转移给带有 fallback 函数的智能合约,并且未检查返回值,那么该交易将不会回滚。然而,仅仅因为它具有 fallback 函数,该合约很可能并没有处理接收 NFT 的机制。
onERC721Received 的函数参数
当调用 onERC721Received 时,会向其传递以下参数,具体描述如下
interface IERC721Receiver {
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4);
}
operator:
从 safeTransfer 的角度来看,Operator 就是 msg.sender。它可能是 NFT 所有者,或是被授权转移该 NFT 的地址。
from:
From 是 NFT 的所有者。如果所有者就是调用 transfer 的人,那么 from 和 operator 这两个参数将相等。
tokenId:
正在转移的 NFT 的 id。
data:
如果调用 safeTransferFrom 时带有 data,它将被转发到接收合约。data 参数将在后面的部分讨论。
onERC721Received 的安全考量
始终在 onERC721Received 中检查 msg.sender
默认情况下,任何人都可以使用任意参数调用 onERC721Received(),从而欺骗合约,使其认为自己收到了并未实际拥有的 NFT。如果你的合约使用了 onERC721Received(),你必须检查 msg.sender 是否是你所期望的 NFT 合约!
safeTransfer 重入
SafeTransfer 和 _safeMint 会将执行控制权移交给外部合约。在使用 safeTransfer 将 NFT 发送到任意地址时务必小心,接收者可以在 onERC721Received() 函数中放入他们想要的任何逻辑,这可能导致重入(reentrancy)。 如果你恰当地 防御了重入攻击,这就无需担心。
safeTransfer 拒绝服务
恶意接收者可以通过在 onERC721Received() 内部回滚,或者使用循环消耗所有 gas 来强行回滚交易。你不应理所当然地认为向任意地址执行的 safeTransferFrom 都会成功。
带数据参数的 safeTransferFrom 及其存在的原因 - 实际用例与效率
ERC721 规定了两个 safeTransferFrom 函数的存在:
function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes calldata data) external payable;
第二个拥有一个额外的 data 参数。以下示例将演示在 onERC721Received() 中使用 data 参数。
高效利用 Gas 的质押,绕过授权
一个非常常见的模式是为了质押目的将 NFT 存入合约。当然,NFT 并不在智能合约“内部”,而是那个特定 id 的 ownerOf 变成了质押合约,并且质押合约有一些账本记录以追踪原所有者。
以下代码片段展示了一种常见但低效的实现方式。它效率低下的原因在于它要求用户在调用 deposit() 之前先 approve 质押合约。我们添加了在质押同时进行投票的选项,作为在转移期间添加参数的示例。
contract Staking {
struct Stake {
uint8 voteId;
address originalOwner;
}
mapping(uint256 id => Stake stake) public stakes;
function deposit(uint256 id, uint8 _voteId) external {
stakes[id] = Stake({voteId: _voteId, originalOwner: msg.sender});
// user must approve Staking contract first
nft.transferFrom(msg.sender, address(this), id);
}
function withdraw(uint256 id) external {
require(msg.sender == staked[id].originalOwner, "not original owner");
delete stakes[id];
nft.transferFrom(address(this), msg.sender, id);
}
}
一个更为 gas 高效 的替代方案是直接使用 safeTransfer 将资产移入。这允许用户跳过 approve 步骤。当然,这需要前端应用程序来处理,以减少用户错误。请注意,现在的 vote 参数包含在 data 参数中。
contract ImprovedStaking is IERC721Receiver {
struct Stake {
uint8 voteId;
address originalOwner;
}
mapping(uint256 id => Stake stake) public stakes;
function onERC721Received(address operator, address from, uint256 id, bytes calldata data) external {
// important safety to check only allow calls from our intended NFT
require(msg.sender == address(nft), "wrong NFT");
uint8 voteId = abi.decode(data, (uint8));
originalOwners[id] = from; // from is the original owner
}
function withdraw(uint256 id) external {
address originalOwner = stakes[id].originalOwner;
require(msg.sender == originalOwner, "not owner");
delete stakes[id];
nft.transferFrom(address(this), msg.sender, id);
}
}
再次强调,在 onERC721Received 中强制检查 msg.sender 是 NFT 合约极为重要,否则任何人都可以调用该函数并向其提供恶意数据。
上述示例说明了 data 参数是有多么实用。bytes calldata data 参数赋予了我们灵活编码任何我们关心的数据的能力。我们仅包含了一个 uint8 voteId,但如果我们还想添加 intendedDuration、delegate 以及其他参数,我们可以这样写:
(voteId, intendedDuration, delegate) = abi.decode(data, (uint8, uint256, address)。
_safeMint 和 safeTransferFrom 与 _mint 和 transferFrom 的 Gas 考量
如果你预期接收者是一个 EOA(外部拥有账户),那么更倾向于使用 transferFrom 或 _mint,因为检查它们是否是一个合约(正如 _safeMint 和 safeTransferFrom 所做的那样)将是对 gas 的浪费。
burn 函数与 NFT 销毁
可以通过将 NFT 转移到零地址来对其进行销毁(burn)。能够销毁 NFT 并非 ERC 规范的正式组成部分,因此并不强制要求合约支持此操作。
ERC721 实现
OpenZeppelin 的实现对开发者来说是对初学者最友好的库,如果与其余的可升级合约一起使用,它是理想的选择。更有经验的开发者应考虑 Solady ERC721 实现,因为它将提供可观的 gas 节省。
测试你的知识
因为 ERC721 是如此无处不在,认真的 Solidity 开发者应当完全理解该协议,并且能够凭记忆从零开始实现一个。要查看你是否理解了全部内容,尝试解决以下关于 ERC721 的安全练习:
Overmint 1 (RareSkills Riddles)
Overmint 2 (RareSkills Riddles)
Diamond Hands (RareSkills Riddles)
Jpeg Sniper (Mr Steal Yo Crypto)
继续学习:ERC721 Enumerable
ERC721 的 Enumerable 扩展允许智能合约列出一个地址拥有的所有 NFT。请参阅我们关于 ERC721 Enumerable 的文章以继续学习。
通过 RareSkills 了解更多
请查看我们行业领先的 Solidity 训练营(bootcamp) 以了解有关该计划的更多信息。
最初发布于 2023 年 11 月 8 日