ERC 721 标准及其相关安全问题的全面指南

文章详细介绍了以太坊的ERC-721标准,涵盖了NFT的核心功能如所有权映射、铸造、转移、余额管理、授权机制等,还讨论了安全传输和销毁NFT的方法,适合有经验的开发者深入学习。

ERC721(或 ERC-721)是最广泛使用的以太坊标准,用于不可替代代币。它将一个唯一的编号与以太坊地址关联,从而表明该地址拥有该唯一编号——即NFT。

确实有许多教程涵盖这个著名的代币设计,然而,我们发现许多开发者,甚至是经验丰富的开发者,对规范并没有完全理解——有时也没有搞清楚安全问题。因此,我们在此记录了该标准,强调了更有经验的开发者容易忽视的领域。

在最后提供了练习问题,以测试较不为人知的边界情况。

目录

  1. 是什么让NFT独一无二?
  2. 所有权和 ownerOf 函数
  3. 铸造过程
  4. 使用 transferFrom 转移NFT
  5. 理解 balanceOf 函数
  6. 无限批准:setApprovalForAllisApprovedForAll 函数
  7. 针对特定批准的 approvegetApproved 函数
  8. 没有可枚举扩展来识别拥有的NFT
  9. 安全转移:safeTransferFrom_safeMintonERC721Received 函数
  10. safeTransferFrom 带数据及其存在的原因 – 实际用例与效率
  11. 关于 _safeMintsafeTransferFrom_minttransferFrom 的Gas考虑
  12. burn 函数和NFT销毁
  13. ERC721 实现
  14. 测试你的知识

是什么让NFT独一无二?

NFT 通过三个值(链ID、合约地址、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的炒作很多,但它们只是被美化的哈希映射。“拥有”一个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)转移到接收者,但这符合规范。

使用 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);

    //铸造过程略去以提高可读性

    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。

理解 ERC721 balanceOf 函数

ERC721规范要求我们跟踪每个合约中每个地址拥有多少NFT。

ERC721包含一个映射mapping(address owner => uint256 balances) balanceOf。

我们的最小NFT现在具有以下代码所示的功能。

应该强调的是 balanceOf 仅表示某个地址拥有多少NFT,它并不说明是哪几个。我们需要更新可能改变余额的函数,当然就是minttransfer。我们更新这些函数的位置之前已经标出。

erc721 balanceOf

这里有另一个警告:所有者可以随意转移NFT,因此在做出决定时依赖balanceOf时必须非常小心。不要将 balanceOf() 视为一个静态值,因为如果所有者从另一个地址转移NFT到自己地址,或者将NFT转移到另一个他们自己拥有的地址,则此值可能在交易期间发生变化,他们可以操纵 balanceOf() 函数。

无限批准:ERC721 setApprovalForAllisApprovedForAll 函数

ERC721规范允许NFT所有者在不将NFT转移给另一个地址的情况下将NFT控制权交给其他地址。实现这一点的第一个机制是 setApprovalForAll() 函数。顾名思义,它允许其他地址代表所有者转移NFT。这适用于地址拥有的任何NFT。 对应的isApprovedForAll()函数检查某个被称为操作员的地址是否已获得所有者的授权。

一个owner可以有多个操作员。这是一种机制,使得同一NFT可以在多个NFT市场上出售。如果市场获得了所有者地址的授权,他们可以在买家支付适当数量的以太币即可将其转移给买家。

erc721 approveForAll

此时 TransferFrom 允许所有者和一个已被 _approvedForAll 的地址转移代币。

特定token ID的批准与 ERC721 approvegetApproved 函数

与其批准另一个地址能够转移所有你拥有的每个NFT,不如批准他们单个ID,通常这样更安全。这被放置在公共映射 getApproved() 中。

isApprovedForAll 不同,获得NFT批准与所有者地址无关,而是仅与ID相关。

在转移后,新所有者可能不希望其他人对该ID有批准。因此,transferFrom 函数需要更新以清除该批准。

approve 的一个限制是每个 id 只可以批准一个地址。如果我们想要批准多个地址,那么在转移期间删除全部将非常昂贵。

