Creating ERC721 Tokens or Non-Fungible Tokens (NFT) with Vyper and Web3.py (Part 1)

Learn how to create ERC721 tokens or Non-Fungible Tokens (NFT) with Vyper and Web3.py

ERC721 tokens or Non-Fungible Tokens (NFT) are different than ERC20 tokens or Fungible Tokens. ERC20 tokens is like money. 10$ money paper that I have is same as 10$ money paper that you have although they have different serial numbers. ERC721 tokens is like painting. Every panting is unique. Mona Lisa painting is unique. There is only one Mona Lisa painting. We could imitate it but the value of the imitation would not be the same as the original Mona Lisa painting.

Without further ado, let's develop ERC721 tokens smart contract. Make sure Mamba is installed and its virtual environment is activated. Initialize the project directory. Of course, you can use Docker if you want.


$ python3 -m venv mamba-venv
$ source mamba-venv/bin/activate
(mamba-venv) $ pip install wheel
(mamba-venv) $ pip install black-mamba
(mamba-venv) $ mkdir erc721
(mamba-venv) $ cd erc721
(mamba-venv) $ mamba init

There is a ERC721 source code that we can use. Download it from here: https://github.com/vyperlang/vyper/blob/master/examples/tokens/ERC721.vy. Put it in contracts directory. Then compile it.


(mamba-venv) $ curl https://raw.githubusercontent.com/vyperlang/vyper/master/examples/tokens/ERC721.vy -o contracts/ERC721.vy
(mamba-venv) $ mamba compile

This is the code of ERC721.vy.


# @dev Implementation of ERC-721 non-fungible token standard.
# @author Ryuya Nakamura (@nrryuya)
# Modified from: https://github.com/vyperlang/vyper/blob/de74722bf2d8718cca46902be165f9fe0e3641dd/examples/tokens/ERC721.vy

from vyper.interfaces import ERC721

implements: ERC721

# Interface for the contract called by safeTransferFrom()
interface ERC721Receiver:
    def onERC721Received(
            _operator: address,
            _from: address,
            _tokenId: uint256,
            _data: Bytes[1024]
        ) -> bytes32: view


# @dev Emits when ownership of any NFT changes by any mechanism. This event emits when NFTs are
#      created (`from` == 0) and destroyed (`to` == 0). Exception: during contract creation, any
#      number of NFTs may be created and assigned without emitting Transfer. At the time of any
#      transfer, the approved address for that NFT (if any) is reset to none.
# @param _from Sender of NFT (if address is zero address it indicates token creation).
# @param _to Receiver of NFT (if address is zero address it indicates token destruction).
# @param _tokenId The NFT that got transfered.
event Transfer:
    sender: indexed(address)
    receiver: indexed(address)
    tokenId: indexed(uint256)

# @dev This emits when the approved address for an NFT is changed or reaffirmed. The zero
#      address indicates there is no approved address. When a Transfer event emits, this also
#      indicates that the approved address for that NFT (if any) is reset to none.
# @param _owner Owner of NFT.
# @param _approved Address that we are approving.
# @param _tokenId NFT which we are approving.
event Approval:
    owner: indexed(address)
    approved: indexed(address)
    tokenId: indexed(uint256)

# @dev This emits when an operator is enabled or disabled for an owner. The operator can manage
#      all NFTs of the owner.
# @param _owner Owner of NFT.
# @param _operator Address to which we are setting operator rights.
# @param _approved Status of operator rights(true if operator rights are given and false if
# revoked).
event ApprovalForAll:
    owner: indexed(address)
    operator: indexed(address)
    approved: bool


# @dev Mapping from NFT ID to the address that owns it.
idToOwner: HashMap[uint256, address]

# @dev Mapping from NFT ID to approved address.
idToApprovals: HashMap[uint256, address]

# @dev Mapping from owner address to count of his tokens.
ownerToNFTokenCount: HashMap[address, uint256]

# @dev Mapping from owner address to mapping of operator addresses.
ownerToOperators: HashMap[address, HashMap[address, bool]]

# @dev Address of minter, who can mint a token
minter: address

