Problem: (BEP-21) no way to propose validator election

Solution: Integrate the new election spec specifed in BEP-21
This commit is contained in:
Vanshdeep Singh 2018-07-18 15:20:32 +02:00
parent 9cfc1e6f5a
commit a8e5da31aa
16 changed files with 427 additions and 71 deletions

View File

@ -30,3 +30,17 @@ def generate_key_pair():
PrivateKey = crypto.Ed25519SigningKey
PublicKey = crypto.Ed25519VerifyingKey
def key_pair_from_ed25519_key(hex_private_key):
"""Generate base58 encode public-private key pair from a hex encoded private key"""
priv_key = crypto.Ed25519SigningKey(bytes.fromhex(hex_private_key)[:32], encoding='bytes')
public_key = priv_key.get_verifying_key()
return CryptoKeypair(private_key=priv_key.encode(encoding='base58').decode('utf-8'),
public_key=public_key.encode(encoding='base58').decode('utf-8'))
def public_key_from_ed25519_key(hex_public_key):
"""Generate base58 public key from hex encoded public key"""
public_key = crypto.Ed25519VerifyingKey(bytes.fromhex(hex_public_key), encoding='bytes')
return public_key.encode(encoding='base58').decode('utf-8')

View File

@ -13,9 +13,9 @@ from bigchaindb.common.exceptions import SchemaValidationError
logger = logging.getLogger(__name__)
def _load_schema(name):
def _load_schema(name, path=__file__):
"""Load a schema from disk"""
path = os.path.join(os.path.dirname(__file__), name + '.yaml')
path = os.path.join(os.path.dirname(path), name + '.yaml')
with open(path) as handle:
schema = yaml.safe_load(handle)
fast_schema = rapidjson_schema.loads(rapidjson.dumps(schema))

View File

@ -58,6 +58,7 @@ definitions:
enum:
- CREATE
- TRANSFER
- VALIDATOR_ELECTION
asset:
type: object
additionalProperties: false

View File

@ -515,7 +515,7 @@ class Transaction(object):
version (string): Defines the version number of a Transaction.
hash_id (string): Hash id of the transaction.
"""
if operation not in Transaction.ALLOWED_OPERATIONS:
if operation not in self.ALLOWED_OPERATIONS:
allowed_ops = ', '.join(self.__class__.ALLOWED_OPERATIONS)
raise ValueError('`operation` must be one of {}'
.format(allowed_ops))
@ -939,7 +939,7 @@ class Transaction(object):
Returns:
bool: If all Inputs are valid.
"""
if self.operation == Transaction.CREATE:
if self.operation == self.CREATE:
# NOTE: Since in the case of a `CREATE`-transaction we do not have
# to check for outputs, we're just submitting dummy
# values to the actual method. This simplifies it's logic
@ -986,8 +986,7 @@ class Transaction(object):
return all(validate(i, cond)
for i, cond in enumerate(output_condition_uris))
@staticmethod
def _input_valid(input_, operation, message, output_condition_uri=None):
def _input_valid(self, input_, operation, message, output_condition_uri=None):
"""Validates a single Input against a single Output.
Note:
@ -1012,7 +1011,7 @@ class Transaction(object):
ParsingError, ASN1DecodeError, ASN1EncodeError):
return False
if operation == Transaction.CREATE:
if operation == self.CREATE:
# NOTE: In the case of a `CREATE` transaction, the
# output is always valid.
output_valid = True
@ -1164,3 +1163,53 @@ class Transaction(object):
outputs = [Output.from_dict(output) for output in tx['outputs']]
return cls(tx['operation'], tx['asset'], inputs, outputs,
tx['metadata'], tx['version'], hash_id=tx['id'])
@classmethod
def from_db(cls, bigchain, tx_dict_list):
"""Helper method that reconstructs a transaction dict that was returned
from the database. It checks what asset_id to retrieve, retrieves the
asset from the asset table and reconstructs the transaction.
Args:
bigchain (:class:`~bigchaindb.tendermint.BigchainDB`): An instance
of BigchainDB used to perform database queries.
tx_dict_list (:list:`dict` or :obj:`dict`): The transaction dict or
list of transaction dict as returned from the database.
Returns:
:class:`~Transaction`
"""
return_list = True
if isinstance(tx_dict_list, dict):
tx_dict_list = [tx_dict_list]
return_list = False
tx_map = {}
tx_ids = []
for tx in tx_dict_list:
tx.update({'metadata': None})
tx_map[tx['id']] = tx
tx_ids.append(tx['id'])
assets = list(bigchain.get_assets(tx_ids))
for asset in assets:
if asset is not None:
tx = tx_map[asset['id']]
del asset['id']
tx['asset'] = asset
tx_ids = list(tx_map.keys())
metadata_list = list(bigchain.get_metadata(tx_ids))
for metadata in metadata_list:
tx = tx_map[metadata['id']]
tx.update({'metadata': metadata.get('metadata')})
if return_list:
tx_list = []
for tx_id, tx in tx_map.items():
tx_list.append(cls.from_dict(tx))
return tx_list
else:
tx = list(tx_map.values())[0]
return cls.from_dict(tx)

View File

@ -101,56 +101,6 @@ class Transaction(Transaction):
validate_language_key(tx_body['asset'], 'data')
return super().from_dict(tx_body)
@classmethod
def from_db(cls, bigchain, tx_dict_list):
"""Helper method that reconstructs a transaction dict that was returned
from the database. It checks what asset_id to retrieve, retrieves the
asset from the asset table and reconstructs the transaction.
Args:
bigchain (:class:`~bigchaindb.tendermint.BigchainDB`): An instance
of BigchainDB used to perform database queries.
tx_dict_list (:list:`dict` or :obj:`dict`): The transaction dict or
list of transaction dict as returned from the database.
Returns:
:class:`~Transaction`
"""
return_list = True
if isinstance(tx_dict_list, dict):
tx_dict_list = [tx_dict_list]
return_list = False
tx_map = {}
tx_ids = []
for tx in tx_dict_list:
tx.update({'metadata': None})
tx_map[tx['id']] = tx
if tx['operation'] == Transaction.CREATE:
tx_ids.append(tx['id'])
assets = list(bigchain.get_assets(tx_ids))
for asset in assets:
tx = tx_map[asset['id']]
del asset['id']
tx.update({'asset': asset})
tx_ids = list(tx_map.keys())
metadata_list = list(bigchain.get_metadata(tx_ids))
for metadata in metadata_list:
tx = tx_map[metadata['id']]
tx.update({'metadata': metadata.get('metadata')})
if return_list:
tx_list = []
for tx_id, tx in tx_map.items():
tx_list.append(cls.from_dict(tx))
return tx_list
else:
tx = list(tx_map.values())[0]
return cls.from_dict(tx)
class FastTransaction:
"""A minimal wrapper around a transaction dictionary. This is useful for

