diff --git a/bigchaindb/voting.py b/bigchaindb/voting.py index 0138e783..159f631f 100644 --- a/bigchaindb/voting.py +++ b/bigchaindb/voting.py @@ -1,25 +1,41 @@ +""" +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 -def filter_eligible_votes(votes, block_voters, keyring, check_signature): +def partition_eligible_votes(votes, eligible_voters, verify_vote_signature): """ Filter votes from unknown nodes or nodes that are not listed on - block. Here is our sybill protection. + block. This is the primary Sybill protection. """ - eligible_voters = set(keyring) & set(block_voters) - eligible_votes = [] + eligible, ineligible = ([], []) for vote in votes: - pubkey = vote['node_pubkey'] - voter_eligible = pubkey in eligible_voters - sig_legit = sig_is_legit(vote) - if voter_eligible and sig_legit: - eligible_votes[pubkey].append(vote) + voter_eligible = vote['node_pubkey'] in eligible_voters + if voter_eligible and verify_vote_signature(vote): + eligible.append(vote) + else: + ineligible.append(vote) - return eligible_votes + return eligible, ineligible -def count_votes(eligible_votes, check_schema): +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) @@ -27,9 +43,11 @@ def count_votes(eligible_votes, check_schema): n_valid = 0 n_invalid = 0 prev_blocks = collections.Counter() + cheat = {} for pubkey, votes in by_voter.items(): - if len(votes) > 1 or not schema_is_correct(votes[0]): + if len(votes) > 1: + cheat[pubkey] = votes n_invalid += 1 continue @@ -41,7 +59,41 @@ def count_votes(eligible_votes, check_schema): n_invalid += 1 return { - 'valid': n_valid, - 'invalid': n_invalid, - 'prev_block': prev_blocks.most_common()[0] - } + '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. + """ + + # 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 + + +INVALID = 'invalid' +VALID = TX_VALID = 'valid' +UNDECIDED = TX_UNDECIDED = 'undecided' diff --git a/tests/test_voting.py b/tests/test_voting.py new file mode 100644 index 00000000..67c5c284 --- /dev/null +++ b/tests/test_voting.py @@ -0,0 +1,61 @@ +import pytest + +from bigchaindb.core import Bigchain +from bigchaindb.voting import (count_votes, partition_eligible_votes, + decide_votes, INVALID, VALID, UNDECIDED) + + +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) + + assert el == votes + assert inel == [] + + +def test_count_votes(): + nodes = list(map(Bigchain, 'abc')) + votes = [n.vote('block', 'a', True) for n in nodes] + + assert count_votes(votes) == ({ + 'n_valid': 3, + 'n_invalid': 0, + 'n_agree_prev_block': 3 + }, {}) + + +DECISION_TESTS = [dict( + zip(['n_voters', 'n_valid', 'n_invalid', 'n_agree_prev_block'], t)) + for t in [ + (1, 1, 1, 1), + (2, 2, 1, 2), + (3, 2, 2, 2), + (4, 3, 2, 3), + (5, 3, 3, 3), + (6, 4, 3, 4), + (7, 4, 4, 4), + (8, 5, 4, 5), + ] +] + + +@pytest.mark.parametrize('kwargs', DECISION_TESTS) +def test_decide_votes_valid(kwargs): + kwargs = kwargs.copy() + kwargs['n_invalid'] = 0 + assert decide_votes(**kwargs) == VALID + kwargs['n_agree_prev_block'] -= 1 + assert decide_votes(**kwargs) == INVALID + kwargs['n_valid'] -= 1 + assert 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 + kwargs['n_invalid'] -= 1 + assert decide_votes(**kwargs) == UNDECIDED