# @dev Mapping of interface id to bool about whether or not it's supported
supportedInterfaces: HashMap[bytes32, bool]

# @dev ERC165 interface ID of ERC165
ERC165_INTERFACE_ID: constant(bytes32) = 0x0000000000000000000000000000000000000000000000000000000001ffc9a7

# @dev ERC165 interface ID of ERC721
ERC721_INTERFACE_ID: constant(bytes32) = 0x0000000000000000000000000000000000000000000000000000000080ac58cd


@external
def __init__():
    """
    @dev Contract constructor.
    """
    self.supportedInterfaces[ERC165_INTERFACE_ID] = True
    self.supportedInterfaces[ERC721_INTERFACE_ID] = True
    self.minter = msg.sender


@view
@external
def supportsInterface(_interfaceID: bytes32) -> bool:
    """
    @dev Interface identification is specified in ERC-165.
    @param _interfaceID Id of the interface
    """
    return self.supportedInterfaces[_interfaceID]


### VIEW FUNCTIONS ###

@view
@external
def balanceOf(_owner: address) -> uint256:
    """
    @dev Returns the number of NFTs owned by `_owner`.
         Throws if `_owner` is the zero address. NFTs assigned to the zero address are considered invalid.
    @param _owner Address for whom to query the balance.
    """
    assert _owner != ZERO_ADDRESS
    return self.ownerToNFTokenCount[_owner]


@view
@external
def ownerOf(_tokenId: uint256) -> address:
    """
    @dev Returns the address of the owner of the NFT.
         Throws if `_tokenId` is not a valid NFT.
    @param _tokenId The identifier for an NFT.
    """
    owner: address = self.idToOwner[_tokenId]
    # Throws if `_tokenId` is not a valid NFT
    assert owner != ZERO_ADDRESS
    return owner


@view
@external
def getApproved(_tokenId: uint256) -> address:
    """
    @dev Get the approved address for a single NFT.
         Throws if `_tokenId` is not a valid NFT.
    @param _tokenId ID of the NFT to query the approval of.
    """
    # Throws if `_tokenId` is not a valid NFT
    assert self.idToOwner[_tokenId] != ZERO_ADDRESS
    return self.idToApprovals[_tokenId]


@view
@external
def isApprovedForAll(_owner: address, _operator: address) -> bool:
    """
    @dev Checks if `_operator` is an approved operator for `_owner`.
    @param _owner The address that owns the NFTs.
    @param _operator The address that acts on behalf of the owner.
    """
    return (self.ownerToOperators[_owner])[_operator]


### TRANSFER FUNCTION HELPERS ###

@view
@internal
def _isApprovedOrOwner(_spender: address, _tokenId: uint256) -> bool:
    """
    @dev Returns whether the given spender can transfer a given token ID
    @param spender address of the spender to query
    @param tokenId uint256 ID of the token to be transferred
    @return bool whether the msg.sender is approved for the given token ID,
        is an operator of the owner, or is the owner of the token
    """
    owner: address = self.idToOwner[_tokenId]
    spenderIsOwner: bool = owner == _spender
    spenderIsApproved: bool = _spender == self.idToApprovals[_tokenId]
    spenderIsApprovedForAll: bool = (self.ownerToOperators[owner])[_spender]
    return (spenderIsOwner or spenderIsApproved) or spenderIsApprovedForAll


@internal
def _addTokenTo(_to: address, _tokenId: uint256):
    """
    @dev Add a NFT to a given address
         Throws if `_tokenId` is owned by someone.
    """
    # Throws if `_tokenId` is owned by someone
    assert self.idToOwner[_tokenId] == ZERO_ADDRESS
    # Change the owner
    self.idToOwner[_tokenId] = _to
    # Change count tracking
    self.ownerToNFTokenCount[_to] += 1


@internal
def _removeTokenFrom(_from: address, _tokenId: uint256):
    """
    @dev Remove a NFT from a given address
         Throws if `_from` is not the current owner.
    """
    # Throws if `_from` is not the current owner
    assert self.idToOwner[_tokenId] == _from
    # Change the owner
    self.idToOwner[_tokenId] = ZERO_ADDRESS
    # Change count tracking
    self.ownerToNFTokenCount[_from] -= 1