View File

@ -18,6 +18,7 @@ import requests
import bigchaindb
from bigchaindb import backend, config_utils
from bigchaindb.models import Transaction
from bigchaindb.validation_plugins import operation_class
from bigchaindb.common.exceptions import (SchemaValidationError,
ValidationError,
DoubleSpend)
@ -150,10 +151,10 @@ class BigchainDB(object):
txns = []
assets = []
txn_metadatas = []
for transaction in transactions:
for transaction_obj in transactions:
# self.update_utxoset(transaction)
transaction = transaction.to_dict()
if transaction['operation'] == 'CREATE':
transaction = transaction_obj.to_dict()
if transaction['operation'] == transaction_obj.CREATE:
asset = transaction.pop('asset')
asset['id'] = transaction['id']
assets.append(asset)
@ -252,7 +253,7 @@ class BigchainDB(object):
return backend.query.delete_unspent_outputs(
self.connection, *unspent_outputs)
def get_transaction(self, transaction_id, include_status=False):
def get_transaction(self, transaction_id, include_status=False, cls=Transaction):
transaction = backend.query.get_transaction(self.connection, transaction_id)
asset = backend.query.get_asset(self.connection, transaction_id)
metadata = backend.query.get_metadata(self.connection, [transaction_id])
@ -268,7 +269,7 @@ class BigchainDB(object):
transaction.update({'metadata': metadata})
transaction = Transaction.from_dict(transaction)
transaction = cls.from_dict(transaction)
if include_status:
return transaction, self.TX_VALID if transaction else None
@ -326,7 +327,7 @@ class BigchainDB(object):
if len(transactions) + len(current_spent_transactions) > 1:
raise DoubleSpend('tx "{}" spends inputs twice'.format(txid))
elif transactions:
transaction = Transaction.from_db(self, transactions[0])
transaction = operation_class(transactions[0]).from_db(self, transactions[0])
elif current_spent_transactions:
transaction = current_spent_transactions[0]
@ -364,7 +365,8 @@ class BigchainDB(object):
if block:
transactions = backend.query.get_transactions(self.connection, block['transactions'])
result['transactions'] = [t.to_dict() for t in Transaction.from_db(self, transactions)]
cls = operation_class(transactions[0])
result['transactions'] = [t.to_dict() for t in cls.from_db(self, transactions)]
return result
@ -392,9 +394,9 @@ class BigchainDB(object):
# CLEANUP: The conditional below checks for transaction in dict format.
# It would be better to only have a single format for the transaction
# throught the code base.
if not isinstance(transaction, Transaction):
if isinstance(transaction, dict):
try:
transaction = Transaction.from_dict(tx)
transaction = operation_class(tx).from_dict(tx)
except SchemaValidationError as e:
logger.warning('Invalid transaction schema: %s', e.__cause__.message)
return False

