Creating A Simple Voting Blockchain with Vyper and Web3.py (Part 1)

With Mamba, you can create a simple voting blockchain application. In this first part of tutorial, we focus on the test.

Voting is a suitable application to be made on blockchain platform because blockchain enables integrity in voting application easily. Let's create one.

Create a Mamba project directory and setup Mamba project directory.


(.venv) $ mkdir voting
(.venv) $ cd voting
(.venv) $ mamba init

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


# Voting with delegation.

# Information about voters
struct Voter:
    # weight is accumulated by delegation
    weight: int128
    # if true, that person already voted (which includes voting by delegating)
    voted: bool
    # person delegated to
    delegate: address
    # index of the voted proposal, which is not meaningful unless `voted` is True.
    vote: int128

# Users can create proposals
struct Proposal:
    # short name (up to 32 bytes)
    name: bytes32
    # number of accumulated votes
    voteCount: int128

voters: public(map(address, Voter))
proposals: public(map(int128, Proposal))
voterCount: public(int128)
chairperson: public(address)
int128Proposals: public(int128)


@private
@constant
def _delegated(addr: address) -> bool:
    return self.voters[addr].delegate != ZERO_ADDRESS


@public
@constant
def delegated(addr: address) -> bool:
    return self._delegated(addr)


@private
@constant
def _directlyVoted(addr: address) -> bool:
    return self.voters[addr].voted and (self.voters[addr].delegate == ZERO_ADDRESS)


@public
@constant
def directlyVoted(addr: address) -> bool:
    return self._directlyVoted(addr)


# Setup global variables
@public
def __init__(_proposalNames: bytes32[2]):
    self.chairperson = msg.sender
    self.voterCount = 0
    for i in range(2):
        self.proposals[i] = Proposal({
            name: _proposalNames[i],
            voteCount: 0
        })
        self.int128Proposals += 1

# Give a `voter` the right to vote on this ballot.
# This may only be called by the `chairperson`.
@public
def giveRightToVote(voter: address):
    # Throws if the sender is not the chairperson.
    assert msg.sender == self.chairperson
    # Throws if the voter has already voted.
    assert not self.voters[voter].voted
    # Throws if the voter's voting weight isn't 0.
    assert self.voters[voter].weight == 0
    self.voters[voter].weight = 1
    self.voterCount += 1

# Used by `delegate` below, callable externally via `forwardWeight`
@private
def _forwardWeight(delegate_with_weight_to_forward: address):
    assert self._delegated(delegate_with_weight_to_forward)
    # Throw if there is nothing to do:
    assert self.voters[delegate_with_weight_to_forward].weight > 0

    target: address = self.voters[delegate_with_weight_to_forward].delegate
    for i in range(4):
        if self._delegated(target):
            target = self.voters[target].delegate
            # The following effectively detects cycles of length <= 5,
            # in which the delegation is given back to the delegator.
            # This could be done for any int128ber of loops,
            # or even infinitely with a while loop.
            # However, cycles aren't actually problematic for correctness;
            # they just result in spoiled votes.
            # So, in the production version, this should instead be
            # the responsibility of the contract's client, and this
            # check should be removed.
            assert target != delegate_with_weight_to_forward
        else:
            # Weight will be moved to someone who directly voted or
            # hasn't voted.
            break

    weight_to_forward: int128 = self.voters[delegate_with_weight_to_forward].weight
    self.voters[delegate_with_weight_to_forward].weight = 0
    self.voters[target].weight += weight_to_forward

    if self._directlyVoted(target):
        self.proposals[self.voters[target].vote].voteCount += weight_to_forward
        self.voters[target].weight = 0

    # To reiterate: if target is also a delegate, this function will need
    # to be called again, similarly to as above.

# Public function to call _forwardWeight
@public
def forwardWeight(delegate_with_weight_to_forward: address):
    self._forwardWeight(delegate_with_weight_to_forward)

# Delegate your vote to the voter `to`.
@public
def delegate(to: address):
    # Throws if the sender has already voted
    assert not self.voters[msg.sender].voted
    # Throws if the sender tries to delegate their vote to themselves or to
    # the default address value of 0x0000000000000000000000000000000000000000
    # (the latter might not be problematic, but I don't want to think about it).
    assert to != msg.sender
    assert to != ZERO_ADDRESS

    self.voters[msg.sender].voted = True
    self.voters[msg.sender].delegate = to

    # This call will throw if and only if this delegation would cause a loop
        # of length <= 5 that ends up delegating back to the delegator.
    self._forwardWeight(msg.sender)

# Give your vote (including votes delegated to you)
# to proposal `proposals[proposal].name`.
@public
def vote(proposal: int128):
    # can't vote twice
    assert not self.voters[msg.sender].voted
    # can only vote on legitimate proposals
    assert proposal < self.int128Proposals

    self.voters[msg.sender].vote = proposal
    self.voters[msg.sender].voted = True

    # transfer msg.sender's weight to proposal
    self.proposals[proposal].voteCount += self.voters[msg.sender].weight
    self.voters[msg.sender].weight = 0

