flesh out voting module

This commit is contained in:
Scott Sadler 2017-02-23 15:08:06 +01:00
parent dbfcce34e7
commit d71e560ba4
2 changed files with 129 additions and 16 deletions

View File

@ -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 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 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, ineligible = ([], [])
eligible_votes = []
for vote in votes: for vote in votes:
pubkey = vote['node_pubkey'] voter_eligible = vote['node_pubkey'] in eligible_voters
voter_eligible = pubkey in eligible_voters if voter_eligible and verify_vote_signature(vote):
sig_legit = sig_is_legit(vote) eligible.append(vote)
if voter_eligible and sig_legit: else:
eligible_votes[pubkey].append(vote) 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) by_voter = collections.defaultdict(list)
for vote in eligible_votes: for vote in eligible_votes:
by_voter[vote['node_pubkey']].append(vote) by_voter[vote['node_pubkey']].append(vote)
@ -27,9 +43,11 @@ def count_votes(eligible_votes, check_schema):
n_valid = 0 n_valid = 0
n_invalid = 0 n_invalid = 0
prev_blocks = collections.Counter() prev_blocks = collections.Counter()
cheat = {}
for pubkey, votes in by_voter.items(): 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 n_invalid += 1
continue continue
@ -41,7 +59,41 @@ def count_votes(eligible_votes, check_schema):
n_invalid += 1 n_invalid += 1
return { return {
'valid': n_valid, 'n_valid': n_valid,
'invalid': n_invalid, 'n_invalid': n_invalid,
'prev_block': prev_blocks.most_common()[0] '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'

61
tests/test_voting.py Normal file
View File

@ -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