@internal
def _clearApproval(_owner: address, _tokenId: uint256):
    """
    @dev Clear an approval of a given address
         Throws if `_owner` is not the current owner.
    """
    # Throws if `_owner` is not the current owner
    assert self.idToOwner[_tokenId] == _owner
    if self.idToApprovals[_tokenId] != ZERO_ADDRESS:
        # Reset approvals
        self.idToApprovals[_tokenId] = ZERO_ADDRESS


@internal
def _transferFrom(_from: address, _to: address, _tokenId: uint256, _sender: address):
    """
    @dev Exeute transfer of a NFT.
         Throws unless `msg.sender` is the current owner, an authorized operator, or the approved
         address for this NFT. (NOTE: `msg.sender` not allowed in private function so pass `_sender`.)
         Throws if `_to` is the zero address.
         Throws if `_from` is not the current owner.
         Throws if `_tokenId` is not a valid NFT.
    """
    # Check requirements
    assert self._isApprovedOrOwner(_sender, _tokenId)
    # Throws if `_to` is the zero address
    assert _to != ZERO_ADDRESS
    # Clear approval. Throws if `_from` is not the current owner
    self._clearApproval(_from, _tokenId)
    # Remove NFT. Throws if `_tokenId` is not a valid NFT
    self._removeTokenFrom(_from, _tokenId)
    # Add NFT
    self._addTokenTo(_to, _tokenId)
    # Log the transfer
    log Transfer(_from, _to, _tokenId)


### TRANSFER FUNCTIONS ###

@external
def transferFrom(_from: address, _to: address, _tokenId: uint256):
    """
    @dev Throws unless `msg.sender` is the current owner, an authorized operator, or the approved
         address for this NFT.
         Throws if `_from` is not the current owner.
         Throws if `_to` is the zero address.
         Throws if `_tokenId` is not a valid NFT.
    @notice The caller is responsible to confirm that `_to` is capable of receiving NFTs or else
            they maybe be permanently lost.
    @param _from The current owner of the NFT.
    @param _to The new owner.
    @param _tokenId The NFT to transfer.
    """
    self._transferFrom(_from, _to, _tokenId, msg.sender)


@external
def safeTransferFrom(
        _from: address,
        _to: address,
        _tokenId: uint256,
        _data: Bytes[1024]=b""
    ):
    """
    @dev Transfers the ownership of an NFT from one address to another address.
         Throws unless `msg.sender` is the current owner, an authorized operator, or the
         approved address for this NFT.
         Throws if `_from` is not the current owner.
         Throws if `_to` is the zero address.
         Throws if `_tokenId` is not a valid NFT.
         If `_to` is a smart contract, it calls `onERC721Received` on `_to` and throws if
         the return value is not `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))`.
         NOTE: bytes4 is represented by bytes32 with padding
    @param _from The current owner of the NFT.
    @param _to The new owner.
    @param _tokenId The NFT to transfer.
    @param _data Additional data with no specified format, sent in call to `_to`.
    """
    self._transferFrom(_from, _to, _tokenId, msg.sender)
    if _to.is_contract: # check if `_to` is a contract address
        returnValue: bytes32 = ERC721Receiver(_to).onERC721Received(msg.sender, _from, _tokenId, _data)
        # Throws if transfer destination is a contract which does not implement 'onERC721Received'
        assert returnValue == method_id("onERC721Received(address,address,uint256,bytes)", output_type=bytes32)


@external
def approve(_approved: address, _tokenId: uint256):
    """
    @dev Set or reaffirm the approved address for an NFT. The zero address indicates there is no approved address.
         Throws unless `msg.sender` is the current NFT owner, or an authorized operator of the current owner.
         Throws if `_tokenId` is not a valid NFT. (NOTE: This is not written the EIP)
         Throws if `_approved` is the current owner. (NOTE: This is not written the EIP)
    @param _approved Address to be approved for the given NFT ID.
    @param _tokenId ID of the token to be approved.
    """
    owner: address = self.idToOwner[_tokenId]
    # Throws if `_tokenId` is not a valid NFT
    assert owner != ZERO_ADDRESS
    # Throws if `_approved` is the current owner
    assert _approved != owner
    # Check requirements
    senderIsOwner: bool = self.idToOwner[_tokenId] == msg.sender
    senderIsApprovedForAll: bool = (self.ownerToOperators[owner])[msg.sender]
    assert (senderIsOwner or senderIsApprovedForAll)
    # Set the approval
    self.idToApprovals[_tokenId] = _approved
    log Approval(owner, _approved, _tokenId)