View File

@ -75,12 +75,20 @@ def public_key64_to_address(base64_public_key):
def public_key_from_base64(base64_public_key):
return base64.b64decode(base64_public_key).hex().upper()
return key_from_base64(base64_public_key)
def key_from_base64(base64_key):
return base64.b64decode(base64_key).hex().upper()
def public_key_to_base64(ed25519_public_key):
ed25519_public_key = bytes.fromhex(ed25519_public_key)
return base64.b64encode(ed25519_public_key).decode('utf-8')
return key_to_base64(ed25519_public_key)
def key_to_base64(ed25519_key):
ed25519_key = bytes.fromhex(ed25519_key)
return base64.b64encode(ed25519_key).decode('utf-8')
def amino_encoded_public_key(ed25519_public_key):

View File

@ -0,0 +1,2 @@
from bigchaindb.upsert_validator.validator_election import ValidatorElection # noqa

View File

@ -0,0 +1,48 @@
---
"$schema": "http://json-schema.org/draft-04/schema#"
type: object
title: Validator Election Schema - Propose a change to validator set
required:
- operation
- asset
- outputs
properties:
operation:
type: string
value: "VALIDATOR_ELECTION"
asset:
additionalProperties: false
properties:
data:
additionalProperties: false
properties:
node_id:
type: string
public_key:
type: string
power:
"$ref": "#/definitions/positiveInteger"
required:
- node_id
- public_key
- power
required:
- data
outputs:
type: array
items:
"$ref": "#/definitions/output"
definitions:
output:
type: object
properties:
condition:
type: object
required:
- uri
properties:
uri:
type: string
pattern: "^ni:///sha-256;([a-zA-Z0-9_-]{0,86})[?]\
(fpt=ed25519-sha-256(&)?|cost=[0-9]+(&)?|\
subtypes=ed25519-sha-256(&)?){2,3}$"

View File

