From fdad8cd79687fc78f45fead2b748a4cb6cb113c5 Mon Sep 17 00:00:00 2001 From: Scott Sadler Date: Thu, 23 Feb 2017 17:58:08 +0100 Subject: [PATCH] Static Classify Voting --- bigchaindb/voting.py | 238 +++++++++++++++++++++++++++---------------- tests/test_voting.py | 36 ++++--- 2 files changed, 174 insertions(+), 100 deletions(-) diff --git a/bigchaindb/voting.py b/bigchaindb/voting.py index af12f691..dcef47b6 100644 --- a/bigchaindb/voting.py +++ b/bigchaindb/voting.py @@ -1,10 +1,7 @@ -""" -Everything to do with creating and checking votes. -All functions in this module should be referentially transparent, that is, -they always give the same output for a given input. This makes it easier -to test. -""" import collections +from bigchaindb.common.schema import SchemaValidationError, validate_vote_schema +from bigchaindb.common.utils import serialize +from bigchaindb.common.crypto import PublicKey VALID = 'valid' @@ -12,88 +9,159 @@ INVALID = 'invalid' UNDECIDED = 'undecided' -def partition_eligible_votes(votes, eligible_voters, verify_vote_signature): +class Voting: """ - Filter votes from unknown nodes or nodes that are not listed on - block. This is the primary Sybill protection. - """ - eligible, ineligible = ([], []) + Everything to do with creating and checking votes. - for vote in votes: - voter_eligible = vote['node_pubkey'] in eligible_voters - if voter_eligible and verify_vote_signature(vote): - eligible.append(vote) - else: - ineligible.append(vote) + All functions in this class should be referentially transparent, that is, + they always give the same output for a given input. This makes it easier + to test. This also means no logging! - return eligible, ineligible - - -def count_votes(eligible_votes): - """ - Given a list of eligible votes, (votes from known nodes that are listed - as voters), count the votes to produce three quantities: - - Number of votes that say valid - Number of votes that say invalid - Highest agreement on previous block ID - - Also, detect if there are multiple votes from a single node and return them - in a separate "cheat" dictionary. - """ - by_voter = collections.defaultdict(list) - for vote in eligible_votes: - by_voter[vote['node_pubkey']].append(vote) - - n_valid = 0 - n_invalid = 0 - prev_blocks = collections.Counter() - cheat = {} - - for pubkey, votes in by_voter.items(): - if len(votes) > 1: - cheat[pubkey] = votes - n_invalid += 1 - continue - - vote = votes[0] - prev_blocks[vote['vote']['previous_block']] += 1 - if vote['vote']['is_block_valid']: - n_valid += 1 - else: - n_invalid += 1 - - return { - 'n_valid': n_valid, - 'n_invalid': n_invalid, - 'n_agree_prev_block': prev_blocks.most_common()[0][1] - }, cheat - - -def decide_votes(n_voters, n_valid, n_invalid, n_agree_prev_block): - """ - Decide on votes. - - To return VALID there must be a clear majority that say VALID - and also agree on the previous block. This is achieved using the > operator. - - A tie on an even number of votes counts as INVALID so the >= operator is - used. + Assumptions regarding data: + * Vote is a dictionary, but it is not assumed that any properties are. + * Everything else is assumed to be structurally correct, otherwise errors + may be thrown. """ - # Check insane cases. This is basic, not exhaustive. - if n_valid + n_invalid > n_voters or n_agree_prev_block > n_voters: - raise ValueError('Arguments not sane: %s' % { - 'n_voters': n_voters, - 'n_valid': n_valid, - 'n_invalid': n_invalid, - 'n_agree_prev_block': n_agree_prev_block, - }) + @classmethod + def block_election(cls, block, votes, keyring): + """ + Calculate the election status of a block. + """ + eligible_voters = set(block['voters']) & set(keyring) + eligible_votes, ineligible_votes = \ + cls.partition_eligible_votes(votes, eligible_voters) + results = cls.count_votes(eligible_votes) + results['status'] = decide_votes(results['counts']) + results['ineligible'] = ineligible_votes + return results - if n_invalid * 2 >= n_voters: - return INVALID - if n_valid * 2 > n_voters: - if n_agree_prev_block * 2 > n_voters: - return VALID - return INVALID - return UNDECIDED + @classmethod + def partition_eligible_votes(cls, votes, eligible_voters): + """ + Filter votes from unknown nodes or nodes that are not listed on + block. This is the primary Sybill protection. + """ + eligible, ineligible = ([], []) + + for vote in votes: + voter_eligible = vote.get('node_pubkey') in eligible_voters + if voter_eligible and cls.verify_vote_signature(vote): + eligible.append(vote) + else: + ineligible.append(vote) + + return eligible, ineligible + + @classmethod + def count_votes(cls, eligible_votes): + """ + Given a list of eligible votes, (votes from known nodes that are listed + as voters), count the votes to produce three quantities: + + Number of votes that say valid + Number of votes that say invalid + Highest agreement on previous block ID + + Also, detect if there are multiple votes from a single node and return them + in a separate "cheat" dictionary. + """ + by_voter = collections.defaultdict(list) + for vote in eligible_votes: + by_voter[vote['node_pubkey']].append(vote) + + n_valid = 0 + n_invalid = 0 + prev_blocks = collections.Counter() + cheat = [] + malformed = [] + + for pubkey, votes in by_voter.items(): + if len(votes) > 1: + cheat.append(votes) + n_invalid += 1 + continue + + vote = votes[0] + + if not cls.verify_vote_schema(vote): + malformed.append(vote) + n_invalid += 1 + continue + + prev_blocks[vote['vote']['previous_block']] += 1 + if vote['vote']['is_block_valid']: + n_valid += 1 + else: + n_invalid += 1 + + return { + 'counts': { + 'n_valid': n_valid, + 'n_invalid': n_invalid, + 'n_agree_prev_block': prev_blocks.most_common()[0][1], + }, + 'cheat': cheat, + 'malformed': malformed, + } + + @classmethod + def decide_votes(cls, n_voters, n_valid, n_invalid, n_agree_prev_block): + """ + Decide on votes. + + To return VALID there must be a clear majority that say VALID + and also agree on the previous block. This is achieved using the > operator. + + A tie on an even number of votes counts as INVALID so the >= operator is + used. + """ + + # Check insane cases. This is basic, not exhaustive. + if n_valid + n_invalid > n_voters or n_agree_prev_block > n_voters: + raise ValueError('Arguments not sane: %s' % { + 'n_voters': n_voters, + 'n_valid': n_valid, + 'n_invalid': n_invalid, + 'n_agree_prev_block': n_agree_prev_block, + }) + + if n_invalid * 2 >= n_voters: + return INVALID + if n_valid * 2 > n_voters: + if n_agree_prev_block * 2 > n_voters: + return VALID + return INVALID + return UNDECIDED + + @classmethod + def verify_vote_signature(cls, vote): + """Verify the signature of a vote + + A valid vote should have been signed by a voter's private key. + + Args: + vote (list): voters of the block that is under election + + Returns: + bool: True if the signature is correct, False otherwise. + """ + signature = vote.get('signature') + pk_base58 = vote.get('node_pubkey') + + if not (type(signature) == str and type(pk_base58) == str): + raise ValueError("Malformed vote: %s" % vote) + + public_key = PublicKey(pk_base58) + body = serialize(signed_vote['vote']).encode() + return public_key.verify(body, signature) + + @classmethod + def verify_vote_schema(cls, vote): + # I'm not sure this is the correct approach. Maybe we should allow + # duck typing w/r/t votes. + try: + validate_vote_schema(vote) + return True + except SchemaValidationError: + return False diff --git a/tests/test_voting.py b/tests/test_voting.py index 33be2fde..2d7b723f 100644 --- a/tests/test_voting.py +++ b/tests/test_voting.py @@ -1,33 +1,36 @@ import pytest +from unittest.mock import patch from bigchaindb.core import Bigchain -from bigchaindb.voting import (count_votes, partition_eligible_votes, - decide_votes, INVALID, VALID, UNDECIDED) +from bigchaindb.voting import Voting, INVALID, VALID, UNDECIDED ################################################################################ # Tests for checking vote eligibility -def test_partition_eligible_votes(): +@patch('bigchaindb.voting.Voting.verify_vote_signature') +def test_partition_eligible_votes(_): nodes = list(map(Bigchain, 'abc')) votes = [n.vote('block', 'a', True) for n in nodes] - el, inel = partition_eligible_votes(votes, 'abc', lambda _: True) + el, inel = Voting.partition_eligible_votes(votes, 'abc') assert el == votes assert inel == [] -def test_count_votes(): +@patch('bigchaindb.voting.Voting.verify_vote_schema') +def test_count_votes(_): nodes = list(map(Bigchain, 'abc')) + votes = [n.vote('block', 'a', True) for n in nodes] - assert count_votes(votes) == ({ + assert Voting.count_votes(votes)['counts'] == { 'n_valid': 3, 'n_invalid': 0, 'n_agree_prev_block': 3 - }, {}) + } ################################################################################ @@ -53,26 +56,29 @@ DECISION_TESTS = [dict( def test_decide_votes_valid(kwargs): kwargs = kwargs.copy() kwargs['n_invalid'] = 0 - assert decide_votes(**kwargs) == VALID + assert Voting.decide_votes(**kwargs) == VALID kwargs['n_agree_prev_block'] -= 1 - assert decide_votes(**kwargs) == INVALID + assert Voting.decide_votes(**kwargs) == INVALID kwargs['n_valid'] -= 1 - assert decide_votes(**kwargs) == UNDECIDED + assert Voting.decide_votes(**kwargs) == UNDECIDED @pytest.mark.parametrize('kwargs', DECISION_TESTS) def test_decide_votes_invalid(kwargs): kwargs = kwargs.copy() kwargs['n_valid'] = 0 - assert decide_votes(**kwargs) == INVALID + assert Voting.decide_votes(**kwargs) == INVALID kwargs['n_invalid'] -= 1 - assert decide_votes(**kwargs) == UNDECIDED + assert Voting.decide_votes(**kwargs) == UNDECIDED def test_decide_votes_checks_arguments(): with pytest.raises(ValueError): - decide_votes(n_voters=1, n_valid=2, n_invalid=0, n_agree_prev_block=0) + Voting.decide_votes(n_voters=1, n_valid=2, n_invalid=0, + n_agree_prev_block=0) with pytest.raises(ValueError): - decide_votes(n_voters=1, n_valid=0, n_invalid=2, n_agree_prev_block=0) + Voting.decide_votes(n_voters=1, n_valid=0, n_invalid=2, + n_agree_prev_block=0) with pytest.raises(ValueError): - decide_votes(n_voters=1, n_valid=0, n_invalid=0, n_agree_prev_block=2) + Voting.decide_votes(n_voters=1, n_valid=0, n_invalid=0, + n_agree_prev_block=2)