@external
def setApprovalForAll(_operator: address, _approved: bool):
    """
    @dev Enables or disables approval for a third party ("operator") to manage all of
         `msg.sender`'s assets. It also emits the ApprovalForAll event.
         Throws if `_operator` is the `msg.sender`. (NOTE: This is not written the EIP)
    @notice This works even if sender doesn't own any tokens at the time.
    @param _operator Address to add to the set of authorized operators.
    @param _approved True if the operators is approved, false to revoke approval.
    """
    # Throws if `_operator` is the `msg.sender`
    assert _operator != msg.sender
    self.ownerToOperators[msg.sender][_operator] = _approved
    log ApprovalForAll(msg.sender, _operator, _approved)


### MINT & BURN FUNCTIONS ###

@external
def mint(_to: address, _tokenId: uint256) -> bool:
    """
    @dev Function to mint tokens
         Throws if `msg.sender` is not the minter.
         Throws if `_to` is zero address.
         Throws if `_tokenId` is owned by someone.
    @param _to The address that will receive the minted tokens.
    @param _tokenId The token id to mint.
    @return A boolean that indicates if the operation was successful.
    """
    # Throws if `msg.sender` is not the minter
    assert msg.sender == self.minter
    # Throws if `_to` is zero address
    assert _to != ZERO_ADDRESS
    # Add NFT. Throws if `_tokenId` is owned by someone
    self._addTokenTo(_to, _tokenId)
    log Transfer(ZERO_ADDRESS, _to, _tokenId)
    return True


@external
def burn(_tokenId: uint256):
    """
    @dev Burns a specific ERC721 token.
         Throws unless `msg.sender` is the current owner, an authorized operator, or the approved
         address for this NFT.
         Throws if `_tokenId` is not a valid NFT.
    @param _tokenId uint256 id of the ERC721 token to be burned.
    """
    # Check requirements
    assert self._isApprovedOrOwner(msg.sender, _tokenId)
    owner: address = self.idToOwner[_tokenId]
    # Throws if `_tokenId` is not a valid NFT
    assert owner != ZERO_ADDRESS
    self._clearApproval(owner, _tokenId)
    self._removeTokenFrom(owner, _tokenId)
    log Transfer(owner, ZERO_ADDRESS, _tokenId)

Create a unit test for this smart contract: test/test_ERC721.py.


from black_mamba.testlib import contract, w3, TestContract
from web3 import Web3
import pytest


