本文记录几个ERC721的常见问题解析。
社区已经有很多人在讲解 ERC721 的知识了,本文假定你已经看过了那些文章,或者已经熟悉了 ERC721 的基本概念。本文主要对常见问题以及我觉得比较经典的问题单独记录一下。
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 有两个方法:approve
和setApprovalForAll
,因为不可拆分的特点,不可能像 ERC20 那样,提前授权一定数量。而是通过approve
方法授权某一个 tokenId,或者通过setApprovalForAll
授权全部的 tokenId。
特别提醒:第二个方法,即授权全部的 tokenId 是需要非常慎重的!如果授权给了一个不可靠的地址,那有可能损失全部的 tokenId。你可以连续多次调用
approve
提前授权一批 tokenId,为了减少gas费,也可以写一个合约进行批量授权,也是一种办法。
这里给出非同质化代币(NFT)Openzeppelin 的标准实现
打开上面的 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 是改不了了,只能在业务层面做一些补救了。
看 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
的长度来判断是不是合约,然而:
to.isContract()
来判断一个地址是合约,肯定没问题。所以,当你加上!
使用 !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 的爱好者,请你们发出交易的时候,请擦亮眼睛,好好识别你的交易对象。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!