diff --git a/bigchaindb/__init__.py b/bigchaindb/__init__.py index c0e4fd56..be1826d5 100644 --- a/bigchaindb/__init__.py +++ b/bigchaindb/__init__.py @@ -61,6 +61,7 @@ config = { # 'bichaindb.core': 'info', # }, }, + 'consensus_plugin': None } # We need to maintain a backup copy of the original config dict in case diff --git a/bigchaindb/common/asset/__init__.py b/bigchaindb/common/asset/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bigchaindb/common/asset/consensus.py b/bigchaindb/common/asset/consensus.py new file mode 100644 index 00000000..8b973ddb --- /dev/null +++ b/bigchaindb/common/asset/consensus.py @@ -0,0 +1,104 @@ +from bigchaindb.common.exceptions import ValidationError +from bigchaindb.consensus import BaseConsensusRules +from bigchaindb.models import Transaction + +ASSET_TYPE_MIX = 'mix' +ASSET_TYPE_PURE = 'pure' +ASSET_TYPE_COMPOSITION = 'composition' + +ASSET_TYPES = [ + ASSET_TYPE_MIX, + ASSET_TYPE_PURE, + ASSET_TYPE_COMPOSITION +] + + +class AssetCompositionConsensusRules(BaseConsensusRules): + + @staticmethod + def validate_transaction(bigchain, transaction): + + input_txs = None + if transaction.operation == Transaction.TRANSFER: + input_txs = transaction.get_input_txs(bigchain) + + AssetCompositionConsensusRules\ + .validate_asset(bigchain, transaction, input_txs) + + return transaction.validate(bigchain, input_txs) + + @staticmethod + def validate_asset(bigchain, transaction, input_txs): + assets = AssetCompositionConsensusRules\ + .resolve_asset(bigchain, transaction, input_txs) + + asset_types = set([asset['data']['type'] + for asset in assets + if 'data' in asset + and 'type' in asset['data'] + and asset['data']['type'] in ASSET_TYPES]) + + asset_type = ASSET_TYPE_PURE + if len(asset_types) == 1: + asset_type = asset_types.pop() + if len(asset_types) > 1: + raise ValidationError('Cannot mix assets') + + if asset_type == ASSET_TYPE_PURE: + return AssetCompositionConsensusRules\ + .validate_pure(bigchain, transaction, input_txs) + elif asset_type == ASSET_TYPE_MIX: + return AssetCompositionConsensusRules\ + .validate_mix(bigchain, transaction, input_txs) + elif asset_type == ASSET_TYPE_COMPOSITION: + return AssetCompositionConsensusRules\ + .validate_composition(bigchain, transaction, input_txs) + + @staticmethod + def validate_pure(bigchain, transaction, input_txs): + transaction.validate_asset( + bigchain, + [input_tx + for (input_, input_tx, status) + in input_txs if input_tx is not None]) + + if transaction.operation == Transaction.TRANSFER: + AssetCompositionConsensusRules\ + .validate_amount_conservation(transaction, input_txs) + + return transaction + + @staticmethod + def validate_mix(bigchain, transaction, input_txs): + if transaction.operation == Transaction.TRANSFER: + pass + return transaction + + @staticmethod + def validate_composition(bigchain, transaction, input_txs): + if transaction.operation == Transaction.TRANSFER: + AssetCompositionConsensusRules \ + .validate_amount_conservation(transaction, input_txs) + + return transaction + + @staticmethod + def validate_amount_conservation(transaction, input_txs): + transaction.validate_amount( + [input_tx.outputs[input_.fulfills.output] + for (input_, input_tx, status) + in input_txs if input_tx is not None]) + + @staticmethod + def resolve_asset(bigchain, transaction, input_txs): + if not hasattr(transaction, 'asset'): + raise ValidationError('Asset not found in transaction {}'.format(transaction)) + + if transaction.operation == Transaction.CREATE: + return [transaction.asset] + elif transaction.operation == Transaction.TRANSFER: + asset_ids = transaction.get_asset_ids( + [input_tx + for (input_, input_tx, status) + in input_txs if input_tx is not None]) + return [bigchain.get_transaction(asset_id).asset for asset_id in asset_ids] diff --git a/bigchaindb/common/transaction.py b/bigchaindb/common/transaction.py index e956812f..39faf74a 100644 --- a/bigchaindb/common/transaction.py +++ b/bigchaindb/common/transaction.py @@ -974,6 +974,21 @@ class Transaction(object): tx = Transaction._remove_signatures(self.to_dict()) return Transaction._to_str(tx) + @staticmethod + def get_asset_ids(transactions): + if not isinstance(transactions, list): + transactions = [transactions] + + if not len(transactions): + return [] + + # create a set of the transactions' asset ids + asset_ids = {tx.id if tx.operation == Transaction.CREATE + else tx.asset['id'] + for tx in transactions} + + return asset_ids + @staticmethod def get_asset_id(transactions): """Get the asset id from a list of :class:`~.Transactions`. @@ -994,14 +1009,10 @@ class Transaction(object): :exc:`AssetIdMismatch`: If the inputs are related to different assets. """ + asset_ids = Transaction.get_asset_ids(transactions) - if not isinstance(transactions, list): - transactions = [transactions] - - # create a set of the transactions' asset ids - asset_ids = {tx.id if tx.operation == Transaction.CREATE - else tx.asset['id'] - for tx in transactions} + if len(asset_ids) == 0: + return None # check that all the transasctions have the same asset id if len(asset_ids) > 1: diff --git a/bigchaindb/config_utils.py b/bigchaindb/config_utils.py index 5a72a7d6..9ae5f395 100644 --- a/bigchaindb/config_utils.py +++ b/bigchaindb/config_utils.py @@ -16,6 +16,7 @@ import copy import json import logging import collections +import importlib.util from functools import lru_cache from pkg_resources import iter_entry_points, ResolutionError @@ -249,6 +250,16 @@ def autoconfigure(filename=None, config=None, force=False): set_config(newconfig) # sets bigchaindb.config +def load_from_path(path): + module_path, object_name = path.split(':') + module_name = os.path.basename(module_path).split('.').pop(0) + + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return getattr(module, object_name) + + @lru_cache() def load_consensus_plugin(name=None): """Find and load the chosen consensus plugin. @@ -271,6 +282,12 @@ def load_consensus_plugin(name=None): for entry_point in iter_entry_points('bigchaindb.consensus', name): plugin = entry_point.load() + if not plugin and name: + try: + plugin = load_from_path(name) + except: + raise ResolutionError('Invalid consensus plugin {}'.format(name)) + # No matching entry_point found if not plugin: raise ResolutionError( diff --git a/bigchaindb/consensus.py b/bigchaindb/consensus.py index a0672577..00e0e9a4 100644 --- a/bigchaindb/consensus.py +++ b/bigchaindb/consensus.py @@ -1,3 +1,4 @@ +from bigchaindb.models import Transaction from bigchaindb.voting import Voting @@ -15,9 +16,28 @@ class BaseConsensusRules(): def validate_transaction(bigchain, transaction): """See :meth:`bigchaindb.models.Transaction.validate` for documentation.""" - return transaction.validate(bigchain) + input_txs = None + + result = transaction.validate(bigchain, input_txs) + + if transaction.operation == Transaction.TRANSFER: + input_txs = transaction.get_input_txs(bigchain) + + transaction.validate_asset( + bigchain, + [input_tx + for (input_, input_tx, status) + in input_txs if input_tx is not None]) + + transaction.validate_amount( + [input_tx.outputs[input_.fulfills.output] + for (input_, input_tx, status) + in input_txs if input_tx is not None]) + + return result @staticmethod def validate_block(bigchain, block): """See :meth:`bigchaindb.models.Block.validate` for documentation.""" return block.validate(bigchain) + diff --git a/bigchaindb/core.py b/bigchaindb/core.py index a9143f33..ce6b3dda 100644 --- a/bigchaindb/core.py +++ b/bigchaindb/core.py @@ -28,7 +28,7 @@ class Bigchain(object): # return if transaction is in backlog TX_IN_BACKLOG = 'backlog' - def __init__(self, public_key=None, private_key=None, keyring=[], connection=None, backlog_reassign_delay=None): + def __init__(self, public_key=None, private_key=None, keyring=[], connection=None, backlog_reassign_delay=None, consensusPlugin=None): """Initialize the Bigchain instance A Bigchain instance has several configuration parameters (e.g. host). @@ -58,7 +58,8 @@ class Bigchain(object): backlog_reassign_delay = bigchaindb.config['backlog_reassign_delay'] self.backlog_reassign_delay = backlog_reassign_delay - consensusPlugin = bigchaindb.config.get('consensus_plugin') + if consensusPlugin is None: + consensusPlugin = bigchaindb.config.get('consensus_plugin') if consensusPlugin: self.consensus = config_utils.load_consensus_plugin(consensusPlugin) diff --git a/bigchaindb/models.py b/bigchaindb/models.py index c371e792..b50fae67 100644 --- a/bigchaindb/models.py +++ b/bigchaindb/models.py @@ -11,7 +11,7 @@ from bigchaindb.common.schema import validate_transaction_schema class Transaction(Transaction): - def validate(self, bigchain): + def validate(self, bigchain, input_txs=None): """Validate transaction spend Args: @@ -29,17 +29,17 @@ class Transaction(Transaction): if self.operation == Transaction.TRANSFER: # store the inputs so that we can check if the asset ids match - input_txs = [] - for input_ in self.inputs: + if not input_txs: + input_txs = self.get_input_txs(bigchain) + + for input_, input_tx, input_status in input_txs: input_txid = input_.fulfills.txid - input_tx, status = bigchain.\ - get_transaction(input_txid, include_status=True) if input_tx is None: raise InputDoesNotExist("input `{}` doesn't exist" .format(input_txid)) - if status != bigchain.TX_VALID: + if input_status != bigchain.TX_VALID: raise TransactionNotInValidBlock( 'input `{}` does not exist in a valid block'.format( input_txid)) @@ -51,34 +51,46 @@ class Transaction(Transaction): output = input_tx.outputs[input_.fulfills.output] input_conditions.append(output) - input_txs.append(input_tx) # Validate that all inputs are distinct links = [i.fulfills.to_uri() for i in self.inputs] if len(links) != len(set(links)): raise DoubleSpend('tx "{}" spends inputs twice'.format(self.id)) - # validate asset id - asset_id = Transaction.get_asset_id(input_txs) - if asset_id != self.asset['id']: - raise AssetIdMismatch(('The asset id of the input does not' - ' match the asset id of the' - ' transaction')) - - input_amount = sum([input_condition.amount for input_condition in input_conditions]) - output_amount = sum([output_condition.amount for output_condition in self.outputs]) - - if output_amount != input_amount: - raise AmountError(('The amount used in the inputs `{}`' - ' needs to be same as the amount used' - ' in the outputs `{}`') - .format(input_amount, output_amount)) - if not self.inputs_valid(input_conditions): raise InvalidSignature('Transaction signature is invalid.') return self + def get_input_txs(self, bigchain): + input_txs = [] + for input_ in self.inputs: + input_txid = input_.fulfills.txid + input_tx, status = bigchain. \ + get_transaction(input_txid, include_status=True) + input_txs.append((input_, input_tx, status)) + return input_txs + + def validate_asset(self, bigchain, input_txs=None): + # validate asset id + asset_id = Transaction.get_asset_id(input_txs) + if asset_id != self.asset['id']: + raise AssetIdMismatch(('The asset id of the input does not' + ' match the asset id of the' + ' transaction')) + return self + + def validate_amount(self, input_conditions): + input_amount = sum([input_condition.amount for input_condition in input_conditions]) + output_amount = sum([output_condition.amount for output_condition in self.outputs]) + + if output_amount != input_amount: + raise AmountError(('The amount used in the inputs `{}`' + ' needs to be same as the amount used' + ' in the outputs `{}`') + .format(input_amount, output_amount)) + return self + @classmethod def from_dict(cls, tx_body): validate_transaction_schema(tx_body) diff --git a/tests/assets/conftest.py b/tests/assets/conftest.py new file mode 100644 index 00000000..1c6d9853 --- /dev/null +++ b/tests/assets/conftest.py @@ -0,0 +1,22 @@ +import pytest + + +@pytest.fixture +def app(request): + from bigchaindb.web import server + app = server.create_app(debug=True) + return app + + +@pytest.fixture +def plugin(): + import os + plugin_path = 'bigchaindb/common/asset/consensus.py:AssetCompositionConsensusRules' + return '{}/{}'.format(os.getcwd().split('/tests')[0], plugin_path) + + +@pytest.fixture +def b_consensus(plugin): + from bigchaindb import Bigchain + return Bigchain(consensusPlugin=plugin) + diff --git a/tests/assets/test_asset_types.py b/tests/assets/test_asset_types.py new file mode 100644 index 00000000..172506c1 --- /dev/null +++ b/tests/assets/test_asset_types.py @@ -0,0 +1,104 @@ +import json + +from bigchaindb.common import crypto +from bigchaindb.common.exceptions import ValidationError +import pytest +import random + +TX_ENDPOINT = '/api/v1/transactions/' + + +def post_tx(b, client, tx): + class Response(): + status_code = None + + response = Response() + try: + b.validate_transaction(tx) + response.status_code = 202 + except ValidationError: + response.status_code = 400 + + if response.status_code == 202: + mine(b, [tx]) + return response + + +def mine(b, tx_list): + block = b.create_block(tx_list) + b.write_block(block) + + # vote the block valid + vote = b.vote(block.id, b.get_last_voted_block().id, True) + b.write_vote(vote) + + return block, vote + + +def create_simple_tx(user_pub, user_priv, asset=None, metadata=None): + from bigchaindb.models import Transaction + create_tx = Transaction.create([user_pub], [([user_pub], 1)], asset=asset, metadata=metadata) + create_tx = create_tx.sign([user_priv]) + return create_tx + + +def transfer_simple_tx(user_pub, user_priv, input_tx, metadata=None): + from bigchaindb.models import Transaction + + asset_id = input_tx.id if input_tx.operation == 'CREATE' else input_tx.asset['id'] + + transfer_tx = Transaction.transfer(input_tx.to_inputs(), + [([user_pub], 1)], + asset_id=asset_id, + metadata=metadata) + transfer_tx = transfer_tx.sign([user_priv]) + + return transfer_tx + + +@pytest.mark.bdb +@pytest.mark.usefixtures('inputs') +def test_asset_type_mix(b_consensus, client): + from bigchaindb.models import Transaction + + alice_priv, alice_pub = crypto.generate_key_pair() + + create_a = create_simple_tx( + alice_pub, alice_priv, + asset={ + 'type': 'mix', + 'data': { + 'material': 'secret sauce' + } + }) + response = post_tx(b_consensus, client, create_a) + assert response.status_code == 202 + + transfer_a = transfer_simple_tx(alice_pub, alice_priv, create_a) + response = post_tx(b_consensus, client, transfer_a) + assert response.status_code == 202 + + bob_priv, bob_pub = crypto.generate_key_pair() + tx_b = create_simple_tx( + bob_pub, + bob_priv, + asset={ + 'type': 'mix', + 'data': { + 'material': 'bulk' + } + }) + response = post_tx(b_consensus, client, tx_b) + assert response.status_code == 202 + + carly_priv, carly_pub = crypto.generate_key_pair() + + tx_mix = Transaction.transfer( + transfer_a.to_inputs() + tx_b.to_inputs(), + [([carly_pub], 1)], + transfer_a.id + ) + + tx_mix_signed = tx_mix.sign([alice_priv, bob_priv]) + response = post_tx(b_consensus, client, tx_mix_signed) + assert response.status_code == 202 diff --git a/tests/assets/test_digital_assets.py b/tests/assets/test_digital_assets.py index c31ec3da..7630ec6f 100644 --- a/tests/assets/test_digital_assets.py +++ b/tests/assets/test_digital_assets.py @@ -44,7 +44,7 @@ def test_validate_transfer_asset_id_mismatch(b, user_pk, user_sk): tx_transfer.asset['id'] = 'aaa' tx_transfer_signed = tx_transfer.sign([user_sk]) with pytest.raises(AssetIdMismatch): - tx_transfer_signed.validate(b) + b.validate_transaction(tx_transfer_signed) def test_get_asset_id_create_transaction(b, user_pk): diff --git a/tests/assets/test_divisible_assets.py b/tests/assets/test_divisible_assets.py index 87a29c2b..61c8d63f 100644 --- a/tests/assets/test_divisible_assets.py +++ b/tests/assets/test_divisible_assets.py @@ -538,7 +538,7 @@ def test_amount_error_transfer(b, user_pk, user_sk): asset_id=tx_create.id) tx_transfer_signed = tx_transfer.sign([user_sk]) with pytest.raises(AmountError): - tx_transfer_signed.validate(b) + b.validate_transaction(tx_transfer_signed) # TRANSFER # output amount greater than input amount @@ -546,7 +546,7 @@ def test_amount_error_transfer(b, user_pk, user_sk): asset_id=tx_create.id) tx_transfer_signed = tx_transfer.sign([user_sk]) with pytest.raises(AmountError): - tx_transfer_signed.validate(b) + b.validate_transaction(tx_transfer_signed) @pytest.mark.skip(reason='Figure out how to handle this case') diff --git a/tests/test_config_utils.py b/tests/test_config_utils.py index 4234e242..4d6244d9 100644 --- a/tests/test_config_utils.py +++ b/tests/test_config_utils.py @@ -204,6 +204,7 @@ def test_autoconfigure_read_both_from_file_and_env(monkeypatch, request): 'keyring': KEYRING.split(':'), 'backlog_reassign_delay': 5, 'log': {}, + 'consensus_plugin': None }