class TestERC721(TestContract):

    def test_initial_contract(self, w3):
        erc721_contract = contract("ERC721", [])
        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            erc721_contract.functions.mint(w3.eth.accounts[2], 1).transact(tx)
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[2], 1).transact(tx)

    def test_mint(self, w3):
        erc721_contract = contract("ERC721", [])
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[0]).call() == 0
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[1]).call() == 0
        with pytest.raises(ValueError):
            assert erc721_contract.functions.ownerOf(1).call()

        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 1).transact(tx)
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[0]).call() == 0
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[1]).call() == 1
        assert erc721_contract.functions.ownerOf(1).call() == w3.eth.accounts[1]

        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 2).transact(tx)
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[0]).call() == 0
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[1]).call() == 2
        assert erc721_contract.functions.ownerOf(2).call() == w3.eth.accounts[1]

    def test_transferFrom(self, w3):
        erc721_contract = contract("ERC721", [])
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 1).transact(tx)
        assert erc721_contract.functions.ownerOf(1).call() == w3.eth.accounts[1]

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[2], 1).transact(tx)

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[2], 1).transact(tx)
        assert erc721_contract.functions.ownerOf(1).call() == w3.eth.accounts[2]

    def test_approve(self, w3):
        erc721_contract = contract("ERC721", [])
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 1).transact(tx)
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 2).transact(tx)

        assert erc721_contract.functions.getApproved(1).call() == '0x0000000000000000000000000000000000000000'

        tx = { "from": w3.eth.accounts[3], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 1).transact(tx)

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.approve(w3.eth.accounts[2], 1).transact(tx)
        assert erc721_contract.functions.getApproved(1).call() == w3.eth.accounts[2]

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.approve(w3.eth.accounts[3], 1).transact(tx)
        assert erc721_contract.functions.getApproved(1).call() == w3.eth.accounts[3]

        tx = { "from": w3.eth.accounts[3], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 1).transact(tx)
        with pytest.raises(ValueError):
            erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 2).transact(tx)

    def test_setApprovalForAll(self, w3):
        erc721_contract = contract("ERC721", [])
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 1).transact(tx)
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 2).transact(tx)
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 3).transact(tx)

        assert erc721_contract.functions.isApprovedForAll(w3.eth.accounts[0], w3.eth.accounts[1]).call() == False

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 1).transact(tx)

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.setApprovalForAll(w3.eth.accounts[2], True).transact(tx)

        assert erc721_contract.functions.isApprovedForAll(w3.eth.accounts[1], w3.eth.accounts[2]).call() == True

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 1).transact(tx)

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 2).transact(tx)

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.setApprovalForAll(w3.eth.accounts[2], False).transact(tx)

        assert erc721_contract.functions.isApprovedForAll(w3.eth.accounts[1], w3.eth.accounts[2]).call() == False

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 3).transact(tx)

    def test_burn(self, w3):
        erc721_contract = contract("ERC721", [])
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 1).transact(tx)

        assert erc721_contract.functions.ownerOf(1).call() == w3.eth.accounts[1]

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            assert erc721_contract.functions.burn(1).call()

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.burn(1).transact(tx)

        with pytest.raises(ValueError):
            assert erc721_contract.functions.ownerOf(1).call()

Then you can run the test. But before doing that, use Ganache CLI for the tester provider. Don't use EthereumTesterProvider (it got some bugs). Run Ganache CLI in a different terminal.


$ docker run -p 8545:8545 trufflesuite/ganache-cli:latest
Ganache CLI v6.10.2 (ganache-core: 2.11.3)
....

Then you can run the test.


(mamba-venv) $ py.test test/test_ERC721.py

The test should run perfectly. Now it's time to discuss the code.

To create an art on the blockchain or an ERC721 token, only the minter (the one who mints) can do that. The address which deploys the smart contract automatically becomes the minter as you can see in the constructor method and the mint method. When the minter mints the token, she needs to specify who is the owner of this new minted token. The token is just a number, a 256 bit number. So using painting as analogy, the token 1 is different than the token 2. The token 1 can be owned by account A. The token 2 can be owned by account B. The value of the token 1 can be different than the value of the token 2. How do we link the token 1 to the digital art? You can link them outside the smart contract. So you can put a digital painting A and tell everyone in the world that the token 1 in this smart contract represents the digital painting A. You can also modify the smart contract and put the hash of the content of the digital painting A in the smart contract.


# ERC721.vy
minter: address

@external
def __init__():
    ...
    self.minter = msg.sender

@external
def mint(_to: address, _tokenId: uint256) -> bool:
    ...
    assert msg.sender == self.minter
    ...
    self._addTokenTo(_to, _tokenId)
    ...

We can test this scenario with this test method.


    # test_ERC721.py
    def test_initial_contract(self, w3):
        erc721_contract = contract("ERC721", [])
        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            erc721_contract.functions.mint(w3.eth.accounts[2], 1).transact(tx)
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[2], 1).transact(tx)

In test_initial_contract, we test that the address which deploys the smart contact becomes the minter. Remember, only the minter can call mint method. Then we need to test the mint method itself.


    # test_ERC721.py
    def test_mint(self, w3):
        erc721_contract = contract("ERC721", [])
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[0]).call() == 0
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[1]).call() == 0
        with pytest.raises(ValueError):
            assert erc721_contract.functions.ownerOf(1).call()

        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 1).transact(tx)
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[0]).call() == 0
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[1]).call() == 1
        assert erc721_contract.functions.ownerOf(1).call() == w3.eth.accounts[1]

        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 2).transact(tx)
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[0]).call() == 0
        assert erc721_contract.functions.balanceOf(w3.eth.accounts[1]).call() == 2
        assert erc721_contract.functions.ownerOf(2).call() == w3.eth.accounts[1]

