Creating Your Own ERC20 Token with Vyper and Web3.py (Part 1)

With Mamba, you can create your own ERC20 token. In this first part of tutorial, we focus on the test.

ERC20 token is one of the most famous projects that you can build on top of blockchain. Let's create your own token.

Create a Mamba project directory and setup Mamba project directory.


$ python3 -m venv .venv
$ source .venv/bin/activate
(.venv) $ pip install wheel
(.venv) $ pip install black-mamba
(.venv) $ pip install git+git://github.com/ethereum/eth-tester.git@c12172cf3191eda2c57a1f3b80569448ca52796f
(.venv) $ mkdir erc20
(.venv) $ cd erc20
(.venv) $ mamba init

There is already an ERC20 token application source code in Vyper programming language. You can download it from Vyper GitHub page: https://github.com/ethereum/vyper/blob/master/examples/tokens/ERC20.vy. Put it in contracts directory inside Mamba project directory. For reference, this is the content of ERC20.vy.


# @dev Implementation of ERC-20 token standard.
# @author Takayuki Jimba (@yudetamago)
# https://github.com/ethereum/EIPs/blob/master/EIPS/eip-20.md

from vyper.interfaces import ERC20

implements: ERC20

event Transfer:
    sender: indexed(address)
    receiver: indexed(address)
    value: uint256

event Approval:
    owner: indexed(address)
    spender: indexed(address)
    value: uint256

name: public(String[64])
symbol: public(String[32])
decimals: public(uint256)

# NOTE: By declaring `balanceOf` as public, vyper automatically generates a 'balanceOf()' getter
#       method to allow access to account balances.
#       The _KeyType will become a required parameter for the getter and it will return _ValueType.
#       See: https://vyper.readthedocs.io/en/v0.1.0-beta.8/types.html?highlight=getter#mappings
balanceOf: public(HashMap[address, uint256])
allowances: HashMap[address, HashMap[address, uint256]]
total_supply: uint256
minter: address


@external
def __init__(_name: String[64], _symbol: String[32], _decimals: uint256, _supply: uint256):
    init_supply: uint256 = _supply * 10 ** _decimals
    self.name = _name
    self.symbol = _symbol
    self.decimals = _decimals
    self.balanceOf[msg.sender] = init_supply
    self.total_supply = init_supply
    self.minter = msg.sender
    log Transfer(ZERO_ADDRESS, msg.sender, init_supply)


@view
@external
def totalSupply() -> uint256:
    """
    @dev Total number of tokens in existence.
    """
    return self.total_supply


@view
@external
def allowance(_owner : address, _spender : address) -> uint256:
    """
    @dev Function to check the amount of tokens that an owner allowed to a spender.
    @param _owner The address which owns the funds.
    @param _spender The address which will spend the funds.
    @return An uint256 specifying the amount of tokens still available for the spender.
    """
    return self.allowances[_owner][_spender]


@external
def transfer(_to : address, _value : uint256) -> bool:
    """
    @dev Transfer token for a specified address
    @param _to The address to transfer to.
    @param _value The amount to be transferred.
    """
    # NOTE: vyper does not allow underflows
    #       so the following subtraction would revert on insufficient balance
    self.balanceOf[msg.sender] -= _value
    self.balanceOf[_to] += _value
    log Transfer(msg.sender, _to, _value)
    return True


@external
def transferFrom(_from : address, _to : address, _value : uint256) -> bool:
    """
     @dev Transfer tokens from one address to another.
     @param _from address The address which you want to send tokens from
     @param _to address The address which you want to transfer to
     @param _value uint256 the amount of tokens to be transferred
    """
    # NOTE: vyper does not allow underflows
    #       so the following subtraction would revert on insufficient balance
    self.balanceOf[_from] -= _value
    self.balanceOf[_to] += _value
    # NOTE: vyper does not allow underflows
    #      so the following subtraction would revert on insufficient allowance
    self.allowances[_from][msg.sender] -= _value
    log Transfer(_from, _to, _value)
    return True


@external
def approve(_spender : address, _value : uint256) -> bool:
    """
    @dev Approve the passed address to spend the specified amount of tokens on behalf of msg.sender.
         Beware that changing an allowance with this method brings the risk that someone may use both the old
         and the new allowance by unfortunate transaction ordering. One possible solution to mitigate this
         race condition is to first reduce the spender's allowance to 0 and set the desired value afterwards:
         https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
    @param _spender The address which will spend the funds.
    @param _value The amount of tokens to be spent.
    """
    self.allowances[msg.sender][_spender] = _value
    log Approval(msg.sender, _spender, _value)
    return True


