Web3专题(七) ERC721 常见问题的答疑解惑

本文记录几个ERC721的常见问题解析。

社区已经有很多人在讲解 ERC721 的知识了,本文假定你已经看过了那些文章,或者已经熟悉了 ERC721 的基本概念。本文主要对常见问题以及我觉得比较经典的问题单独记录一下。

ERC721 vs ERC20

ERC721 协议标准non-fungible tokens(NFT)提供了一组标准接口,或称为契约。和上一篇讲解的 ERC20 对比如下:

名称 特点
ERC20 fungible tokens(同质化代币) 用户 A 持有的 1 个 token 和用户 B 持有的 1 个 token 一模一样,可等价交换,还可以拆分,如 0.1 个 token
ERC721 non-fungible tokens(非同质化代币) 用户 A 持有的 1 个 token 和用户 B 持有的 1 个 token 完全不一样,不可等价交换。通常 token 叫做 tokenId,是唯一的,不可拆分

ERC721 和 ERC20 的这种区别,使得它们的授权流程不一样:

  • ERC20 只有一个 approve 方法,因为是 token 是可拆分的,所以只要授权一定数量的 token,在这个数量之内的转账,调用transferFrom方法时,不用每次调用授权,可以减少用户心智负担及 gas 费。这个需要用户提前想好授权多少数量。

  • ERC721 有两个方法:approvesetApprovalForAll,因为不可拆分的特点,不可能像 ERC20 那样,提前授权一定数量。而是通过approve方法授权某一个 tokenId,或者通过setApprovalForAll授权全部的 tokenId。

特别提醒:第二个方法,即授权全部的 tokenId 是需要非常慎重的!如果授权给了一个不可靠的地址,那有可能损失全部的 tokenId。你可以连续多次调用approve提前授权一批 tokenId,为了减少gas费,也可以写一个合约进行批量授权,也是一种办法。

这里给出非同质化代币(NFT)Openzeppelin 的标准实现

源码中 ERC721 的依赖项 ERC165 有什么用?

打开上面的 Openzeppelin 的链接,我们先看到如下代码:

contract ERC721 is Context, ERC165, IERC721, IERC721Metadata {
    // ...
}

可以看到,ERC721 依赖了许多接口。Context, IERC721, IERC721Metadata很简单,我们自不必说,读者可以点进去一看就懂了。

对于ERC165所包含的接口supportsInterface(bytes4 interfaceId),相信你如果刚开始写合约的话,一定疑惑,为啥每次都要实现这个接口来覆盖父级代码?这源于ERC165 标准提出的建议,合约要提供一个方法来探测自己都实现了什么接口,便于调用者在调用真正的方法之前,先调用supportsInterface看看目标合约是否已经实现相应的接口,而 ERC721 采纳了这个建议并作为自己标准的一部分。

然而,在我众多的工作实践中,几乎没有哪个工程师调用过这个方法:supportsInterface。可能大家都不知道这个方法是干什么的,也可能大家都不太注重代码的健壮性,也不想办法优化 gas,不然呢?

ownerOf引发的报错

@openzeppelin/contracts@^4.8.0中, ownerOf 的源码是:

/**
 * @dev See {IERC721-ownerOf}.
 */
function ownerOf(uint256 tokenId) public view virtual override returns (address) {
    address owner = _ownerOf(tokenId);
    require(owner != address(0), "ERC721: invalid token ID");
    return owner;
}

其中require判断 0 地址这一行,以前没太注意,在工作中,我使用了 ownerOf 判断一个 tokenId 是否 mint,通过返回的地址是 0 地址来做判断,进而实现一些业务,结果就是交易直接回滚报错了,更不幸的是,直到发布上线后才发现……

那么,该怎么改进这个业务逻辑呢?需要使用_exists方法,这个方法在源码中是内部方法,需要我们在自己的 token 合约里包装一下,提供一个外部调用的方法。当然,这么做需要重新部署合约,原来那个 bug 是改不了了,只能在业务层面做一些补救了。

onERC721Received 方法有什么用?

看 ERC721 的源码,我们会发现,所有带了safe前缀的方法都检查了onERC721Received方法,包括_safeMint, _safeTransfer, safeTransferFrom,这些到底是怎么 safe 的呢?我们来精读一下onERC721Received的源码。

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 {
                /// @solidity memory-safe-assembly
                assembly {
                    revert(add(32, reason), mload(reason))
                }
            }
        }
    } else {
        return true;
    }
}

我们先看 if (to.isContract()) { 这一行,请思考一个问题,如果我把这个判断反过来用,保持源码的逻辑不变,如下:

if (!to.isContract()) {
    return true;
}

// do something...

请问,这样用可以吗?(请思考 30 秒再继续)

答案是 不可以! 如果这么用了,将出现重大的安全漏洞。为什么呢?这就要说说isContract这个方法的原理了,它内部是通过一个地址的code的长度来判断是不是合约,然而:

  • 如果 code 大于 0,一定是合约,这没什么疑问,所以用 to.isContract() 来判断一个地址是合约,肯定没问题。
  • 如果 code 等于 0,一定是 EOA 地址吗? 不一定! 有 3 种可能性:

所以,当你加上!使用 !to.isContract() 做判断时,有 3 种可能性,其中 2 种是合约,1 种是 EOA 账户地址。已经有这样的攻击案例发生了,我后面也会写文章详细讲讲这个攻击案例。

接下来,我们说说onERC721Received方法,这个方法的目的是:为了避免将 tokenId 转移到一个无法控制的合约地址,造成永久锁死

1. 为什么不调用onERC721Received方法,就是不安全的?

因为不调用,当 to 地址是个合约时,我们不知道 to 合约是否有withdraw等类似的方法来取出 tokenId,如果交易成功,to 合约却没有withdraw相关的方法来取出,那就永久锁死在这个合约,谁也用不了。 因为合约是没办法主动触发交易的,只能由 EOA(外部账户地址)来调用合约触发交易。

2. 为什么调用了onERC721Received方法,就是安全的(safe)?

因为调用后,可以知道 to 合约一定实现了onERC721Received方法,而实现了onERC721Received方法就代表遵循了 ERC721 的标准,准备接收 tokenId,也就会提供withdraw等相关的方法供 EOA 取出 tokenId。

当然,这是假定 to 合约的创建者是一个遵纪守法的好公民。那如果 to 合约的创建者是个坏人呢?或者就是一个考虑不周全、对 ERC721 理解不完整的“好人”?实现了onERC721Received方法,却没有实现withdraw等相关的方法,或者干脆在onERC721Received方法里把 tokenId 转移给自己,据为己有!那你就只能自认倒霉咯! 要知道,制定标准只能防范小人,却不能消灭所有小人。就像制定法律一样,有了法律,不代表就没有坏人了,是一个道理。

所以,广大 web3 的爱好者,请你们发出交易的时候,请擦亮眼睛,好好识别你的交易对象。

点赞 1
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

5 条评论

请先 登录 后评论
认知那些事
认知那些事
0x2b62...95a0
人立于天地之间,必然有我们的出路。