ERC721和ERC20一样,都是一个代币标准,ERC721代币是不可细分的,每一个代币都是唯一的。每一个ERC721代币都有自己的标识符,通常用于表示独立的资产,例如数字艺术品,游戏中的虚拟角色或房地产。
该协议允许在智能合约中实施NFT标准API。该标准提供了跟踪和传输NFT的基本功能。
首先了解什么是非同质化代币,NTF 的全称是 Non-Fungible Token,即非同质化代币。非同质化的意思是某物不可与另一物互换,它是独一无二的。例如,我家的钥匙和你家的钥匙,看起来都是钥匙,但是不能交换的,因为我家的钥匙打不开你家的门,反之你的钥匙也是。
ERC721和ERC20一样,都是一个代币标准,ERC721代币是不可细分的,每一个代币都是唯一的。每一个ERC721代币都有自己的标识符,通常用于表示独立的资产,例如数字艺术品,游戏中的虚拟角色或房地产。
源码来自:链接。
接口内容:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC721 is IERC165 {
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
function balanceOf(address owner) external view returns (uint256 balance);
function ownerOf(uint256 tokenId) external view returns (address owner);
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
function safeTransferFrom(address from, address to, uint256 tokenId) external;
function transferFrom(address from, address to, uint256 tokenId) external;
function approve(address to, uint256 tokenId) external;
function setApprovalForAll(address operator, bool approved) external;
function getApproved(uint256 tokenId) external view returns (address operator);
function isApprovedForAll(address owner, address operator) external view returns (bool);
}
分析各接口的功能:
bytes4(keccak256("onERC721Received(address,uint256,bytes)"))
,因为在调用safeTransferFrom函数时,会对 receiver 进行检测,如果recever是EOA也是可以的。transferFrom和safeTransferFrom的区别,后面再详细说明。
该接口和 IERC20Metadata 类似,都是可选择的。该接口是用来存储额外数据的,比如代币的name,symbol以及URI。 这个URI可以是图片链接等。
interface ERC721Enumerable {
function totalSupply() external view returns (uint256);
function tokenByIndex(uint256 _index) external view returns (uint256);
function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns (uint256);
}
该接口提供了三个函数,旨在提高NTF的可读性和可访问性,同时也进一步完善了NTF交易市场的功能,这些功能可用于查询NTF市场发行了多少代币,某人的第index号代币的tokenId是多少,还可以根据发行的index找到对应的tokenId。
这个合约是ERC-721协议中最重要也是最核心的部分,需要重点解读。
_update()函数是贯穿了,铸币,销币,以及转币,所以首先要重点看懂这个函数,代码如下:
function _update(address to, uint256 tokenId, address auth) internal virtual returns (address) {
address from = _ownerOf(tokenId);
// Perform (optional) operator check
if (auth != address(0)) {
_checkAuthorized(from, auth, tokenId);
}
// Execute the update
if (from != address(0)) {
// Clear approval. No need to re-authorize or emit the Approval event
_approve(address(0), tokenId, address(0), false);
unchecked {
_balances[from] -= 1;
}
}
if (to != address(0)) {
unchecked {
_balances[to] += 1;
}
}
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
return from;
}
其中参数是需要明确的,to是待转移的地址,可以理解为 接收者receiver;tokenId是待操作的token;auth是代币的所有者或是被授权者。
_update()是如何实现mint、burn和transferFrom的,函数的执行逻辑:
首先第一步都是获取待操作的tokenId的所有者。
mint的实现,调用方式为:
_update(to, tokenId, address(0))
函数执行,from的值为零地址,程序直接执行到第三个if语句,to的_balance加一,该tokenId的所有者为to
transferFrom的实现,safeTransferFrom同样是调用transferFrom,调用的方式为:
address previousOwner = _update(to, tokenId, _msgSender())
,首先可以知道to不为零地址,_msgSender()也不为零,至少目前零地址的私钥还没人知道。函数执行,from的值为代币所有者,进入第一个if语句,如果auth是代币所有者或是被授权者则通过检测,进入第二个if语句,移除原tokenId所有者对其他人的授权,代币所有者的token数减一,最后进入第三个if语句,接收者的token数加一,更新mapping。burn的实现,调用方法:
_update(address(0), tokenId, address(0))
,进入第二个if语句,移除原tokenId所有者对其他人的授权,代币所有者的token数减一,将代币所有者设置为零地址。transfer的实现,调用方式为:
_update(to, tokenId, address(0))
,首先to不为零,进入第二个if语句,移除原tokenId所有者对其他人的授权,代币所有者的token数减一,最后进入第三个if语句,接收者的token数加一,更新mapping。
合约中还有几个映射:
_checkAuthorized(address owner, address spender, uint256 tokenId)
函数负责检测对于指定tokenId,owner和spender的关系,只有当spender不为零地址,且(owner==spender【代币所有者亲自操作】或owner将所有代币授权给了spender,或owner将tokenId这枚token授权给了spender)才可以通过。
该合同继承了ERC721,但是重写了 ERC721中的 _update()函数,这一操作使得ERC721中的 铸币、销币、转账都繁发生了变化。
题外话
这是solidity继承的基本知识,验证如下:
contract Person { function eat() public virtual { console.log("Person"); } function call() public { eat(); } } contract Man is Person { function eat() public override { console.log("Man"); } }
复现逻辑:部署Man合约,并调用call函数看看输出结果是什么。
就是Man,虽然call函数声明在父类中,但是父类会调用子类重写的eat函数,这可以理解为【就近原则】。
看到该合约_update的实现逻辑:
function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address) {
address previousOwner = super._update(to, tokenId, auth);
if (previousOwner == address(0)) {
_addTokenToAllTokensEnumeration(tokenId);
} else if (previousOwner != to) {
_removeTokenFromOwnerEnumeration(previousOwner, tokenId);
}
if (to == address(0)) {
_removeTokenFromAllTokensEnumeration(tokenId);
} else if (previousOwner != to) {
_addTokenToOwnerEnumeration(to, tokenId);
}
return previousOwner;
}
ta调用了父类的_update函数,说明代币的转移逻辑还是还ERC721的一样,只是根据 tokenId的所有者以及接收者进行了一些对数组的添加和删除操作。利用数组来记录代币的发行量。
该合约只有一个函数,则是 onERC721Received(),这是当接收者 to 为合约地址时,在调用ERC721中的三个safe函数(_safeMint, _safeTransfer, safeTransferFrom)时的回调函数。实现这个接口的作用是什么呢?为什么当to为EOA时则可以不需要实现这个方法呢?我的理解是,如果有人向我tx.origin转入 ERC721 token的时候,我可以去调用transferFrom或者safeTransferFrom函数将手中的代币转出去,但是如果转入的是合约地址,那么如果你实现没有提供调用 ransferFrom或者safeTransferFrom函数 的功能,那么这个token将永远的留在了这个合约中,无法转移出去。而明确要求合约接收者实现这个函数,这是为了让接收者有意识地去处理接收到的token。当然,要是实现了接口而函数体只是按照要求返回固定值,那么还是会被锁死这个token,因为人家提醒你了,做不做就是自己的事了。
该合约提供了一个紧急暂停功能,比如在合约被黑客攻击时,合约通过可以修饰器锁住所有的转账功能,这是一种防护措施。
该合约对外提供了一个销币的功能。
该合约提供了一个设置 tokenURI的函数:_setTokenURI(uint256 tokenId, string memory _tokenURI)
,使其tokenId在mint的时候,就可以与某个URI绑定起来。
该合约提供了一个包装功能,比如 用户将手中的 _underlying代币存入到该合约中,那么该合约会为用户铸造一个相同tokenId的代币,同理取出的话,会将用户从该合约获取的tokenId全部销毁,销毁的方式为:_update(address(0), tokenId, _msgSender())
(突然感觉,这个update函数真的太牛了,这个兼容性真的,佩服的很。)合约还实现了onERC721Received
函数,实现逻辑如下:
function onERC721Received(address, address from, uint256 tokenId, bytes memory) public virtual returns (bytes4) {
if (address(underlying()) != _msgSender()) {
revert ERC721UnsupportedToken(_msgSender());
}
_safeMint(from, tokenId);
return IERC721Receiver.onERC721Received.selector;
}
这个函数是为了让其他用户通过 _underlying 的 safeTransferFrom 向这个合约转移代币的时候,同时为用户包装代币。正是因为接口如此实现,所以在 depositFor
函数中使用的转账函数为:transferFrom()
。
不过在 depositFor() 和 withdrawTo()函数中都有重入的风险,depositFor::_safeMint(),withdrawTo::safeTransferFrom()。
提供了一个校验功能,检验合约接收者是否实现了 IERC721Receiver
接口,实现checkOnERC721Received函数的时候,是否按要求返回了 IERC721Receiver.onERC721Received.selector
。
在ERC721合约中,有一个内部函数 _transfer()
,这函数有点奇怪,它可以实现任意转移某人的token,前提是只要知道tokenId以及ta的owner。可以看到他的实现代码:
function _transfer(address from, address to, uint256 tokenId) internal {
if (to == address(0)) {
revert ERC721InvalidReceiver(address(0));
}
address previousOwner = _update(to, tokenId, address(0));
if (previousOwner == address(0)) {
revert ERC721NonexistentToken(tokenId);
} else if (previousOwner != from) {
revert ERC721IncorrectOwner(from, tokenId, previousOwner);
}
}
因为ta对_update的调用方式为:_update(to, tokenId, address(0))
,即auth参数是零地址,则可以跳过第一个if语句的判断,换句话说就是不要验证身份。写了一个简单的示例代码进行复现:
pragma solidity ^0.8.20;
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol";
contract Test is ERC721("ERC721", "erc721") {
uint256 public tokenId;
function mint() public {
_mint(msg.sender, tokenId);
tokenId++;
}
function transfer(address from, address to, uint256 tokenId) public {
_transfer(from, to, tokenId);
}
}
攻击逻辑,假如A:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
账户拥有tokenId为0,1,2,3四个代币。并且我:0x70997970C51812dc3A010C7d01b50e0d17dc79C8
知道A的地址以及tokenId,那么我可以调用 这样调用transfer(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266,0x70997970C51812dc3A010C7d01b50e0d17dc79C8,0)
,则可以完成攻击,此时查看 ownerOf(0)的所有者是不是我
结果很明显,我拥有了0号代币。同理,内部的_safeTransfer()也是同样的道理,只不过需要接收者实现指定的接口,但是这都无关紧要,钱还是可以照样拿走的。
如果你的合约继承了ERC721合约,并且调用了它的内部函数 _transfer(),则一定要给这个外部函数添加访问控制。加了访问控制之后,这个函数可以看作是管理员权限级别的,可以强制转移某人的代币。
字面意思safeTransferFrom多了一个 safe前缀,看起来要比 transferFrom函数要更安全,在 ERC-721说明文档中也是这样说的,但是真的是这样吗?emmm,只能说各有各的安全吧。safeTransferFrom的安全之后在于,合约接收者必须要实现指定的接收函数,旨在让用户正确处理token(至少让token还能再次转移嘛),而transferFrom没有这个功能,要是直接转入给合约接收者,且合约接收者没有事先做好处理这笔token的操作,那么很遗憾,这笔token锁死了。。。这样想想safeTransferFrom确实要安全一点。但是执行safeTransferFrom的时候,会执行合约接收者的onERC721Received
函数,及其类似 fallback,那么就会存在重入的风险,而transferFrom就没有。总而言之,各有各的好,各有各的坏。
在ERC721的内部函数中,_safeMint()
,_safeTransfer()
都是具有重入的风险的。所以要是继承ERC721合约的项目,如果将这些内部函数暴露出来的话,这需要特别小心这几个点。
码字不易,点个赞再走呗~😜
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!