@external
def mint(_to: address, _value: uint256):
    """
    @dev Mint an amount of the token and assigns it to an account.
         This encapsulates the modification of balances such that the
         proper events are emitted.
    @param _to The account that will receive the created tokens.
    @param _value The amount that will be created.
    """
    assert msg.sender == self.minter
    assert _to != ZERO_ADDRESS
    self.total_supply += _value
    self.balanceOf[_to] += _value
    log Transfer(ZERO_ADDRESS, _to, _value)


@internal
def _burn(_to: address, _value: uint256):
    """
    @dev Internal function that burns an amount of the token of a given
         account.
    @param _to The account whose tokens will be burned.
    @param _value The amount that will be burned.
    """
    assert _to != ZERO_ADDRESS
    self.total_supply -= _value
    self.balanceOf[_to] -= _value
    log Transfer(_to, ZERO_ADDRESS, _value)


@external
def burn(_value: uint256):
    """
    @dev Burn an amount of the token of msg.sender.
    @param _value The amount that will be burned.
    """
    self._burn(msg.sender, _value)


@external
def burnFrom(_to: address, _value: uint256):
    """
    @dev Burn an amount of the token from a given account.
    @param _to The account whose tokens will be burned.
    @param _value The amount that will be burned.
    """
    self.allowances[_to][msg.sender] -= _value
    self._burn(_to, _value)

Compile it.

(.venv) $ mamba compile

Now, let's create a test for this voting application. Create test/test_erc20.py.


from black_mamba.testlib import contract, w3, TestContract
from eth_tester.exceptions import TransactionFailed
import pytest


ZERO_ADDRESS = '0x' + '0' * 40


@pytest.fixture()
def erc20_contract():
    erc20_contract = contract("ERC20", ["Haha Coin", "HAH", 3, 1000])
    yield erc20_contract


class TestERC20(TestContract):

    def allow_account(self, erc20_contract, manager, middle, allowance_money):
        assert erc20_contract.functions.allowance(manager, middle).call() == 0
        erc20_contract.functions.approve(middle, allowance_money).transact({ "from": manager })
        assert erc20_contract.functions.allowance(manager, middle).call() == allowance_money
        approval_log = erc20_contract.events.Approval.getLogs()[0].args
        assert approval_log.owner == manager
        assert approval_log.spender == middle
        assert approval_log.value == allowance_money

    def test_initial(self, w3, erc20_contract):
        assert erc20_contract.functions.name().call() == "Haha Coin"
        assert erc20_contract.functions.symbol().call() == "HAH"
        assert erc20_contract.functions.decimals().call() == 3
        assert erc20_contract.functions.totalSupply().call() == 1000000
        assert erc20_contract.functions.balanceOf(w3.eth.accounts[0]).call() == 1000000
        assert erc20_contract.functions.balanceOf(w3.eth.accounts[1]).call() == 0

    def test_transfer(self, w3, erc20_contract):
        manager = w3.eth.accounts[0]
        destination = w3.eth.accounts[1]
        total_supply = 1000000
        transfer_money = 900
        assert erc20_contract.functions.balanceOf(manager).call() == total_supply
        assert erc20_contract.functions.balanceOf(destination).call() == 0
        erc20_contract.functions.transfer(destination, transfer_money).transact({ "from": manager })
        assert erc20_contract.functions.balanceOf(manager).call() == total_supply - transfer_money
        assert erc20_contract.functions.balanceOf(destination).call() == transfer_money
        transfer_log = erc20_contract.events.Transfer.getLogs()[0].args
        assert transfer_log.receiver == destination
        assert transfer_log.sender == manager
        assert transfer_log.value == transfer_money

    def test_mint(self, w3, erc20_contract):
        manager = w3.eth.accounts[0]
        destination = w3.eth.accounts[1]
        total_supply = 1000000
        mint_amount = 1000
        assert erc20_contract.functions.totalSupply().call() == total_supply
        assert erc20_contract.functions.balanceOf(destination).call() == 0
        erc20_contract.functions.mint(destination, mint_amount).transact({ "from": manager })
        assert erc20_contract.functions.totalSupply().call() == total_supply + mint_amount
        assert erc20_contract.functions.balanceOf(destination).call() == mint_amount
        transfer_log = erc20_contract.events.Transfer.getLogs()[0].args
        assert transfer_log.receiver == destination
        assert transfer_log.sender == ZERO_ADDRESS
        assert transfer_log.value == mint_amount

    def test_transferFrom(self, w3, erc20_contract):
        manager = w3.eth.accounts[0]
        middle = w3.eth.accounts[1]
        destination = w3.eth.accounts[1]
        total_supply = 1000000
        allowance_money = 900
        transfer_money = 700
        too_big_money = 1000

        self.allow_account(erc20_contract, manager, middle, allowance_money)

        assert erc20_contract.functions.balanceOf(destination).call() == 0
        with pytest.raises(TransactionFailed):
            erc20_contract.functions.transferFrom(manager, destination, too_big_money).transact({ "from": middle })
        erc20_contract.functions.transferFrom(manager, destination, transfer_money).transact({ "from": middle })
        assert erc20_contract.functions.balanceOf(destination).call() == transfer_money
        assert erc20_contract.functions.balanceOf(manager).call() == total_supply - transfer_money
        assert erc20_contract.functions.allowance(manager, middle).call() == allowance_money - transfer_money
        transfer_log = erc20_contract.events.Transfer.getLogs()[0].args
        assert transfer_log.receiver == destination
        assert transfer_log.sender == manager
        assert transfer_log.value == transfer_money

    def test_burn(self, w3, erc20_contract):
        manager = w3.eth.accounts[0]
        total_supply = 1000000
        burn_amount = 1000
        assert erc20_contract.functions.totalSupply().call() == total_supply
        erc20_contract.functions.burn(burn_amount).transact({ "from": manager })
        assert erc20_contract.functions.totalSupply().call() == total_supply - burn_amount
        assert erc20_contract.functions.balanceOf(manager).call() == total_supply - burn_amount
        transfer_log = erc20_contract.events.Transfer.getLogs()[0].args
        assert transfer_log.receiver == ZERO_ADDRESS
        assert transfer_log.sender == manager
        assert transfer_log.value == burn_amount

    def test_burnFrom(self, w3, erc20_contract):
        manager = w3.eth.accounts[0]
        middle = w3.eth.accounts[1]
        destination = w3.eth.accounts[1]
        total_supply = 1000000
        allowance_money = 900
        burn_money = 700
        too_big_money = 1000

        self.allow_account(erc20_contract, manager, middle, allowance_money)

        with pytest.raises(TransactionFailed):
            erc20_contract.functions.burnFrom(manager, too_big_money).transact({ "from": middle })
        erc20_contract.functions.burnFrom(manager, burn_money).transact({ "from": middle })
        assert erc20_contract.functions.balanceOf(manager).call() == total_supply - burn_money
        assert erc20_contract.functions.allowance(manager, middle).call() == allowance_money - burn_money
        transfer_log = erc20_contract.events.Transfer.getLogs()[0].args
        assert transfer_log.receiver == ZERO_ADDRESS
        assert transfer_log.sender == manager
        assert transfer_log.value == burn_money

