Vyper ERC-721 Contract Walkthrough
The ERC-721 standard is used to hold the ownership of Non-Fungible Tokens (NFT). ERC-20 tokens behave as a commodity, because there is no difference between individual tokens. In contrast to that, ERC-721 tokens are designed for assets that are similar but not identical, such as different cat cartoons or titles to different pieces of real estate.
In this article we will analyze Ryuya Nakamura's ERC-721 contract. This contract is written in Vyper, a Python-like contract language designed to make it harder to write insecure code than it is in 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.vy4Kopiér
Comments in Vyper, as in Python, start with a hash (
#) and continue to the end of the line. Comments that include
@<keyword> are used by NatSpec to produce human-readable
1from vyper.interfaces import ERC72123implements: ERC7214Kopiér
The ERC-721 interface is built into the Vyper language. You can see the code definition here. The interface definition is written in Python, rather than Vyper, because interfaces are used not only within the blockchain, but also when sending the blockchain a transaction from an external client, which may be written in Python.
The first line imports the interface, and the second specifies that we are implementing it here.
1# Interface for the contract called by safeTransferFrom()2interface ERC721Receiver:3 def onERC721Received(4Kopiér
ERC-721 supports two types of transfer:
transferFrom, which lets the sender specify any destination address and places the responsibility for the transfer on the sender. This means that you can transfer to an invalid address, in which case the NFT is lost for good.
safeTransferFrom, which checks if the destination address is a contract. If so, the ERC-721 contract asks the receiving contract if it wants to receive the NFT.
safeTransferFrom requests a receiving contract has to implement
1 _operator: address,2 _from: address,3Kopiér
_from address is the current owner of the token. The
_operator address is the one that
requested the transfer (those two may not be the same, because of allowances).
1 _tokenId: uint256,2Kopiér
ERC-721 token IDs are 256 bits. Typically they are created by hashing a description of whatever the token represents.
1 _data: Bytes2Kopiér
The request can have up to 1024 bytes of user data.
1 ) -> bytes32: view2Kopiér
To prevent cases in which a contract accidentally accepts a transfer the return value is not a boolean, but 256 bits with a specific value.
This function is a
view, which means it can read the state of the blockchain, but not modify it.
Events are emitted to inform users and servers outside of the blockchain of events. Note that the content of events is not available to contracts on the blockchain.
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 transfered.8event Transfer:9 sender: indexed(address)10 receiver: indexed(address)11 tokenId: indexed(uint256)12Vis alleKopiér
This is similar to the ERC-20 Transfer event, except that we report a
tokenId instead of an amount.
Nobody owns address zero, so by convention we use it to report creation and destruction of tokens.
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)11Vis alleKopiér
An ERC-721 approval is similar to an ERC-20 allowance. A specific address is allowed to transfer a specific token. This gives a mechanism for contracts to respond when they accept a token. Contracts cannot listen for events, so if you just transfer the token to them they don't "know" about it. This way the owner first submits an approval and then sends a request to the contract: "I approved for you to transfer token X, please do ...".
This is a design choice to make the ERC-721 standard similar to the ERC-20 standard. Because ERC-721 tokens are not fungible, a contract can also identify that it got a specific token by looking at the token's ownership.
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: bool11Vis alleKopiér
It is sometimes useful to have an operator that can manage all of an account's tokens of a specific type (those that are managed by a specific contract), similar to a power of attorney. For example, I might want to give such a power to a contract that checks if I haven't contacted it for six months, and if so distributes my assets to my heirs (if one of them asks for it, contracts can't do anything without being called by a transaction). In ERC-20 we can just give a high allowance to an inheritance contract, but that doesn't work for ERC-721 because the tokens are not fungible. This is the equivalent.
approved value tells us whether the event is for an approval, or the withdrawal of an approval.
These variables contain the current state of the tokens: which ones are available and who owns them. Most of these
HashMap objects, unidirectional mappings that exist between two types.
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]6Kopiér
User and contract identities in Ethereum are represented by 160-bit addresses. These two variables map from token IDs to their owners and those approved to transfer them (at a maximum of one for each). In Ethereum, uninitialized data is always zero, so if there is no owner or approved transferor the value for that token is zero.
1# @dev Mapping from owner address to count of his tokens.2ownerToNFTokenCount: HashMap[address, uint256]3Kopiér
This variable holds the count of tokens for each owner. There is no mapping from owners to tokens, so
the only way to identify the tokens that a specific owner owns is to look back in the blockchain's event history
and see the appropriate
Transfer events. We can use this variable to know when we have all the NFTs and don't
need to look even further in time.
Note that this algorithm only works for user interfaces and external servers. Code running on the blockchain itself cannot read past events.
1# @dev Mapping from owner address to mapping of operator addresses.2ownerToOperators: HashMap[address, HashMap[address, bool]]3Kopiér
An account may have more than a single operator. A simple
HashMap is insufficient to
keep track of them, because each key leads to a single value. Instead, you can use
HashMap[address, bool] as the value. By default the value for each address is
False, which means it
is not an operator. You can set values to
True as needed.
1# @dev Address of minter, who can mint a token2minter: address3Kopiér
New tokens have to be created somehow. In this contract there is a single entity that is allowed to do so, the
minter. This is likely to be sufficient for a game, for example. For other purposes, it might be necessary
to create a more complicated business logic.
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) = 0x0000000000000000000000000000000000000000000000000000000080ac58cd9Kopiér
ERC-165 specifies a mechanism for a contract to disclose how applications can communicate with it, to which ERCs it conforms. In this case, the contract conforms to ERC-165 and ERC-721.
These are the functions that actually implement ERC-721.
In Vyper, as in Python, the constructor function is called
1 """2 @dev Contract constructor.3 """4Kopiér
In Python, and in Vyper, you can also create a comment by specifying a multi-line string (which starts and ends
"""), and not using it in any way. These comments can also include
1 self.supportedInterfaces[ERC165_INTERFACE_ID] = True2 self.supportedInterfaces[ERC721_INTERFACE_ID] = True3 self.minter = msg.sender4Kopiér
To access state variables you use
self.<variable name> (again, same as in Python).
These are functions that do not modify the state of the blockchain, and therefore can be executed for free if they are called externally. If the view functions are called by a contract they still have to be executed on every node and therefore cost gas.
These keywords prior to a function definition that start with an at sign (
@) are called decorations. They
specify the circumstances in which a function can be called.
@viewspecifies that this function is a view.
@externalspecifies that this particular function can be called by transactions and by other contracts.
1def supportsInterface(_interfaceID: bytes32) -> bool:2Kopiér
In contrast to Python, Vyper is a static typed language.
You can't declare a variable, or a function parameter, without identifying the data
type. In this case the input parameter is
bytes32, a 256-bit value
(256 bits is the native word size of the Ethereum Virtual Machine). The output is a boolean
value. By convention, the names of function parameters start with an underscore (
1 """2 @dev Interface identification is specified in ERC-165.3 @param _interfaceID Id of the interface4 """5 return self.supportedInterfaces[_interfaceID]6Kopiér
Return the value from the
self.supportedInterfaces HashMap, which is set in the constructor (
1### VIEW FUNCTIONS ###2Kopiér
These are the view functions that make information about the tokens available to users and other contracts.
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_ADDRESS10Vis alleKopiér
This line asserts that
_owner is not
zero. If it is, there is an error and the operation is reverted.
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 owner15Vis alleKopiér
In the Ethereum Virtual Machine (evm) any storage that does not have a value stored in it is zero.
If there is no token at
_tokenId then the value of
self.idToOwner[_tokenId] is zero. In that
case the function reverts.
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]12Vis alleKopiér
getApproved can return zero. If the token is valid it returns
If there is no approver that value is zero.
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]10Vis alleKopiér
This function checks if
_operator is allowed to manage all of
_owner's tokens in this contract.
Because there can be multiple operators, this is a two level HashMap.
These functions implement operations that are part of transferring or managing tokens.
12### TRANSFER FUNCTION HELPERS ###34@view5@internal6