We are testing the mint method with other methods, such as balanceOf and ownerOf. The balanceOf method checks how many tokens the address has. The ownerOf method checks who is the owner of this token.

Let's move on to the transferFrom method. We need to have the capability to transfer the ownership of the art, right? For example, you may want to sell your Mona Lisa digital painting on the blockchain.


# ERC721.vy
@external
def transferFrom(_from: address, _to: address, _tokenId: uint256):
    ...
    self._transferFrom(_from, _to, _tokenId, msg.sender)

@internal
def _transferFrom(_from: address, _to: address, _tokenId: uint256, _sender: address):
    ...
    assert self._isApprovedOrOwner(_sender, _tokenId)
    # Throws if `_to` is the zero address
    assert _to != ZERO_ADDRESS
    # Clear approval. Throws if `_from` is not the current owner
    self._clearApproval(_from, _tokenId)
    # Remove NFT. Throws if `_tokenId` is not a valid NFT
    self._removeTokenFrom(_from, _tokenId)
    # Add NFT
    self._addTokenTo(_to, _tokenId)
    # Log the transfer
    log Transfer(_from, _to, _tokenId)

Only the owner or the approved operators (more on this later) can transfer the ownership of the token. In the method, we clear the approval (it's like Access Control List), remove the token from the owner, then add the token to the destination.

Let's test transferFrom method.


    # test_ERC721.py
    def test_transferFrom(self, w3):
        erc721_contract = contract("ERC721", [])
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 1).transact(tx)
        assert erc721_contract.functions.ownerOf(1).call() == w3.eth.accounts[1]

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[2], 1).transact(tx)

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[2], 1).transact(tx)
        assert erc721_contract.functions.ownerOf(1).call() == w3.eth.accounts[2]

Here, we only test the transferFrom method called by the owner. After the method being executed, the ownership of the token has been changed.

Let's look at the approve method.


# ERC721.vy
@external
def approve(_approved: address, _tokenId: uint256):
    ...
    owner: address = self.idToOwner[_tokenId]
    # Throws if `_tokenId` is not a valid NFT
    assert owner != ZERO_ADDRESS
    # Throws if `_approved` is the current owner
    assert _approved != owner
    # Check requirements
    senderIsOwner: bool = self.idToOwner[_tokenId] == msg.sender
    senderIsApprovedForAll: bool = (self.ownerToOperators[owner])[msg.sender]
    assert (senderIsOwner or senderIsApprovedForAll)
    # Set the approval
    self.idToApprovals[_tokenId] = _approved
    log Approval(owner, _approved, _tokenId)

The approval ACL (Access Control List) is just a dictionary (idToApprovals). The key is the token number. The value is the address bestowed with the power to execute the transferring token method.

Let's test the approve method.


    # test_ERC721.py
    def test_approve(self, w3):
        erc721_contract = contract("ERC721", [])
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 1).transact(tx)
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 2).transact(tx)

        assert erc721_contract.functions.getApproved(1).call() == '0x0000000000000000000000000000000000000000'

        tx = { "from": w3.eth.accounts[3], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 1).transact(tx)

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.approve(w3.eth.accounts[2], 1).transact(tx)
        assert erc721_contract.functions.getApproved(1).call() == w3.eth.accounts[2]

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.approve(w3.eth.accounts[3], 1).transact(tx)
        assert erc721_contract.functions.getApproved(1).call() == w3.eth.accounts[3]

        tx = { "from": w3.eth.accounts[3], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 1).transact(tx)
        with pytest.raises(ValueError):
            erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 2).transact(tx)

We can check the result of the approve method with getApproved and transferFrom methods. getApproved method accepts token number and returns the operator who got the power. To test whether the operator can transfer the token of the owner, we can use transferFrom method.