Let's discuss the test file. First, you need fixtures to make your tests simpler.


@pytest.fixture()
def erc20_contract():
    erc20_contract = contract("ERC20", ["Haha Coin", "HAH", 3, 1000])
    yield erc20_contract

Here, you create a Pytest fixture for creating a smart contract object. Later you must pass this fixture to each test method.

Let's create a helper method to approve the allowance.


    def allow_account(self, erc20_contract, manager, middle, allowance_money):
        assert erc20_contract.functions.allowance(manager, middle).call() == 0
        erc20_contract.functions.approve(middle, allowance_money).transact({ "from": manager })
        assert erc20_contract.functions.allowance(manager, middle).call() == allowance_money
        approval_log = erc20_contract.events.Approval.getLogs()[0].args
        assert approval_log.owner == manager
        assert approval_log.spender == middle
        assert approval_log.value == allowance_money

You check the logs of Approval event and make sure the parameters are correct.

Let's create the first test method of this ERC20 token.


    def test_initial(self, w3, erc20_contract):
        assert erc20_contract.functions.name().call() == "Haha Coin"
        assert erc20_contract.functions.symbol().call() == "HAH"
        assert erc20_contract.functions.decimals().call() == 3
        assert erc20_contract.functions.totalSupply().call() == 1000000
        assert erc20_contract.functions.balanceOf(w3.eth.accounts[0]).call() == 1000000
        assert erc20_contract.functions.balanceOf(w3.eth.accounts[1]).call() == 0

Here, you check the name, symbol, decimals, total supply and the balance of the manager's account. Notice that your test method has the fixture in its arguments (erc20_contract).

Let's create the second test method to test the transfer method.


    def test_transfer(self, w3, erc20_contract):
        manager = w3.eth.accounts[0]
        destination = w3.eth.accounts[1]
        total_supply = 1000000
        transfer_money = 900
        assert erc20_contract.functions.balanceOf(manager).call() == total_supply
        assert erc20_contract.functions.balanceOf(destination).call() == 0
        erc20_contract.functions.transfer(destination, transfer_money).transact({ "from": manager })
        assert erc20_contract.functions.balanceOf(manager).call() == total_supply - transfer_money
        assert erc20_contract.functions.balanceOf(destination).call() == transfer_money
        transfer_log = erc20_contract.events.Transfer.getLogs()[0].args
        assert transfer_log.receiver == destination
        assert transfer_log.sender == manager
        assert transfer_log.value == transfer_money