@ -0,0 +1,140 @@
from bigchaindb.common.exceptions import (InvalidSignature,
DuplicateTransaction)
from bigchaindb.tendermint.utils import key_from_base64
from bigchaindb.common.crypto import (public_key_from_ed25519_key)
from bigchaindb.common.transaction import Transaction, Input, Output
from bigchaindb.common.schema import (_load_schema,
_validate_schema,
TX_SCHEMA_VERSION,
TX_SCHEMA_COMMON,
TX_SCHEMA_CREATE)
_, TX_SCHEMA_VALIDATOR_ELECTION = _load_schema('transaction_validator_election_' +
TX_SCHEMA_VERSION, __file__)
class ValidatorElection(Transaction):
VALIDATOR_ELECTION = 'VALIDATOR_ELECTION'
# NOTE: this transaction class extends create so the operation inheritence is achieved
# by renaming CREATE to VALIDATOR_ELECTION
CREATE = VALIDATOR_ELECTION
ALLOWED_OPERATIONS = (VALIDATOR_ELECTION,)
def __init__(self, operation, asset, inputs=None, outputs=None,
metadata=None, version=None, hash_id=None):
# operation `CREATE` is being passed as argument as `VALIDATOR_ELECTION` is an extension
# of `CREATE` and any validation on `CREATE` in the parent class should apply to it
super().__init__(operation, asset, inputs, outputs, metadata, version, hash_id)
self.operation = self.VALIDATOR_ELECTION
@classmethod
def current_validators(cls, bigchain):
"""Return a dictionary of validators with key as `public_key` and
value as the `voting_power`
"""
validators = {}
for validator in bigchain.get_validators():
# NOTE: we assume that Tendermint encodes public key in base64
public_key = public_key_from_ed25519_key(key_from_base64(validator['pub_key']['value']))
validators[public_key] = validator['voting_power']
return validators
@classmethod
def recipients(cls, bigchain):
"""Convert validator dictionary to a recipient list for `Transaction`"""
recipients = []
for public_key, voting_power in cls.current_validators(bigchain).items():
recipients.append(([public_key], voting_power))
return recipients
@classmethod
def is_same_topology(cls, current_topology, election_topology):
voters = {}
for voter in election_topology:
if len(voter.public_keys) > 1:
return False
[public_key] = voter.public_keys
voting_power = voter.amount
voters[public_key] = voting_power
# Check whether the voters and their votes is same to that of the
# validators and their voting power in the network
return (current_topology.items() == voters.items())
def validate(self, bigchain, current_transactions=[]):
"""Validate election transaction
NOTE:
* A valid election is initiated by an existing validator.
* A valid election is one where voters are validators and votes are
alloacted according to the voting power of each validator node.
Args:
bigchain (BigchainDB): an instantiated bigchaindb.tendermint.BigchainDB object.
Returns:
The transaction (Transaction) if the transaction is valid else it
raises an exception describing the reason why the transaction is
invalid.
Raises:
ValidationError: If the transaction is invalid
"""
input_conditions = []
duplicates = any(txn for txn in current_transactions if txn.id == self.id)
if bigchain.get_transaction(self.to_dict()['id'], cls=ValidatorElection) or duplicates:
raise DuplicateTransaction('transaction `{}` already exists'
.format(self.id))
if not self.inputs_valid(input_conditions):
raise InvalidSignature('Transaction signature is invalid.')
current_validators = self.current_validators(bigchain)
# NOTE: Proposer should be a single node
if len(self.inputs) != 1:
return False
# NOTE: Check if the proposer is a validator.
[election_initiator_node_pub_key] = self.inputs[0].owners_before
if election_initiator_node_pub_key not in current_validators.keys():
return False
# NOTE: Check if all validators have been assigned votes equal to their voting power
return self.is_same_topology(current_validators, self.outputs)
@classmethod
def generate(cls, initiator, voters, election_data, metadata=None):
election = cls.create(initiator, voters, metadata, asset=election_data)
cls.validate_schema(election.to_dict())
return election
@classmethod
def from_dict(cls, tx):
cls.validate_id(tx)
# The schema validation will ensure that the asset has been properly defined
cls.validate_schema(tx)
inputs = [Input.from_dict(input_) for input_ in tx['inputs']]
outputs = [Output.from_dict(output) for output in tx['outputs']]
return cls(cls.VALIDATOR_ELECTION, tx['asset'], inputs, outputs, tx['metadata'],
tx['version'], hash_id=tx['id'])
@classmethod
def validate_schema(cls, tx):
"""Validate the validator election transaction. Since `VALIDATOR_ELECTION` extends `CREATE`
transaction, all the validations for `CREATE` transaction should be inherited
"""
_validate_schema(TX_SCHEMA_COMMON, tx)
_validate_schema(TX_SCHEMA_CREATE, tx)
_validate_schema(TX_SCHEMA_VALIDATOR_ELECTION, tx)

View File

@ -0,0 +1,15 @@
from bigchaindb.models import Transaction
from bigchaindb.upsert_validator import ValidatorElection
OPERATION_TO_CLASS = {
Transaction.CREATE: Transaction,
Transaction.TRANSFER: Transaction,
ValidatorElection.VALIDATOR_ELECTION: ValidatorElection
}
def operation_class(tx):
"""For the given `tx` based on the `operation` key return its implementation class"""
return OPERATION_TO_CLASS.get(tx['operation'], Transaction)

View File