# Computes the winning proposal taking all
# previous votes into account.
@private
@constant
def _winningProposal() -> int128:
    winning_vote_count: int128 = 0
    winning_proposal: int128 = 0
    for i in range(2):
        if self.proposals[i].voteCount > winning_vote_count:
            winning_vote_count = self.proposals[i].voteCount
            winning_proposal = i
    return winning_proposal

@public
@constant
def winningProposal() -> int128:
    return self._winningProposal()


# Calls winningProposal() function to get the index
# of the winner contained in the proposals array and then
# returns the name of the winner
@public
@constant
def winnerName() -> bytes32:
    return self.proposals[self._winningProposal()].name

Compile it.

(.venv) $ mamba compile

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


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


def test_initial_ballot(eth_tester):
    accounts = eth_tester.get_accounts()
    ballot_contract = contract("ballot", [[b"bitcoin", b"ethereum"]])
    assert ballot_contract.functions.int128Proposals().call() == 2
    assert ballot_contract.functions.chairperson().call() == accounts[0]
    assert ballot_contract.functions.voterCount().call() == 0
    assert ballot_contract.functions.proposals__name(0).call()[:7] == b"bitcoin"
    assert ballot_contract.functions.proposals__name(1).call()[:8] == b"ethereum"
    assert ballot_contract.functions.proposals__voteCount(0).call() == 0
    assert ballot_contract.functions.proposals__voteCount(1).call() == 0

def test_giveRightToVote(eth_tester):
    accounts = eth_tester.get_accounts()
    ballot_contract = contract("ballot", [[b"bitcoin", b"ethereum"]])
    assert ballot_contract.functions.voterCount().call() == 0
    assert ballot_contract.functions.voters__weight(accounts[1]).call() == 0
    tx = { "from": accounts[0] }
    ballot_contract.functions.giveRightToVote(accounts[1]).transact(tx)
    assert ballot_contract.functions.voterCount().call() == 1
    assert ballot_contract.functions.voters__weight(accounts[1]).call() == 1
    ballot_contract.functions.giveRightToVote(accounts[2]).transact(tx)
    assert ballot_contract.functions.voterCount().call() == 2
    assert ballot_contract.functions.voters__weight(accounts[2]).call() == 1

def test_giveRightToVote_fails(eth_tester):
    accounts = eth_tester.get_accounts()
    ballot_contract = contract("ballot", [[b"bitcoin", b"ethereum"]])
    tx = { "from": accounts[1] }
    with pytest.raises(TransactionFailed):
        ballot_contract.functions.giveRightToVote(accounts[1]).transact(tx)

def test_vote(eth_tester):
    accounts = eth_tester.get_accounts()
    ballot_contract = contract("ballot", [[b"bitcoin", b"ethereum"]])
    tx = { "from": accounts[0] }
    ballot_contract.functions.giveRightToVote(accounts[1]).transact(tx)
    ballot_contract.functions.giveRightToVote(accounts[2]).transact(tx)
    voter1_tx = { "from": accounts[1] }
    ballot_contract.functions.vote(0).transact(voter1_tx)
    voter2_tx = { "from": accounts[2] }
    ballot_contract.functions.vote(0).transact(voter2_tx)
    assert ballot_contract.functions.proposals__voteCount(0).call() == 2
    assert ballot_contract.functions.proposals__voteCount(1).call() == 0
    assert ballot_contract.functions.winnerName().call()[:7] == b"bitcoin"

Let's discuss the test file.

The smart contract's init method accept one variable of array of bytes32 type. That's why you see a nested array as a second parameter in contract function.


ballot_contract = contract("ballot", [[b"bitcoin", b"ethereum"]])

In the test file, you are equipped with ten accounts with some ethers. The first account is the one which launched the smart contract.


from black_mamba.testlib import contract, eth_tester
accounts = eth_tester.get_accounts()

To test the value of the variable (like voterCount) of the contract, you must treat the variable just like a function (append the variable with "()").


assert ballot_contract.functions.voterCount().call() == 0

To test the mapping of the struct (like proposals), you can use "__" to access the inner variable of the struct and use number as parameter as the key to mapping variable.


assert ballot_contract.functions.proposals__name(0).call()[:7] == b"bitcoin"

If you notice the slicing with "[:7]", we use slicing because the name of the proposals is byte32 variable. The variable has the length of 32 bytes while "bitcoin" string has 7 bytes. The rest of the bytes are null ("\x00").

To use the statement which will change the state of the smart contract, we use transact method. Inside the transact method, you fill the value of from, gas, gasPrice, value, data, gasLimit. Because this is just a test, you only need to fill value for from field.


tx = { "from": accounts[0] }
ballot_contract.functions.giveRightToVote(accounts[1]).transact(tx)

To test the fail case which will be marked with exception, you can use raises method from pytest library.


from eth_tester.exceptions import TransactionFailed

tx = { "from": accounts[1] }
with pytest.raises(TransactionFailed):
    ballot_contract.functions.giveRightToVote(accounts[1]).transact(tx)

Now, you can run the test.

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

(To be continued...)