Basically you check the balance of the accounts before and after the transfer happening. On top of that, don't forget to check the Transfer event logs.

Let's create a test method for minting.


    def test_mint(self, w3, erc20_contract):
        manager = w3.eth.accounts[0]
        destination = w3.eth.accounts[1]
        total_supply = 1000000
        mint_amount = 1000
        assert erc20_contract.functions.totalSupply().call() == total_supply
        assert erc20_contract.functions.balanceOf(destination).call() == 0
        erc20_contract.functions.mint(destination, mint_amount).transact({ "from": manager })
        assert erc20_contract.functions.totalSupply().call() == total_supply + mint_amount
        assert erc20_contract.functions.balanceOf(destination).call() == mint_amount
        transfer_log = erc20_contract.events.Transfer.getLogs()[0].args
        assert transfer_log.receiver == destination
        assert transfer_log.sender == ZERO_ADDRESS
        assert transfer_log.value == mint_amount

Basically you test the total supply before and after the minting method is being executed. You also check the balance of the account which receive the additional coins. Don't forget to check the Transfer event logs.

Let's create a test for transferFrom method.


    def test_transferFrom(self, w3, erc20_contract):
        manager = w3.eth.accounts[0]
        middle = w3.eth.accounts[1]
        destination = w3.eth.accounts[1]
        total_supply = 1000000
        allowance_money = 900
        transfer_money = 700
        too_big_money = 1000

        self.allow_account(erc20_contract, manager, middle, allowance_money)

        assert erc20_contract.functions.balanceOf(destination).call() == 0
        with pytest.raises(TransactionFailed):
            erc20_contract.functions.transferFrom(manager, destination, too_big_money).transact({ "from": middle })
        erc20_contract.functions.transferFrom(manager, destination, transfer_money).transact({ "from": middle })
        assert erc20_contract.functions.balanceOf(destination).call() == transfer_money
        assert erc20_contract.functions.balanceOf(manager).call() == total_supply - transfer_money
        assert erc20_contract.functions.allowance(manager, middle).call() == allowance_money - transfer_money
        transfer_log = erc20_contract.events.Transfer.getLogs()[0].args
        assert transfer_log.receiver == destination
        assert transfer_log.sender == manager
        assert transfer_log.value == transfer_money

Basically you test the allowance account of related accounts before and after transferFrom method is being executed. On top of that, you also check the balances of sender and destination account. Remember, you don't check the balance of the middleman's account. Finally, you check the Transfer event logs.

Let's create a test for burning coins.


    def test_burn(self, w3, erc20_contract):
        manager = w3.eth.accounts[0]
        total_supply = 1000000
        burn_amount = 1000
        assert erc20_contract.functions.totalSupply().call() == total_supply
        erc20_contract.functions.burn(burn_amount).transact({ "from": manager })
        assert erc20_contract.functions.totalSupply().call() == total_supply - burn_amount
        assert erc20_contract.functions.balanceOf(manager).call() == total_supply - burn_amount
        transfer_log = erc20_contract.events.Transfer.getLogs()[0].args
        assert transfer_log.receiver == ZERO_ADDRESS
        assert transfer_log.sender == manager
        assert transfer_log.value == burn_amount

Basically you test the total supply of token before and after burning token happening. You also check the balance of the account which bears the burden of burning. Don't forget to check the Transfer event logs.

Let's create a test for burningFrom method.


    def test_burnFrom(self, w3, erc20_contract):
        manager = w3.eth.accounts[0]
        middle = w3.eth.accounts[1]
        destination = w3.eth.accounts[1]
        total_supply = 1000000
        allowance_money = 900
        burn_money = 700
        too_big_money = 1000

        self.allow_account(erc20_contract, manager, middle, allowance_money)

        with pytest.raises(TransactionFailed):
            erc20_contract.functions.burnFrom(manager, too_big_money).transact({ "from": middle })
        erc20_contract.functions.burnFrom(manager, burn_money).transact({ "from": middle })
        assert erc20_contract.functions.balanceOf(manager).call() == total_supply - burn_money
        assert erc20_contract.functions.allowance(manager, middle).call() == allowance_money - burn_money
        transfer_log = erc20_contract.events.Transfer.getLogs()[0].args
        assert transfer_log.receiver == ZERO_ADDRESS
        assert transfer_log.sender == manager
        assert transfer_log.value == burn_money

It's similar to transferFrom method. You must execute approve method first. Then check the total supply before and after burningFrom method is being executed. You also check the balance of the account which bear the burden of burning. Then you check the allowance of related accounts. Don't forget to check the Transfer event logs.

Now you can run the test.

(.venv) $ py.test test/test_erc20.py

The test should run successfully.