@ -8,9 +8,9 @@ from flask import current_app, request, jsonify
from flask_restful import Resource, reqparse
from bigchaindb.common.exceptions import SchemaValidationError, ValidationError
from bigchaindb.models import Transaction
from bigchaindb.web.views.base import make_error
from bigchaindb.web.views import parameters
from bigchaindb.validation_plugins import operation_class
logger = logging.getLogger(__name__)
@ -68,7 +68,7 @@ class TransactionListApi(Resource):
tx = request.get_json(force=True)
try:
tx_obj = Transaction.from_dict(tx)
tx_obj = operation_class(tx).from_dict(tx)
except SchemaValidationError as e:
return make_error(
400,

View File

@ -18,6 +18,10 @@ from pymongo import MongoClient
from bigchaindb.common import crypto
from bigchaindb.log import setup_logging
from bigchaindb.tendermint.lib import Block
from bigchaindb.tendermint.utils import key_from_base64
from bigchaindb.common.crypto import (key_pair_from_ed25519_key,
public_key_from_ed25519_key)
TEST_DB_NAME = 'bigchain_test'
@ -615,3 +619,41 @@ def utxoset(dummy_unspent_outputs, utxo_collection):
assert res.acknowledged
assert len(res.inserted_ids) == 3
return dummy_unspent_outputs, utxo_collection
@pytest.fixture
def network_validators(node_keys):
validator_pub_power = {}
voting_power = [8, 10, 7, 9]
for pub, priv in node_keys.items():
validator_pub_power[pub] = voting_power.pop()
return validator_pub_power
@pytest.fixture
def network_validators58(network_validators):
network_validators_base58 = {}
for p, v in network_validators.items():
p = public_key_from_ed25519_key(key_from_base64(p))
network_validators_base58[p] = v
return network_validators_base58
@pytest.fixture
def node_key(node_keys):
(pub, priv) = list(node_keys.items())[0]
return key_pair_from_ed25519_key(key_from_base64(priv))
@pytest.fixture(scope='session')
def node_keys():
return {'zL/DasvKulXZzhSNFwx4cLRXKkSM9GPK7Y0nZ4FEylM=':
'cM5oW4J0zmUSZ/+QRoRlincvgCwR0pEjFoY//ZnnjD3Mv8Nqy8q6VdnOFI0XDHhwtFcqRIz0Y8rtjSdngUTKUw==',
'GIijU7GBcVyiVUcB0GwWZbxCxdk2xV6pxdvL24s/AqM=':
'mdz7IjP6mGXs6+ebgGJkn7kTXByUeeGhV+9aVthLuEAYiKNTsYFxXKJVRwHQbBZlvELF2TbFXqnF28vbiz8Cow==',
'JbfwrLvCVIwOPm8tj8936ki7IYbmGHjPiKb6nAZegRA=':
'83VINXdj2ynOHuhvSZz5tGuOE5oYzIi0mEximkX1KYMlt/Csu8JUjA4+by2Pz3fqSLshhuYYeM+IpvqcBl6BEA==',
'PecJ58SaNRsWJZodDmqjpCWqG6btdwXFHLyE40RYlYM=':
'uz8bYgoL4rHErWT1gjjrnA+W7bgD/uDQWSRKDmC8otc95wnnxJo1GxYlmh0OaqOkJaobpu13BcUcvITjRFiVgw=='}

View File

View File

@ -0,0 +1,37 @@
import pytest
@pytest.fixture
def b_mock(b, network_validators):
b.write_transaction = mock_write_transaction
b.get_validators = mock_get_validators(network_validators)
return b
@pytest.fixture
def new_validator():
public_key = '1718D2DBFF00158A0852A17A01C78F4DCF3BA8E4FB7B8586807FAC182A535034'
power = 1
node_id = 'fake_node_id'
return {'public_key': public_key,
'power': power,
'node_id': node_id}
def mock_write_transaction(tx):
return (202, '')
def mock_get_validators(network_validators):
def validator_set():
validators = []
for public_key, power in network_validators.items():
validators.append({
'pub_key': {'type': 'AC26791624DE60', 'value': public_key},
'voting_power': power
})
return validators
return validator_set

View File

@ -0,0 +1,48 @@
import pytest
from bigchaindb.upsert_validator import ValidatorElection
from bigchaindb.common.exceptions import DuplicateTransaction
pytestmark = [pytest.mark.tendermint, pytest.mark.bdb]
def test_upsert_validator_valid_election(b_mock, new_validator, node_key):
voters = ValidatorElection.recipients(b_mock)
election = ValidatorElection.generate([node_key.public_key],
voters,
new_validator, None).sign([node_key.private_key])
assert election.validate(b_mock)
def test_upsert_validator_invalid_election(b_mock, new_validator, node_key):
voters = ValidatorElection.recipients(b_mock)
valid_election = ValidatorElection.generate([node_key.public_key],
voters,
new_validator, None).sign([node_key.private_key])
with pytest.raises(DuplicateTransaction):
valid_election.validate(b_mock, [valid_election])
b_mock.store_bulk_transactions([valid_election])
with pytest.raises(DuplicateTransaction):
valid_election.validate(b_mock, [valid_election])
# Try creating an election with incomplete voter set
invalid_election = ValidatorElection.generate([node_key.public_key],
voters[1:],
new_validator, None).sign([node_key.private_key])
assert not invalid_election.validate(b_mock)
recipients = ValidatorElection.recipients(b_mock)
altered_recipients = []
for r in recipients:
([r_public_key], voting_power) = r
altered_recipients.append(([r_public_key], voting_power - 1))
# Create a transaction which doesn't enfore the network power
tx_election = ValidatorElection.generate([node_key.public_key],
altered_recipients,
new_validator, None).sign([node_key.private_key])
assert not tx_election.validate(b_mock)