Vyper ERC-721 合约概览
简介
ERC-721 标准的作用是持有非同质化代币 (NFT) 的所有权。 ERC-20 代币如同商品一样,因为每个代币之间没有任何区别。 相比之下,ERC-721 代币专门用来代表类似但又不同的资产,例如表示不同的卡通猫咪(opens in a new tab) 或各种房地产的所有权。
在本文中,我们将分析 Ryuya Nakamura 编写的 ERC-721 合约(opens in a new tab)。 该合约是用 Vyper(opens in a new tab) 语言编写的,Vyper 是一种类似 Python 的合约语言,与使用 Solidity 相比,编写不安全的代码变得更加困难。
合约
1# @dev Implementation of ERC-721 non-fungible token standard.2# @author Ryuya Nakamura (@nrryuya)3# Modified from: https://github.com/vyperlang/vyper/blob/de74722bf2d8718cca46902be165f9fe0e3641dd/examples/tokens/ERC721.vy复制
Vyper 中的注释与 Python 中一样,以哈希 (#
) 开头并且持续一整行。 NatSpec(opens in a new tab) 使用包含 @<keyword>
的注释生成方便人阅读的文档。
1from vyper.interfaces import ERC72123implements: ERC721复制
ERC-721 接口内置在 Vyper 语言中。 你可以点击此处查看代码定义。(opens in a new tab) 接口定义是用 Python 而不是 Vyper 编写的,因为接口不仅在区块链内使用, 而且在外部客户端向区块链发送交易时也使用,而客户端可能 是用 Python 编写的。
第一行导入接口,第二行指定我们在这里执行它。
ERC721 接收者接口
1# Interface for the contract called by safeTransferFrom()2interface ERC721Receiver:3 def onERC721Received(复制
ERC-721 支持两类转账:
transferFrom
,让发送者指定任何目的地地址并让发送者 承担转账责任。 这意味着你可以转账到一个无效的地址,在这种情况下,NFT 将永远丢失。safeTransferFrom
,检查目的地址是否是合约。 如果是,ERC-721 合约 将会询问接收合约是否要接收这笔 NFT 转账。
接收合约必须执行 ERC721Receiver
才能回应 safeTransferFrom
请求。
1 _operator: address,2 _from: address,复制
_from
地址是代币的当前所有者。 _operator
地址是请求转账的 地址(由于限额,这两个地址可能不同)。
1 _tokenId: uint256,复制
ERC-721 代币 ID 是 256 位的。 通常,这些 ID 是通过对代币所代表的 任何东西进行哈希运算创建的。
1 _data: Bytes[1024]复制
请求最多可以有 1024 字节的用户数据。
1 ) -> bytes32: view复制
为了防止发生合约意外接受转账的情况,返回值不是布尔值, 而是一个具有特定值的 256 位数字串。
此函数是 view
,这意味着它可以读取区块链的状态,但不能修改。
事件
事件(opens in a new tab) 的触发是为了向区块链外部的用户和服务器通知事件。 请注意,事件 的内容不向区块链上的合约提供。
1# @dev Emits when ownership of any NFT changes by any mechanism. This event emits when NFTs are2# created (`from` == 0) and destroyed (`to` == 0). Exception: during contract creation, any3# number of NFTs may be created and assigned without emitting Transfer. At the time of any4# transfer, the approved address for that NFT (if any) is reset to none.5# @param _from Sender of NFT (if address is zero address it indicates token creation).6# @param _to Receiver of NFT (if address is zero address it indicates token destruction).7# @param _tokenId The NFT that got transferred.8event Transfer:9 sender: indexed(address)10 receiver: indexed(address)11 tokenId: indexed(uint256)显示全部复制
这类似于 ERC-20 转账事件,不同之处在于我们报告的是 tokenId
而不是金额。 没有人拥有零地址,所以根据惯例我们用它来报告代币的创建和销毁。
1# @dev This emits when the approved address for an NFT is changed or reaffirmed. The zero2# address indicates there is no approved address. When a Transfer event emits, this also3# indicates that the approved address for that NFT (if any) is reset to none.4# @param _owner Owner of NFT.5# @param _approved Address that we are approving.6# @param _tokenId NFT which we are approving.7event Approval:8 owner: indexed(address)9 approved: indexed(address)10 tokenId: indexed(uint256)显示全部复制
ERC-721 批准与 ERC-20 限额类似。 特定地址只允许转移特定 代币。 这就形成了一种合约在接受代币时作出回应的机制。 合约不能侦听 事件,所以如果你只是把代币转移给合约,它们不会“知道”这笔转账。 因此,代币所有者 首先提交批准,然后向合约发送请求:“我批准你转移 代币 X,请执行......”。
这是一种设计选择,使 ERC-721 标准与 ERC-20 标准类似。 由于 ERC-721 代币 为非同质化代币,合约还可以通过查看代币的所有权来确定 它得到了一个特定代币。
1# @dev This emits when an operator is enabled or disabled for an owner. The operator can manage2# all NFTs of the owner.3# @param _owner Owner of NFT.4# @param _operator Address to which we are setting operator rights.5# @param _approved Status of operator rights(true if operator rights are given and false if6# revoked).7event ApprovalForAll:8 owner: indexed(address)9 operator: indexed(address)10 approved: bool显示全部复制
有时候,拥有一个能够管理某个帐户所有特定类型代币(由一个特定合约 管理的所有代币)的运营者是很有用的,这类似于委托书。 例如,我可能想把这样一种权力赋予一个合约,即 检查我是否已经 6 个月没有联系它了,如果属实,就会把我的资产分配给我的继承者(如果他们中一人要求这样做,合约在没有 被交易调用时什么都做不了)。 在 ERC-20 中,我们只需给继承合约提供一个高限额即可。 但这对 ERC-721 不起作用,因为代币是非同质化的。 这是对应的。
approved
值表示事件是等待批准,还是等待撤回批准。
状态变量
这些变量包含代币的当前状态:哪些是可用的以及谁拥有它们。 其中大多数 是 HashMap
对象,即存在于两个类型之间的单向映射(opens in a new tab)。
1# @dev Mapping from NFT ID to the address that owns it.2idToOwner: HashMap[uint256, address]34# @dev Mapping from NFT ID to approved address.5idToApprovals: HashMap[uint256, address]复制
以太坊中的用户和合约标识用 160 位地址表示。 这两个变量从代币 ID 映射 到代币所有者及批准的转让者(一次最多 1 个映射)。 在以太坊中,未初始化 数据始终为零,因此如果没有所有者或批准的转让者,该代币的值 为零。
1# @dev Mapping from owner address to count of his tokens.2ownerToNFTokenCount: HashMap[address, uint256]复制
此变量保存每个所有者的代币数量。 没有从所有者到代币的映射,因此 识别特定所有者所拥有代币的唯一方法是在区块链的事件历史记录 中回溯并查看相应的 Transfer
事件。 我们可以使用此变量了解我们拥有全部非同质化代币的 时间,而无需进一步回溯。
注意,此算法只适用于用户接口和外部服务器。 在区块链自身运行的代码无法 读取过去的事件。
1# @dev Mapping from owner address to mapping of operator addresses.2ownerToOperators: HashMap[address, HashMap[address, bool]]复制
一个帐户可能有多个运营者。 仅有 HashMap
不足以跟踪它们,因为每个键都会生成单一值。 然而,可以将 HashMap[address, bool]
作为值。 默认情况下,每个地址的都值是 False
,这意味着它 不是运营者。 你可以根据需要将值设置为 True
。
1# @dev Address of minter, who can mint a token2minter: address复制
必须以某种方式创建新代币。 在此合约中,只允许一个实体创建 代币,即 minter
。 例如,这可能足以满足游戏的需要。 但对于其他用途,可能需要创建一个 更复杂的业务逻辑。
1# @dev Mapping of interface id to bool about whether or not it's supported2supportedInterfaces: HashMap[bytes32, bool]34# @dev ERC165 interface ID of ERC1655ERC165_INTERFACE_ID: constant(bytes32) = 0x0000000000000000000000000000000000000000000000000000000001ffc9a767# @dev ERC165 interface ID of ERC7218ERC721_INTERFACE_ID: constant(bytes32) = 0x0000000000000000000000000000000000000000000000000000000080ac58cd复制
ERC-165(opens in a new tab) 为合约规定了一种机制,用来表明应用程序 如何与合约通信以及合约符合哪些 ERC。 在这种情况下,合约符合 ERC-165 和 ERC-721。
函数
以下函数确实实现了 ERC-721。
构造函数
1@external2def __init__():复制
和 Python 中一样,在 Vyper 中,构造函数也被称为 __init__
。
1 """2 @dev Contract constructor.3 """复制
在 Python 和 Vyper 中,通过指定多行字符串(以 """
起始和结束),你还可以创建注释,但不能以任何方式使用它。 这些注释也可以包括 NatSpec(opens in a new tab) 注释。
1 self.supportedInterfaces[ERC165_INTERFACE_ID] = True2 self.supportedInterfaces[ERC721_INTERFACE_ID] = True3 self.minter = msg.sender复制
要访问状态变量,可以使用 self.<variable name>
(也是与 Python 中的相同)。
视图函数
这些函数不修改区块链状态,因此在外部调用时它们可以 免费执行。 如果视图函数是合约调用的,它们仍然必须在每个节点 上执行,因此需要消耗燃料。
1@view2@external复制
函数定义前面以 (@
) 开头的这些关键词称为修改器。 它们 规定能够调用函数的环境。
@view
指定此函数为 view 函数。@external
指定该特定函数可以由交易及其它合约调用。
1def supportsInterface(_interfaceID: bytes32) -> bool:复制
与 Python 相比,Vyper 是一种静态类型语言(opens in a new tab)。 如果没有先确定数据类型(opens in a new tab),就无法声明变量或函数参数。 因此在上例中,输入参数是一个 256 位的 bytes32
值 (256 位是以太坊虚拟机的原生字长宽度)。 输出是一个 布尔值。 按照惯例,函数参数的名称以下划线 (_
) 开头。
1 """2 @dev Interface identification is specified in ERC-165.3 @param _interfaceID Id of the interface4 """5 return self.supportedInterfaces[_interfaceID]复制
返回 HashMap self.supportedInterfaces
中的值,该 HashMap 在构造函数 (__init__
) 中设置。
1### VIEW FUNCTIONS ###复制
下面这些视图函数让用户和其它合约可以获得代币相关信息。
1@view2@external3def balanceOf(_owner: address) -> uint256:4 """5 @dev Returns the number of NFTs owned by `_owner`.6 Throws if `_owner` is the zero address. NFTs assigned to the zero address are considered invalid.7 @param _owner Address for whom to query the balance.8 """9 assert _owner != ZERO_ADDRESS显示全部复制
此行宣称(opens in a new tab) _owner
不 为 0。 如果为 0,就会出现错误,操作会被回滚。
1 return self.ownerToNFTokenCount[_owner]23@view4@external5def ownerOf(_tokenId: uint256) -> address:6 """7 @dev Returns the address of the owner of the NFT.8 Throws if `_tokenId` is not a valid NFT.9 @param _tokenId The identifier for an NFT.10 """11 owner: address = self.idToOwner[_tokenId]12 # Throws if `_tokenId` is not a valid NFT13 assert owner != ZERO_ADDRESS14 return owner显示全部复制
在以太坊虚拟机 (evm) 中,任何没有存储值的存储都为零。 如果 _tokenId
处没有代币,那么 self.idToOwner[_tokenId]
的值为 0。 在这种情况下,该函数 会回滚操作。
1@view2@external3def getApproved(_tokenId: uint256) -> address:4 """5 @dev Get the approved address for a single NFT.6 Throws if `_tokenId` is not a valid NFT.7 @param _tokenId ID of the NFT to query the approval of.8 """9 # Throws if `_tokenId` is not a valid NFT10 assert self.idToOwner[_tokenId] != ZERO_ADDRESS11 return self.idToApprovals[_tokenId]显示全部复制
注意,getApproved
可以返回零。 如果代币有效,则返回 self.idToApprovals[_tokenId]
。 如果没有批准者,该值为 0。
1@view2@external3def isApprovedForAll(_owner: address, _operator: address) -> bool:4 """5 @dev Checks if `_operator` is an approved operator for `_owner`.6 @param _owner The address that owns the NFTs.7 @param _operator The address that acts on behalf of the owner.8 """9 return (self.ownerToOperators[_owner])[_operator]显示全部复制
此函数检查是否允许 _operator
管理 _owner
在此合约中的所有代币。 因为可以有多个运营者,所以这是一个两级 HashMap。
转账帮助函数
这些函数执行代币转账或管理过程中的一些操作。
12### TRANSFER FUNCTION HELPERS ###34@view5@internal复制
修改器 @internal
表示该函数只能由 同一合约内的其他函数访问。 按照惯例,这些函数名称也以下划线 (_
) 开头。
1def _isApprovedOrOwner(_spender: address, _tokenId: uint256) -> bool:2 """3 @dev Returns whether the given spender can transfer a given token ID4 @param spender address of the spender to query5 @param tokenId uint256 ID of the token to be transferred6 @return bool whether the msg.sender is approved for the given token ID,7 is an operator of the owner, or is the owner of the token8 """9 owner: address = self.idToOwner[_tokenId]10 spenderIsOwner: bool = owner == _spender11 spenderIsApproved: bool = _spender == self.idToApprovals[_tokenId]12 spenderIsApprovedForAll: bool = (self.ownerToOperators[owner])[_spender]13 return (spenderIsOwner or spenderIsApproved) or spenderIsApprovedForAll显示全部复制
有三种方式可以允许地址转移代币:
- 该地址是代币的所有者
- 该地址经批准可以使用该代币
- 该地址是代表代币所有者的运营者
上面的函数可以是一个视图函数,因为它并不改变状态。 为了降低运营成本,任何可以 成为视图函数的函数都应该成为视图函数。
1@internal2def _addTokenTo(_to: address, _tokenId: uint256):3 """4 @dev Add a NFT to a given address5 Throws if `_tokenId` is owned by someone.6 """7 # Throws if `_tokenId` is owned by someone8 assert self.idToOwner[_tokenId] == ZERO_ADDRESS9 # Change the owner10 self.idToOwner[_tokenId] = _to11 # Change count tracking12 self.ownerToNFTokenCount[_to] += 1131415@internal16def _removeTokenFrom(_from: address, _tokenId: uint256):17 """18 @dev Remove a NFT from a given address19 Throws if `_from` is not the current owner.20 """21 # Throws if `_from` is not the current owner22 assert self.idToOwner[_tokenId] == _from23 # Change the owner24 self.idToOwner[_tokenId] = ZERO_ADDRESS25 # Change count tracking26 self.ownerToNFTokenCount[_from] -= 1显示全部复制
当转账出现问题时,我们会撤销调用。
1@internal2def _clearApproval(_owner: address, _tokenId: uint256):3 """4 @dev Clear an approval of a given address5 Throws if `_owner` is not the current owner.6 """7 # Throws if `_owner` is not the current owner8 assert self.idToOwner[_tokenId] == _owner9 if self.idToApprovals[_tokenId] != ZERO_ADDRESS:10 # Reset approvals11 self.idToApprovals[_tokenId] = ZERO_ADDRESS显示全部复制
仅在必要时更改值。 状态变量位于存储中。 写入存储是 EVM(以太坊虚拟机)执行的最昂贵的操作之一(就 燃料而言)。 因此,尽量减少写入操作,即使是写入现有值, 成本也很高。
1@internal2def _transferFrom(_from: address, _to: address, _tokenId: uint256, _sender: address):3 """4 @dev Execute transfer of a NFT.5 Throws unless `msg.sender` is the current owner, an authorized operator, or the approved6 address for this NFT. (NOTE: `msg.sender` not allowed in private function so pass `_sender`.)7 Throws if `_to` is the zero address.8 Throws if `_from` is not the current owner.9 Throws if `_tokenId` is not a valid NFT.10 """显示全部复制
我们使用此内部函数是因为有两种代币转账方式(常规和安全方式),但 我们希望代码中只有一个位置进行此操作,以使审计更容易。
1 # Check requirements2 assert self._isApprovedOrOwner(_sender, _tokenId)3 # Throws if `_to` is the zero address4 assert _to != ZERO_ADDRESS5 # Clear approval. Throws if `_from` is not the current owner6 self._clearApproval(_from, _tokenId)7 # Remove NFT. Throws if `_tokenId` is not a valid NFT8 self._removeTokenFrom(_from, _tokenId)9 # Add NFT10 self._addTokenTo(_to, _tokenId)11 # Log the transfer12 log Transfer(_from, _to, _tokenId)显示全部复制
要在 Vyper 中触发一个事件,可以使用 log
语句(请点击此处了解更多详情(opens in a new tab))。
转账函数
12### TRANSFER FUNCTIONS ###34@external5def transferFrom(_from: address, _to: address, _tokenId: uint256):6 """7 @dev Throws unless `msg.sender` is the current owner, an authorized operator, or the approved8 address for this NFT.9 Throws if `_from` is not the current owner.10 Throws if `_to` is the zero address.11 Throws if `_tokenId` is not a valid NFT.12 @notice The caller is responsible to confirm that `_to` is capable of receiving NFTs or else13 they maybe be permanently lost.14 @param _from The current owner of the NFT.15 @param _to The new owner.16 @param _tokenId The NFT to transfer.17 """18 self._transferFrom(_from, _to, _tokenId, msg.sender)显示全部复制
此函数允许你向任意地址转账。 除非该地址是用户或是知道如何转移代币的 合约,否则你转移的任何代币都将卡在该地址中变得毫无用处。
1@external2def safeTransferFrom(3 _from: address,4 _to: address,5 _tokenId: uint256,6 _data: Bytes[1024]=b""7 ):8 """9 @dev Transfers the ownership of an NFT from one address to another address.10 Throws unless `msg.sender` is the current owner, an authorized operator, or the11 approved address for this NFT.12 Throws if `_from` is not the current owner.13 Throws if `_to` is the zero address.14 Throws if `_tokenId` is not a valid NFT.15 If `_to` is a smart contract, it calls `onERC721Received` on `_to` and throws if16 the return value is not `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))`.17 NOTE: bytes4 is represented by bytes32 with padding18 @param _from The current owner of the NFT.19 @param _to The new owner.20 @param _tokenId The NFT to transfer.21 @param _data Additional data with no specified format, sent in call to `_to`.22 """23 self._transferFrom(_from, _to, _tokenId, msg.sender)显示全部复制
可以先进行转账,因为如果出现问题,我们无论如何都会回滚该操作,因此 调用中的一切操作都会被取消。
1 if _to.is_contract: # check if `_to` is a contract address复制
首先检查地址是否为合约(如果有代码)。 如果不是,假定它是一个用户 地址,并且该用户能够使用或转移代币。 但不要让该地址 给你一种虚假的安全感。 如果你将代币转移到一个没有人知道私钥的地址,即使使用了 safeTransferFrom
,也可能损失代币。
1 returnValue: bytes32 = ERC721Receiver(_to).onERC721Received(msg.sender, _from, _tokenId, _data)复制
调用目标合约,看它是否可以接收 ERC-721 代币。
1 # Throws if transfer destination is a contract which does not implement 'onERC721Received'2 assert returnValue == method_id("onERC721Received(address,address,uint256,bytes)", output_type=bytes32)复制
如果目的地是一个合约,但它不接受 ERC-721 代币(或者决定不接受这笔特定转账),则回滚。
1@external2def approve(_approved: address, _tokenId: uint256):3 """4 @dev Set or reaffirm the approved address for an NFT. The zero address indicates there is no approved address.5 Throws unless `msg.sender` is the current NFT owner, or an authorized operator of the current owner.6 Throws if `_tokenId` is not a valid NFT. (NOTE: This is not written the EIP)7 Throws if `_approved` is the current owner. (NOTE: This is not written the EIP)8 @param _approved Address to be approved for the given NFT ID.9 @param _tokenId ID of the token to be approved.10 """11 owner: address = self.idToOwner[_tokenId]12 # Throws if `_tokenId` is not a valid NFT13 assert owner != ZERO_ADDRESS14 # Throws if `_approved` is the current owner15 assert _approved != owner显示全部复制
根据惯例,如果你不想要批准者,可以指定零地址而不是你自己。
1 # Check requirements2 senderIsOwner: bool = self.idToOwner[_tokenId] == msg.sender3 senderIsApprovedForAll: bool = (self.ownerToOperators[owner])[msg.sender]4 assert (senderIsOwner or senderIsApprovedForAll)复制
要设置批准,你可以是所有者,也可以是所有者授权的运营者。
1 # Set the approval2 self.idToApprovals[_tokenId] = _approved3 log Approval(owner, _approved, _tokenId)456@external7def setApprovalForAll(_operator: address, _approved: bool):8 """9 @dev Enables or disables approval for a third party ("operator") to manage all of10 `msg.sender`'s assets. It also emits the ApprovalForAll event.11 Throws if `_operator` is the `msg.sender`. (NOTE: This is not written the EIP)12 @notice This works even if sender doesn't own any tokens at the time.13 @param _operator Address to add to the set of authorized operators.14 @param _approved True if the operators is approved, false to revoke approval.15 """16 # Throws if `_operator` is the `msg.sender`17 assert _operator != msg.sender18 self.ownerToOperators[msg.sender][_operator] = _approved19 log ApprovalForAll(msg.sender, _operator, _approved)显示全部复制
铸造新代币和销毁现有代币
创建合约的帐户就是 minter
,是获得授权可以铸造 新非同质化代币的超级用户。 然而,即使是铸币者,也不允许其销毁现有代币。 只有所有者或所有者授权的实体 才能那样做。
1### MINT & BURN FUNCTIONS ###23@external4def mint(_to: address, _tokenId: uint256) -> bool:复制
此函数始终返回 True
,因为如果操作失败,它将被回滚。
1 """2 @dev Function to mint tokens3 Throws if `msg.sender` is not the minter.4 Throws if `_to` is zero address.5 Throws if `_tokenId` is owned by someone.6 @param _to The address that will receive the minted tokens.7 @param _tokenId The token id to mint.8 @return A boolean that indicates if the operation was successful.9 """10 # Throws if `msg.sender` is not the minter11 assert msg.sender == self.minter显示全部复制
只有铸币者(创建 ERC-721 合约的帐户)可以铸造新代币。 如果我们将来想改变铸币者的 身份,这可能会成为一个问题。 在生产合约 中,你可能需要一个函数,允许 铸币者将铸币者特权转让给其他人。
1 # Throws if `_to` is zero address2 assert _to != ZERO_ADDRESS3 # Add NFT. Throws if `_tokenId` is owned by someone4 self._addTokenTo(_to, _tokenId)5 log Transfer(ZERO_ADDRESS, _to, _tokenId)6 return True复制
根据惯例,新代币铸造视作来自零地址的转账。
12@external3def burn(_tokenId: uint256):4 """5 @dev Burns a specific ERC721 token.6 Throws unless `msg.sender` is the current owner, an authorized operator, or the approved7 address for this NFT.8 Throws if `_tokenId` is not a valid NFT.9 @param _tokenId uint256 id of the ERC721 token to be burned.10 """11 # Check requirements12 assert self._isApprovedOrOwner(msg.sender, _tokenId)13 owner: address = self.idToOwner[_tokenId]14 # Throws if `_tokenId` is not a valid NFT15 assert owner != ZERO_ADDRESS16 self._clearApproval(owner, _tokenId)17 self._removeTokenFrom(owner, _tokenId)18 log Transfer(owner, ZERO_ADDRESS, _tokenId)显示全部复制
任何可以转移代币的人都可以销毁它。 虽然销毁代币看起来等同于 转移到零地址,但零地址实际上并没有接收到代币。 这样我们可以释放所有用于代币的 存储,因而可以降低交易的燃料成本。
使用此合约
与 Solidity 相比,Vyper 中没有继承。 这种有意而为之的设计选择,是为了使代码 更清晰,从而更容易受保护。 因此,要创建你自己的 Vyper ERC-721 合约,你可以 利用此合约(opens in a new tab),并修改 它以实现想要的业务逻辑。
总结
回顾一下,下面是此合约中最重要的几点:
- 要通过安全转账方式接收 ERC-721 代币,合约必须实现
ERC721Receiver
接口。 - 即使使用了安全转账方式,如果你将代币发送到私钥未知 的地址,代币仍然会被卡住。
- 当操作出现问题时,最好
revert
该调用,而不是只返回 失败值。 - 有了所有者,ERC-721 代币才存在。
- 有三种经过授权的 NFT 转账方式。 你可以是所有者,可以针对特定代币获得批准, 或者可以是所有者全部代币的运营者。
- 过去的事件只在区块链之外可见。 区块链中运行的代码无法看到它们。
现在去实现安全的 Vyper 合约吧。
上次修改时间: @nhsz(opens in a new tab), 2023年8月15日