Say account A owns token 1 and token 2. Account A approves account B for token 1. It means account B can transfer token 1 which owned by account A to any other account even to herself. But account B does not have the power to transfer token 2. The approval is based per token.

What if account A has 1000 tokens and wants to give account B the power to manage her 1000 tokens? There is setApprovalForAll method for such purpose.


# ERC721.vy
@external
def setApprovalForAll(_operator: address, _approved: bool):
    ...
    # Throws if `_operator` is the `msg.sender`
    assert _operator != msg.sender
    self.ownerToOperators[msg.sender][_operator] = _approved
    log ApprovalForAll(msg.sender, _operator, _approved)

The ownerToOperators is a double nested dictionary. The outer key is the owner. The inner key is the operator. The value is the boolean (whether the operator is approved or not).

If ownerToOperators[account A][account B] = true, then account B can manage all account A's tokens.

Let's test the setApprovalForAll method.


    # test_ERC721.py
    def test_setApprovalForAll(self, w3):
        erc721_contract = contract("ERC721", [])
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 1).transact(tx)
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 2).transact(tx)
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 3).transact(tx)

        assert erc721_contract.functions.isApprovedForAll(w3.eth.accounts[0], w3.eth.accounts[1]).call() == False

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 1).transact(tx)

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.setApprovalForAll(w3.eth.accounts[2], True).transact(tx)

        assert erc721_contract.functions.isApprovedForAll(w3.eth.accounts[1], w3.eth.accounts[2]).call() == True

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 1).transact(tx)

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 2).transact(tx)

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.setApprovalForAll(w3.eth.accounts[2], False).transact(tx)

        assert erc721_contract.functions.isApprovedForAll(w3.eth.accounts[1], w3.eth.accounts[2]).call() == False

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            erc721_contract.functions.transferFrom(w3.eth.accounts[1], w3.eth.accounts[4], 3).transact(tx)

We can check the setApprovalForAll method with isApprovedForAll and transferFrom methods. The isApprovedForAll method accepts two parameters: the owner and the operator. It returns the boolean value. If it is true, then the operator can execute the transferFrom in the name of the owner.

Lastly, the owner of the token (or the approved operators) can burn the token. Burning the token means sending the token to abyss. It's like burning Mona Lisa painting with fire. Why do you want to do that? I don't know. Maybe you want to renounce your material possession and become a monk.


# ERC721.vy
@external
def burn(_tokenId: uint256):
    ...
    # Check requirements
    assert self._isApprovedOrOwner(msg.sender, _tokenId)
    owner: address = self.idToOwner[_tokenId]
    # Throws if `_tokenId` is not a valid NFT
    assert owner != ZERO_ADDRESS
    self._clearApproval(owner, _tokenId)
    self._removeTokenFrom(owner, _tokenId)
    log Transfer(owner, ZERO_ADDRESS, _tokenId)

Basically we just clear the approval and remove the token from the owner. Let's test this method.


    # test_ERC721.py
    def test_burn(self, w3):
        erc721_contract = contract("ERC721", [])
        tx = { "from": w3.eth.accounts[0], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.mint(w3.eth.accounts[1], 1).transact(tx)

        assert erc721_contract.functions.ownerOf(1).call() == w3.eth.accounts[1]

        tx = { "from": w3.eth.accounts[2], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        with pytest.raises(ValueError):
            assert erc721_contract.functions.burn(1).call()

        tx = { "from": w3.eth.accounts[1], "gas": 200000, "gasPrice": Web3.toWei("200", "gwei") }
        erc721_contract.functions.burn(1).transact(tx)

        with pytest.raises(ValueError):
            assert erc721_contract.functions.ownerOf(1).call()

Once the token is burnt, the ownerOf method throws exception.

In the next part of this tutorial, we will discuss on modifying this smart contract code to handle the case of uploading the digital art to IPFS or Swarm. Stay tuned.

If you like this tutorial, you can donate to Mamba project. Or you can send some ETHs to arjunaskykok.eth directly 💰. Or you can just simply share this tutorial in social media. Or you can just send some positive energy to me. Close your eyes and say these words inside your heart, "May Arjuna Sky Kok be happy and successful." 🧘