mirror of
https://github.com/bigchaindb/bigchaindb.git
synced 2024-10-13 13:34:05 +00:00
Merge pull request #1213 from bigchaindb/identify-voters
Identify voters + Voting class
This commit is contained in:
commit
9ec9f638fc
158
bigchaindb/voting.py
Normal file
158
bigchaindb/voting.py
Normal file
@ -0,0 +1,158 @@
|
||||
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'
|
||||
INVALID = 'invalid'
|
||||
UNDECIDED = 'undecided'
|
||||
|
||||
|
||||
class Voting:
|
||||
"""
|
||||
Everything to do with creating and checking votes.
|
||||
|
||||
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!
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def block_election(cls, block, votes, keyring):
|
||||
"""
|
||||
Calculate the election status of a block.
|
||||
"""
|
||||
eligible_voters = set(block['block']['voters']) & set(keyring)
|
||||
eligible_votes, ineligible_votes = \
|
||||
cls.partition_eligible_votes(votes, eligible_voters)
|
||||
n_voters = len(eligible_voters)
|
||||
results = cls.count_votes(eligible_votes)
|
||||
results['status'] = cls.decide_votes(n_voters, **results['counts'])
|
||||
results['ineligible'] = ineligible_votes
|
||||
return results
|
||||
|
||||
@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:
|
||||
try:
|
||||
if cls.verify_vote_signature(vote):
|
||||
eligible.append(vote)
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
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), produce the number that say valid and the number that say
|
||||
invalid.
|
||||
|
||||
* Detect if there are multiple votes from a single node and return them
|
||||
in a separate "cheat" dictionary.
|
||||
* Votes must agree on previous block, otherwise they become invalid.
|
||||
|
||||
note:
|
||||
The sum of votes returned by this function does not necessarily
|
||||
equal the length of the list of votes fed in. It may differ for
|
||||
example if there are found to be multiple votes submitted by a
|
||||
single voter.
|
||||
"""
|
||||
prev_blocks = collections.Counter()
|
||||
cheat = []
|
||||
malformed = []
|
||||
|
||||
# Group by pubkey to detect duplicate voting
|
||||
by_voter = collections.defaultdict(list)
|
||||
for vote in eligible_votes:
|
||||
by_voter[vote['node_pubkey']].append(vote)
|
||||
|
||||
for pubkey, votes in by_voter.items():
|
||||
if len(votes) > 1:
|
||||
cheat.append(votes)
|
||||
continue
|
||||
|
||||
vote = votes[0]
|
||||
|
||||
if not cls.verify_vote_schema(vote):
|
||||
malformed.append(vote)
|
||||
continue
|
||||
|
||||
if vote['vote']['is_block_valid'] is True:
|
||||
prev_blocks[vote['vote']['previous_block']] += 1
|
||||
|
||||
n_valid = 0
|
||||
prev_block = None
|
||||
# Valid votes must agree on previous block
|
||||
if prev_blocks:
|
||||
prev_block, n_valid = prev_blocks.most_common()[0]
|
||||
del prev_blocks[prev_block]
|
||||
|
||||
return {
|
||||
'counts': {
|
||||
'n_valid': n_valid,
|
||||
'n_invalid': len(by_voter) - n_valid,
|
||||
},
|
||||
'cheat': cheat,
|
||||
'malformed': malformed,
|
||||
'previous_block': prev_block,
|
||||
'other_previous_block': dict(prev_blocks),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def decide_votes(cls, n_voters, n_valid, n_invalid):
|
||||
"""
|
||||
Decide on votes.
|
||||
|
||||
To return VALID there must be a clear majority that say VALID
|
||||
and also agree on the previous block.
|
||||
|
||||
A tie on an even number of votes counts as INVALID.
|
||||
"""
|
||||
if n_invalid * 2 >= n_voters:
|
||||
return INVALID
|
||||
if n_valid * 2 > n_voters:
|
||||
return VALID
|
||||
return UNDECIDED
|
||||
|
||||
@classmethod
|
||||
def verify_vote_signature(cls, vote):
|
||||
"""
|
||||
Verify the signature of a vote
|
||||
"""
|
||||
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(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 as e:
|
||||
return False
|
203
tests/test_voting.py
Normal file
203
tests/test_voting.py
Normal file
@ -0,0 +1,203 @@
|
||||
import pytest
|
||||
from collections import Counter
|
||||
|
||||
from bigchaindb.core import Bigchain
|
||||
from bigchaindb.voting import Voting, INVALID, VALID, UNDECIDED
|
||||
|
||||
|
||||
################################################################################
|
||||
# Tests for checking vote eligibility
|
||||
|
||||
|
||||
def test_partition_eligible_votes():
|
||||
class TestVoting(Voting):
|
||||
@classmethod
|
||||
def verify_vote_signature(cls, vote):
|
||||
if vote['node_pubkey'] == 'invalid sig':
|
||||
return False
|
||||
if vote['node_pubkey'] == 'value error':
|
||||
raise ValueError()
|
||||
return True
|
||||
|
||||
voters = ['valid', 'invalid sig', 'value error', 'not in set']
|
||||
votes = [{'node_pubkey': k} for k in voters]
|
||||
|
||||
el, inel = TestVoting.partition_eligible_votes(votes, voters[:-1])
|
||||
assert el == [votes[0]]
|
||||
assert inel == votes[1:]
|
||||
|
||||
|
||||
################################################################################
|
||||
# Test vote counting
|
||||
|
||||
|
||||
def test_count_votes():
|
||||
class TestVoting(Voting):
|
||||
@classmethod
|
||||
def verify_vote_schema(cls, vote):
|
||||
return vote['node_pubkey'] != 'malformed'
|
||||
|
||||
voters = (['cheat', 'cheat', 'says invalid', 'malformed'] +
|
||||
['kosher' + str(i) for i in range(10)])
|
||||
|
||||
votes = [Bigchain(v).vote('block', 'a', True) for v in voters]
|
||||
votes[2]['vote']['is_block_valid'] = False
|
||||
# Incorrect previous block subtracts from n_valid and adds to n_invalid
|
||||
votes[-1]['vote']['previous_block'] = 'z'
|
||||
|
||||
assert TestVoting.count_votes(votes) == {
|
||||
'counts': {
|
||||
'n_valid': 9, # 9 kosher votes
|
||||
'n_invalid': 4, # 1 cheat, 1 invalid, 1 malformed, 1 rogue prev block
|
||||
# One of the cheat votes counts towards n_invalid, the other is
|
||||
# not counted here.
|
||||
# len(cheat) + n_valid + n_invalid == len(votes)
|
||||
},
|
||||
'cheat': [votes[:2]],
|
||||
'malformed': [votes[3]],
|
||||
'previous_block': 'a',
|
||||
'other_previous_block': {'z': 1},
|
||||
}
|
||||
|
||||
|
||||
def test_must_agree_prev_block():
|
||||
class TestVoting(Voting):
|
||||
@classmethod
|
||||
def verify_vote_schema(cls, vote):
|
||||
return True
|
||||
|
||||
voters = 'abcd'
|
||||
votes = [Bigchain(v).vote('block', 'a', True) for v in voters]
|
||||
votes[0]['vote']['previous_block'] = 'b'
|
||||
votes[1]['vote']['previous_block'] = 'c'
|
||||
assert TestVoting.count_votes(votes) == {
|
||||
'counts': {
|
||||
'n_valid': 2,
|
||||
'n_invalid': 2,
|
||||
},
|
||||
'previous_block': 'a',
|
||||
'other_previous_block': {'b': 1, 'c': 1},
|
||||
'malformed': [],
|
||||
'cheat': [],
|
||||
}
|
||||
|
||||
|
||||
################################################################################
|
||||
# Tests for vote decision making
|
||||
|
||||
|
||||
DECISION_TESTS = [
|
||||
{'n_voters': 1, 'n_valid': 1, 'n_invalid': 1},
|
||||
{'n_voters': 2, 'n_valid': 2, 'n_invalid': 1},
|
||||
{'n_voters': 3, 'n_valid': 2, 'n_invalid': 2},
|
||||
{'n_voters': 4, 'n_valid': 3, 'n_invalid': 2},
|
||||
{'n_voters': 5, 'n_valid': 3, 'n_invalid': 3},
|
||||
{'n_voters': 6, 'n_valid': 4, 'n_invalid': 3},
|
||||
{'n_voters': 7, 'n_valid': 4, 'n_invalid': 4},
|
||||
{'n_voters': 8, 'n_valid': 5, 'n_invalid': 4}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kwargs', DECISION_TESTS)
|
||||
def test_decide_votes_valid(kwargs):
|
||||
kwargs = kwargs.copy()
|
||||
kwargs['n_invalid'] = 0
|
||||
assert Voting.decide_votes(**kwargs) == VALID
|
||||
kwargs['n_valid'] -= 1
|
||||
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 Voting.decide_votes(**kwargs) == INVALID
|
||||
kwargs['n_invalid'] -= 1
|
||||
assert Voting.decide_votes(**kwargs) == UNDECIDED
|
||||
|
||||
|
||||
################################################################################
|
||||
# Actions - test state transitions
|
||||
|
||||
|
||||
@pytest.mark.parametrize('n_voters', range(8))
|
||||
def test_vote_actions(n_voters):
|
||||
"""
|
||||
* Legal transitions are UNDECIDED -> [VALID|INVALID] only
|
||||
* Block is never left UNDECIDED after voting
|
||||
* Accomodates rogues on previous block / invalid schema
|
||||
"""
|
||||
class TestVoting(Voting):
|
||||
@classmethod
|
||||
def verify_vote_schema(cls, vote):
|
||||
return type(vote['vote']['is_block_valid']) == bool
|
||||
|
||||
@classmethod
|
||||
def verify_vote_signature(cls, vote):
|
||||
return True
|
||||
|
||||
keyring = 'abcdefghijklmnopqrstuvwxyz'[:n_voters]
|
||||
block = {'id': 'block', 'block': {'voters': keyring}}
|
||||
state = UNDECIDED
|
||||
todo = [(state, [], [])]
|
||||
|
||||
def branch(p, r):
|
||||
todo.append((state, votes, votes + [{
|
||||
'node_pubkey': keyring[len(votes)],
|
||||
'vote': {'previous_block': p, 'is_block_valid': r}
|
||||
}]))
|
||||
|
||||
while todo:
|
||||
prev_state, prev_votes, votes = todo.pop(0)
|
||||
results = Counter(v['vote']['is_block_valid'] for v in votes)
|
||||
prev_blocks = Counter(v['vote']['previous_block'] for v in votes)
|
||||
majority = n_voters // 2 + 1
|
||||
honest = (len(votes) == majority and len(prev_blocks) == 1 and
|
||||
not results['lol'] and len(results) == 1)
|
||||
closed = len(votes) == n_voters
|
||||
|
||||
# Test legal transition
|
||||
if votes:
|
||||
state = TestVoting.block_election(block, votes, keyring)['status']
|
||||
assert prev_state in [state, UNDECIDED]
|
||||
|
||||
# Test that decision has been reached
|
||||
if honest or closed:
|
||||
assert state != UNDECIDED or n_voters == 0
|
||||
|
||||
if closed:
|
||||
continue
|
||||
|
||||
# Can accomodate more votes, add them to the todo list.
|
||||
# This vote is the good case
|
||||
branch('A', True)
|
||||
# This vote disagrees on previous block
|
||||
branch('B', True)
|
||||
# This vote says the block is invalid
|
||||
branch('A', False)
|
||||
# This vote is invalid
|
||||
branch('A', 'lol')
|
||||
|
||||
|
||||
################################################################################
|
||||
# Tests for vote signature
|
||||
|
||||
|
||||
def test_verify_vote_signature_passes(b):
|
||||
vote = b.vote('block', 'a', True)
|
||||
assert Voting.verify_vote_signature(vote)
|
||||
vote['signature'] = ''
|
||||
assert not Voting.verify_vote_signature(vote)
|
||||
|
||||
|
||||
################################################################################
|
||||
# Tests for vote schema
|
||||
|
||||
|
||||
def test_verify_vote_schema(b):
|
||||
vote = b.vote('b' * 64, 'a' * 64, True)
|
||||
assert Voting.verify_vote_schema(vote)
|
||||
vote = b.vote('b' * 64, 'a', True)
|
||||
assert not Voting.verify_vote_schema(vote)
|
||||
vote = b.vote('b', 'a' * 64, True)
|
||||
assert not Voting.verify_vote_schema(vote)
|
Loading…
x
Reference in New Issue
Block a user