请注意,如果一个地址是 approvedForAll,那么它能够为其作为操作员的地址拥有的ID approve 另一个地址。setApprovalForAll() 函数并没有改变。

erc721 approve

转移后,批准会被清除,因为新所有者一般不会希望之前的地址对该ID拥有批准。

我们几乎完成了每个ERC721规范要求的功能。剩下的功能需要显著更多的文档说明。

没有可枚举扩展的情况下识别拥有的NFT

确定拥有的ID列表

使用上述方法,是否有有效的方式确定一个地址拥有哪些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提供者赚得盆满钵满。

解析 ERC721 事件

以下是一些使用 web3 js 跟踪某个地址所拥有NFT的示例代码。请注意,代码自第0个区块开始扫描事件,这并不高效。你应该选择一个更合理的最近值。

gist.github.com/RareSkills/5d60ad42cdd81b6e136605a832ba59ee

安全转移:safeTransferFrom_safeMintonERC721Received 函数

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 然后 检查接收地址是否是智能合约

  • 如果不是,不做其他步骤
  • 如果是:
    • 它会尝试在接收NFT的合约上调用onERC721Received()函数,并传递上述参数
    • 如果函数调用回滚或未返回0x150b7a02,则回滚

为什么要检查函数选择器?

检查 onERC721Received() 没有回滚并不足以判断合约是否可以正确处理ERC721代币。

如果NFT被转移到一个具有回退函数的智能合约,而返回值未被检查,事务将不会回滚。然而,合约很可能并没有处理接收NFT的机制,仅仅因为它有一个回退函数。

onERC721Received 的函数参数

当调用 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 安全考虑

始终检查 onERC721Received 中的 msg.sender
默认情况下,任何人都可以调用 onERC721Received() 并提供任意参数,使得合约误认为它收到了不属于它的NFT。如果你的合约使用 onERC721Received(),则必须检查 msg.sender 是否是你预期的NFT合约!

安全转移重入
SafeTransfer 和 _safeMint 将执行控制权移交给外部合约。在使用safeTransfer将NFT发送到任意地址时要小心,接收者可以在onERC721Received()函数中任意逻辑,可能导致重入。 如果你正确地防范重入,这就不需要担心。

安全转移拒绝服务
恶意接收者可以通过在 onERC721Received() 内部回滚事物,或通过循环耗尽所有Gas强制回滚交易。你不应假设对任意地址调用 safeTransferFrom 将会成功。

带数据的 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() 时使用数据参数。

高效Gas的质押,绕过批准

一个非常常见的模式是将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,但如果我们想添加 intendedDurationdelegate 和其他参数,我们可以使用 (voteId, intendedDuration, delegate) = abi.decode(data, (uint8, uint256, address) 进行解码。

_safeMintsafeTransferFrom_minttransferFrom 的Gas考虑

如果你期望接收者是EOA,那么使用 transferFrom_mint 是更可取的,因为检查他们是否是合约(这是 _safeMintsafeTransferFrom 要做的)将浪费Gas。

burn 函数和NFT销毁

可以通过将NFT转移到零地址来销毁NFT。能够销毁NFT并非ERC规范的正式部分,因此合约不要求支持此操作。

ERC721 实现

OpenZeppelin 实现是开发者最友好的库,并且如果与其他可升级合约一起使用,这是理想的做法。经验更丰富的开发者可以考虑 Solady ERC721 实现,这将提供相当显著的Gas节省。

测试你的知识

由于 ERC721 非常普遍,严肃的 Solidity 开发者应该完全理解该协议,并能够凭记忆从零开始实现一个。如果你想确认你是否理解了所有内容,尽量解决以下关于 ERC721 的安全练习:

Overmint 1(RareSkills 谜题) Overmint 2(RareSkills 谜题) Diamond Hands(RareSkills 谜题) Jpeg Sniper(Mr Steal Yo Crypto)

继续学习:ERC721 可枚举

ERC721的可枚举扩展允许智能合约列出一个地址拥有的所有NFT。请查看我们关于 ERC721 可枚举 的文章以继续学习。

通过RareSkills获取更多了解

请查看我们的行业领先的 Solidity训练营 以了解更多有关该计划的信息。

最初发布于2023年11月8日

  • 原文链接: rareskills.io/post/erc72...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/