Vyper ERC-721 Contract Walkthrough

By April 24, 2021Ethereum
Click here to view original web page at ethereum.org

Introduction

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.

The Contract

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 documentation.

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.

The ERC721Receiver Interface

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.

To answer safeTransferFrom requests a receiving contract has to implement ERC721Receiver.

The _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).

ERC-721 token IDs are 256 bits. Typically they are created by hashing a description of whatever the token represents.

The request can have up to 1024 bytes of user data.

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.

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.

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 not fungible, a contract can also identify that it got a specific token by looking at the token's ownership.

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 inheritence contract, but that doesn't work for ERC-721 because the tokens are not fungible. This is the equivalent.

The 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 are HashMap objects, unidirectional mappings that between two types.

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.

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.

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.

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.

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.

The are the functions that actually implement ERC-721.

In Vyper, as in Python, the constructor function is called __init__.

In Python, and in Vyper, you can also create a comment by specifying a multi-line string (which starts and ends with """), and not using it in any way. These comments can also include NatSpec.

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 block chain, and therefore can be executed for free (if they are called externally, if they 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.

  • @view specifies that this function is a view.
  • @external specifies that this particular function can be called by transactions and by other contracts.

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 (_).

Return the value from the self.supportedInterfaces HashMap, which is set in the constructor (__init__).

These are the view functions that make information about the tokens available to users and other contracts.

This line asserts that _owner is not zero. If it is, there is an error and the operation is reverted.

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.

Note that getApproved can return zero. If the token is valid it returns self.idToApprovals[_tokenId]. If there is no approver that value is zero.

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.

This decoration, @internal, means that the function is only accessible from other functions within the same contract. By convention, these function names also start with an underscore (_).

There are three ways in which an address can be allowed to transfer a token:

  1. The address is the owner of the token
  2. The address is approved to spend that token
  3. The address is an operator for the owner of the token

The function above can be a view because it doesn't change the state. To reduce operating costs, any function that can be a view should be a view.

When there's a problem with a transfer we revert the call.

Only change the value if necessary. State variables live in storage. Writing to storage is one of the most expensive operations the EVM (Ethereum Virtual Machine) does (in terms of gas). Therefore, it is a good idea to minimize it, even writing the existing value has a high cost.

We have this internal function because there are two ways to transfer tokens (regular and safe), but we want only a single location in the code where we do it to make auditing easier.

To emit an event in Vyper you use a log statement (see here for more details).

This function lets you transfer to an arbitrary address. Unless the address is a user, or a contract that knows how to transfer tokens, any token you transfer will be stuck in that address and useless.

It is OK to do the transfer first because if there's a problem we are going to revert anyway, so everything done in the call will be cancelled.

First check to see if the address is a contract (if it has code). If not, assume it is a user address and the user will be able to use the token or transfer it. But don't let it lull you into a false sense of security. You can lose tokens, even with safeTransferFrom, if you transfer them to an address for which nobody knows the private key.

Call the target contract to see if it can receive ERC-721 tokens.

If the destination is a contract, but one that doesn't accept ERC-721 tokens (or that decided not to accept this particular transfer), revert.

By convention if you want not to have an approver you appoint the zero address, not yourself.

To set an approval you can either be the owner, or an operator authorized by the owner.

Mint New Tokens and Destroy Existing Ones

The account that created the contract is the minter, the super user that is authorized to mint new NFTs. However, even it is not allowed to burn existing tokens. Only the owner, or an entity authorized by the owner, can do that.

This function always returns True, because if the the operation fails it is reverted.

Only the minter (the account that created the ERC-721 contract) can mint new tokens. This can be a problem in the future if we want to change the minter's identity. In a production contract you would probably want a function that allows the minter to transfer minter priviliges to somebody else.

By convention, the minting of new tokens counts as a transfer from address zero.

Anybody who is allowed to transfer a token is allowed to burn it. While a burn appears equivalent to transfer to the zero address, the zero address does not actually receives the token. This allows us to free up all the storage that was used for the token, which can reduce the gas cost of the transaction.

In contrast to Solidty, Vyper does not have inheritence. This is a deliberate design choice to make the code clearer and therefore easier to secure. So to create your own Vyper ERC-721 contract you take this contract and modify it to implement the business logic you want.

For review, here are some of the most important ideas in this contract:

  • To receive ERC-721 tokens with a safe transfer, contracts have to implement the ERC721Receiver interface.
  • Even if you use safe transfer, tokens can still get stuck if you send them to an address whose private key is unknown.
  • When there is a problem with an operation it is a good idea to revert the call, rather than just return a failure value.
  • ERC-721 tokens exist when they have an owner.
  • There are three ways to be authorized to transfer an NFT. You can be the owner, be approved for a specific token, or be an operator for all of the owner's tokens.
  • Past events are visible only outside the blockchain. Code running inside the blockchain cannot view them.

Now go and implement secure Vyper contracts.

Leave a Reply