文章详细介绍了以太坊的ERC-721标准,涵盖了NFT的核心功能如所有权映射、铸造、转移、余额管理、授权机制等,还讨论了安全传输和销毁NFT的方法,适合有经验的开发者深入学习。
ERC721(或 ERC-721)是最广泛使用的以太坊标准,用于不可替代代币。它将一个唯一的编号与以太坊地址关联,从而表明该地址拥有该唯一编号——即NFT。
确实有许多教程涵盖这个著名的代币设计,然而,我们发现许多开发者,甚至是经验丰富的开发者,对规范并没有完全理解——有时也没有搞清楚安全问题。因此,我们在此记录了该标准,强调了更有经验的开发者容易忽视的领域。
在最后提供了练习问题,以测试较不为人知的边界情况。
ownerOf
函数transferFrom
转移NFTbalanceOf
函数setApprovalForAll
和 isApprovedForAll
函数approve
和 getApproved
函数safeTransferFrom
、_safeMint
和 onERC721Received
函数safeTransferFrom
带数据及其存在的原因 – 实际用例与效率_safeMint
和 safeTransferFrom
与 _mint
和 transferFrom
的Gas考虑burn
函数和NFT销毁NFT 通过三个值(链ID、合约地址、ID)独特地标识。
拥有NFT意味着拥有存储在特定EVM链上的ERC721合约里的一个uint256。
我们将深入探讨构成ERC721规范并促进其行为的函数,包括核心函数和辅助函数。它们是:
ownerOf
:所有权映射transferFrom
:转移所有权balanceOf
:所有权计数setApprovalForAll
& isApprovedForAll
:授权转移权利approve
& getApproved
:单个NFT批准机制safeTransferFrom
& _safeMint
:安全转移函数burn
:NFT销毁ownerOf
函数所有权仅仅是一个映射: ownerOf(uint256 id)
从本质上讲,ERC721只是在一个uint256(NFT的ID)到所有者地址的映射。尽管关于NFT的炒作很多,但它们只是被美化的哈希映射。“拥有”一个NFT意味着存在一个映射,该映射将某个ID作为键,将你的地址作为值。仅此而已。
该规范要求提供一个公共函数,给定ID返回所有者的地址。
为了简单起见,我们将使用公共变量而不是公共函数。在外部交互是相同的。
contract ERC721 {
mapping(uint256 => address) public ownerOf;
}
函数(或公共映射)ownerOf接收NFT的ID并返回拥有该NFT的地址。
mint
函数的铸造过程由于映射的默认值为0,因此默认情况下,零地址“拥有”所有NFT,但这不是我们通常的解读。如果ownerOf返回零地址,我们会说该NFT不存在。铸造是代币进入市场的方式。
Mint不是ERC721规范的一部分,它由用户定义NFT如何铸造。 没有要求NFT按顺序进行铸造 0、1、2、3 等等。我们可以根据块号和他们的地址哈希为某人铸造NFT。以下实现中,任何人都可以铸造任何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), "已铸造");
ownerOf[id] = recipient;
emit Transfer(address(0), recipient, id);
}
}
可能看起来有趣的是,Transfer
事件是从address(0)
转移到接收者,但这符合规范。
transferFrom
转移NFT自然,我们希望有一种方法将NFT移至其他地址。transferFrom
函数实现了这一点。
contract ERC721 {
mapping(uint256 id => address owner) public ownerOf;
event Transfer(address indexed from, address indexed to, uint256 indexed id);
//铸造过程略去以提高可读性
function transferFrom(address from, address to, uint256 id) external payable {
require(ownerOf[id] == msg.sender, "不允许转移");
ownerOf[id] = to;
emit Transfer(from, to, id);
}
}
可能看起来奇怪的是 transferFrom 是可支付的,但这正是EIP 721规范所规定的。推测一下,这一点是为了支持需要以太币购买已铸造NFT的应用程序。许多实现并未遵循规范的这一部分,而且此特性很少被使用。
此外,为什么我们要有 from 字段,如果我们只允许 msg.sender 作为 from?当我们谈论批准时我们会聊到这一点。现在显而易见的是,所有者应该能够转移他们拥有的ID。
balanceOf
函数ERC721规范要求我们跟踪每个合约中每个地址拥有多少NFT。
ERC721包含一个映射mapping(address owner => uint256 balances)
balanceOf。
我们的最小NFT现在具有以下代码所示的功能。
应该强调的是 balanceOf
仅表示某个地址拥有多少NFT,它并不说明是哪几个。我们需要更新可能改变余额的函数,当然就是mint
和transfer
。我们更新这些函数的位置之前已经标出。
这里有另一个警告:所有者可以随意转移NFT,因此在做出决定时依赖balanceOf时必须非常小心。不要将 balanceOf()
视为一个静态值,因为如果所有者从另一个地址转移NFT到自己地址,或者将NFT转移到另一个他们自己拥有的地址,则此值可能在交易期间发生变化,他们可以操纵 balanceOf()
函数。
setApprovalForAll
和 isApprovedForAll
函数ERC721规范允许NFT所有者在不将NFT转移给另一个地址的情况下将NFT控制权交给其他地址。实现这一点的第一个机制是 setApprovalForAll()
函数。顾名思义,它允许其他地址代表所有者转移NFT。这适用于地址拥有的任何NFT。 对应的isApprovedForAll()
函数检查某个被称为操作员的地址是否已获得所有者的授权。
一个owner
可以有多个操作员。这是一种机制,使得同一NFT可以在多个NFT市场上出售。如果市场获得了所有者地址的授权,他们可以在买家支付适当数量的以太币即可将其转移给买家。
此时 TransferFrom 允许所有者和一个已被 _approvedForAll
的地址转移代币。
approve
和 getApproved
函数与其批准另一个地址能够转移所有你拥有的每个NFT,不如批准他们单个ID,通常这样更安全。这被放置在公共映射 getApproved()
中。
与 isApprovedForAll
不同,获得NFT批准与所有者地址无关,而是仅与ID相关。
在转移后,新所有者可能不希望其他人对该ID有批准。因此,transferFrom
函数需要更新以清除该批准。
approve
的一个限制是每个 id
只可以批准一个地址。如果我们想要批准多个地址,那么在转移期间删除全部将非常昂贵。
请注意,如果一个地址是 approvedForAll
,那么它能够为其作为操作员的地址拥有的ID approve
另一个地址。setApprovalForAll()
函数并没有改变。
转移后,批准会被清除,因为新所有者一般不会希望之前的地址对该ID拥有批准。
我们几乎完成了每个ERC721规范要求的功能。剩下的功能需要显著更多的文档说明。
使用上述方法,是否有有效的方式确定一个地址拥有哪些NFT?
没有。
balanceOf
函数只告诉我们某个地址拥有多少NFT,而 ownerOf
只告诉我们特定ID的拥有者。从理论上讲,我们可以循环遍历所有ID来搞清楚某个地址拥有哪些NFT,但这并不是高效的。
在没有可枚举扩展的情况下,没有有效的方法可以仅在链上确定一个地址拥有哪些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, "不是声称的所有者");
}
// 其余逻辑
}
但我们如何有效地确定0xc0ffee…拥有5、7和21而不在链上呢?我们可以遍历所有ID并调用 ownerOf()
,但这会让我们的RPC提供者赚得盆满钵满。
以下是一些使用 web3 js 跟踪某个地址所拥有NFT的示例代码。请注意,代码自第0个区块开始扫描事件,这并不高效。你应该选择一个更合理的最近值。
gist.github.com/RareSkills/5d60ad42cdd81b6e136605a832ba59ee
safeTransferFrom
、_safeMint
和 onERC721Received
函数safeTransferFrom
和 _safeMint
的意图是处理NFT在合约中被“卡住”的情况。如果NFT被转移到一个不具备调用 transferFrom 能力的合约,则该NFT将会在合约中“锁定”,实际上被销毁。
为了防止这种情况发生,ERC-721 只希望转移到那些具有未来能够转移NFT能力的合约。如果合约具备函数 onERC721Received()
并返回特定的字节值0x150b7a02,则该合约被标记为能够“处理”NFT。这就是onERC721Received()
的函数选择器。 (函数选择器是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; // 返回 0x150b7a02
}
}
safeTransferFrom
的行为完全类似 transferFrom
。在内部,它会调用 transferFrom
然后 检查接收地址是否是智能合约。
onERC721Received()
函数,并传递上述参数检查 onERC721Received()
没有回滚并不足以判断合约是否可以正确处理ERC721代币。
如果NFT被转移到一个具有回退函数的智能合约,而返回值未被检查,事务将不会回滚。然而,合约很可能并没有处理接收NFT的机制,仅仅因为它有一个回退函数。
当调用 onERC721Received
时,会传递以下参数:
interface IERC721Receiver {
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4);
}
operator:
Operator 是safeTransfer
中的 msg.sender
。它可能是NFT的所有者,或已被授权转移该NFT的地址。
from:
From 是NFT的所有者。如果所有者在调用转移时,这两个参数将相等。
tokenId:
正在转移的NFT的 id
。
data:
如果 safeTransferFrom
是以 data
调用的,则此信息将被转发到接收合约。data
参数将稍后在一个部分中讨论。
始终检查 onERC721Received 中的 msg.sender
默认情况下,任何人都可以调用 onERC721Received()
并提供任意参数,使得合约误认为它收到了不属于它的NFT。如果你的合约使用 onERC721Received()
,则必须检查 msg.sender
是否是你预期的NFT合约!
安全转移重入
SafeTransfer 和 _safeMint 将执行控制权移交给外部合约。在使用safeTransfer将NFT发送到任意地址时要小心,接收者可以在onERC721Received()
函数中任意逻辑,可能导致重入。 如果你正确地防范重入,这就不需要担心。
安全转移拒绝服务
恶意接收者可以通过在 onERC721Received()
内部回滚事物,或通过循环耗尽所有Gas强制回滚交易。你不应假设对任意地址调用 safeTransferFrom
将会成功。
ERC721规定存在两个安全转移函数:
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()
时使用数据参数。
一个非常常见的模式是将NFT存入一个合约中以进行质押。当然,NFT并不真正“在”智能合约中,而是该特定ID的 ownerOf
是质押合约,质押合约进行一些记录以跟踪原始所有者。
常见但低效的方法在以下代码片段中显示。这种方法之所以低效,是因为它要求用户在调用 deposit()
之前必须批准Staking。我们添加了在质押期间进行投票的选项,作为在转移期间添加参数的示例。
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});
// 用户必须先批准Staking合约
nft.transferFrom(msg.sender, address(this), id);
}
function withdraw(uint256 id) external {
require(msg.sender == staked[id].originalOwner, "不是原始所有者");
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 {
// 重要安全检查,只允许来自我们预期的NFT的调用
require(msg.sender == address(nft), "错误的NFT");
uint8 voteId = abi.decode(data, (uint8));
originalOwners[id] = from; // from是原始所有者
}
function withdraw(uint256 id) external {
address originalOwner = stakes[id].originalOwner;
require(msg.sender == originalOwner, "不是所有者");
delete stakes[id];
nft.transferFrom(address(this), msg.sender, id);
}
}
再次强调,在 onERC721Received
中强烈确保 msg.sender
是NFT合约,否则任何人都可以调用这个函数并提供恶意数据。
上述示例说明了数据参数如何有用。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转移到零地址来销毁NFT。能够销毁NFT并非ERC规范的正式部分,因此合约不要求支持此操作。
OpenZeppelin 实现是开发者最友好的库,并且如果与其他可升级合约一起使用,这是理想的做法。经验更丰富的开发者可以考虑 Solady ERC721 实现,这将提供相当显著的Gas节省。
由于 ERC721 非常普遍,严肃的 Solidity 开发者应该完全理解该协议,并能够凭记忆从零开始实现一个。如果你想确认你是否理解了所有内容,尽量解决以下关于 ERC721 的安全练习:
Overmint 1(RareSkills 谜题) Overmint 2(RareSkills 谜题) Diamond Hands(RareSkills 谜题) Jpeg Sniper(Mr Steal Yo Crypto)
ERC721的可枚举扩展允许智能合约列出一个地址拥有的所有NFT。请查看我们关于 ERC721 可枚举 的文章以继续学习。
请查看我们的行业领先的 Solidity训练营 以了解更多有关该计划的信息。
最初发布于2023年11月8日
- 原文链接: rareskills.io/post/erc72...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!