diff --git a/bigchaindb/backend/localmongodb/query.py b/bigchaindb/backend/localmongodb/query.py index 757a61b7..6587d494 100644 --- a/bigchaindb/backend/localmongodb/query.py +++ b/bigchaindb/backend/localmongodb/query.py @@ -322,3 +322,23 @@ def get_asset_tokens_for_public_key(conn, asset_id, public_key): {'$project': {'_id': False}} ])) return cursor + + +@register_query(LocalMongoDBConnection) +def store_abci_chain(conn, height, chain_id, is_synced=True): + return conn.run( + conn.collection('abci_chains').replace_one( + {'height': height}, + {'height': height, 'chain_id': chain_id, + 'is_synced': is_synced}, + upsert=True, + ) + ) + + +@register_query(LocalMongoDBConnection) +def get_latest_abci_chain(conn): + return conn.run( + conn.collection('abci_chains') + .find_one(projection={'_id': False}, sort=[('height', DESCENDING)]) + ) diff --git a/bigchaindb/backend/localmongodb/schema.py b/bigchaindb/backend/localmongodb/schema.py index 0607c883..e58f1def 100644 --- a/bigchaindb/backend/localmongodb/schema.py +++ b/bigchaindb/backend/localmongodb/schema.py @@ -47,6 +47,7 @@ def create_indexes(conn, dbname): create_utxos_secondary_index(conn, dbname) create_pre_commit_secondary_index(conn, dbname) create_validators_secondary_index(conn, dbname) + create_abci_chains_indexes(conn, dbname) @register_schema(LocalMongoDBConnection) @@ -133,3 +134,16 @@ def create_validators_secondary_index(conn, dbname): conn.conn[dbname]['validators'].create_index('height', name='height', unique=True,) + + +def create_abci_chains_indexes(conn, dbname): + logger.info('Create `abci_chains.height` secondary index.') + + conn.conn[dbname]['abci_chains'].create_index('height', + name='height', + unique=True,) + + logger.info('Create `abci_chains.chain_id` secondary index.') + conn.conn[dbname]['abci_chains'].create_index('chain_id', + name='chain_id', + unique=True) diff --git a/bigchaindb/backend/query.py b/bigchaindb/backend/query.py index 4d62c633..7965d5ee 100644 --- a/bigchaindb/backend/query.py +++ b/bigchaindb/backend/query.py @@ -380,3 +380,23 @@ def get_asset_tokens_for_public_key(connection, asset_id, Iterator of transaction that list given owner in conditions. """ raise NotImplementedError + + +@singledispatch +def store_abci_chain(conn, height, chain_id, is_synced=True): + """Create or update an ABCI chain at the given height. + Usually invoked in the beginning of the ABCI communications (height=0) + or when ABCI client (like Tendermint) is migrated (any height). + + Args: + is_synced: True if the chain is known by both ABCI client and server + """ + raise NotImplementedError + + +@singledispatch +def get_latest_abci_chain(conn): + """Returns the ABCI chain stored at the biggest height, if any, + None otherwise. + """ + raise NotImplementedError diff --git a/bigchaindb/backend/schema.py b/bigchaindb/backend/schema.py index 04c1bdd0..108bd39b 100644 --- a/bigchaindb/backend/schema.py +++ b/bigchaindb/backend/schema.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) # Tables/collections that every backend database must create TABLES = ('transactions', 'blocks', 'assets', 'metadata', - 'validators', 'pre_commit', 'utxos') + 'validators', 'pre_commit', 'utxos', 'abci_chains') VALID_LANGUAGES = ('danish', 'dutch', 'english', 'finnish', 'french', 'german', 'hungarian', 'italian', 'norwegian', 'portuguese', 'romanian', diff --git a/bigchaindb/core.py b/bigchaindb/core.py index 621960fa..3743f0ec 100644 --- a/bigchaindb/core.py +++ b/bigchaindb/core.py @@ -49,12 +49,51 @@ class App(BaseApplication): self.new_height = None def init_chain(self, genesis): - """Initialize chain with block of height 0""" + """Initialize chain upon genesis or a migration""" - validator_set = [vutils.decode_validator(v) for v in genesis.validators] - block = Block(app_hash='', height=0, transactions=[]) + app_hash = '' + height = 0 + + known_chain = self.bigchaindb.get_latest_abci_chain() + if known_chain is not None: + chain_id = known_chain['chain_id'] + + if known_chain['is_synced']: + msg = f'Ignoring the InitChain ABCI request ({genesis}) - ' + \ + 'the chain {chain_id} is already synced.' + + logger.error(msg) + return ResponseInitChain() + + if chain_id != genesis.chain_id: + msg = f'Got mismatching chain ID in the InitChain ' + \ + 'ABCI request - you need to migrate the ABCI client ' + \ + 'and set new chain ID: {chain_id}.' + logger.error(msg) + return ResponseInitChain() + + # set migration values for app hash and height + block = self.bigchaindb.get_latest_block() + app_hash = '' if block is None else block['app_hash'] + height = 0 if block is None else block['height'] + 1 + + known_validators = self.bigchaindb.get_validators() + validator_set = [vutils.decode_validator(v) + for v in genesis.validators] + + if known_validators and known_validators != validator_set: + msg = f'Got mismatching validator set in the InitChain ' + \ + 'ABCI request - you need to migrate the ABCI client ' + \ + 'and set new validator set: {known_validators}.' + logger.error(msg) + return ResponseInitChain() + + block = Block(app_hash=app_hash, height=height, transactions=[]) self.bigchaindb.store_block(block._asdict()) - self.bigchaindb.store_validator_set(1, validator_set, None) + self.bigchaindb.store_validator_set(height + 1, validator_set, None) + abci_chain_height = 0 if known_chain is None else known_chain['height'] + self.bigchaindb.store_abci_chain(abci_chain_height, + genesis.chain_id, True) return ResponseInitChain() def info(self, request): diff --git a/bigchaindb/lib.py b/bigchaindb/lib.py index 8f0f80a8..8ad156d8 100644 --- a/bigchaindb/lib.py +++ b/bigchaindb/lib.py @@ -447,6 +447,13 @@ class BigchainDB(object): 'validators': validators, 'election_id': election_id}) + def store_abci_chain(self, height, chain_id, is_synced=True): + return backend.query.store_abci_chain(self.connection, height, + chain_id, is_synced) + + def get_latest_abci_chain(self): + return backend.query.get_latest_abci_chain(self.connection) + Block = namedtuple('Block', ('app_hash', 'height', 'transactions')) diff --git a/tests/backend/localmongodb/test_queries.py b/tests/backend/localmongodb/test_queries.py index 77bfddd0..9c38d822 100644 --- a/tests/backend/localmongodb/test_queries.py +++ b/tests/backend/localmongodb/test_queries.py @@ -7,6 +7,9 @@ from copy import deepcopy import pytest import pymongo +from bigchaindb.backend import connect, query + + pytestmark = [pytest.mark.tendermint, pytest.mark.bdb] @@ -394,3 +397,51 @@ def test_validator_update(): v91 = query.get_validator_set(conn) assert v91['height'] == 91 + + +@pytest.mark.parametrize('description,stores,expected', [ + ( + 'Query empty database.', + [], + None, + ), + ( + 'Store one chain with the default value for `is_synced`.', + [ + {'height': 0, 'chain_id': 'some-id'}, + ], + {'height': 0, 'chain_id': 'some-id', 'is_synced': True}, + ), + ( + 'Store one chain with a custom value for `is_synced`.', + [ + {'height': 0, 'chain_id': 'some-id', 'is_synced': False}, + ], + {'height': 0, 'chain_id': 'some-id', 'is_synced': False}, + ), + ( + 'Store one chain, then update it.', + [ + {'height': 0, 'chain_id': 'some-id', 'is_synced': True}, + {'height': 0, 'chain_id': 'new-id', 'is_synced': False}, + ], + {'height': 0, 'chain_id': 'new-id', 'is_synced': False}, + ), + ( + 'Store a chain, update it, store another chain.', + [ + {'height': 0, 'chain_id': 'some-id', 'is_synced': True}, + {'height': 0, 'chain_id': 'some-id', 'is_synced': False}, + {'height': 10, 'chain_id': 'another-id', 'is_synced': True}, + ], + {'height': 10, 'chain_id': 'another-id', 'is_synced': True}, + ), +]) +def test_store_abci_chain(description, stores, expected): + conn = connect() + + for store in stores: + query.store_abci_chain(conn, **store) + + actual = query.get_latest_abci_chain(conn) + assert expected == actual, description diff --git a/tests/backend/localmongodb/test_schema.py b/tests/backend/localmongodb/test_schema.py index 136715ab..f7ae1bda 100644 --- a/tests/backend/localmongodb/test_schema.py +++ b/tests/backend/localmongodb/test_schema.py @@ -24,7 +24,7 @@ def test_init_creates_db_tables_and_indexes(): collection_names = conn.conn[dbname].collection_names() assert set(collection_names) == { 'transactions', 'assets', 'metadata', 'blocks', 'utxos', 'pre_commit', - 'validators' + 'validators', 'abci_chains', } indexes = conn.conn[dbname]['assets'].index_information().keys() @@ -79,7 +79,8 @@ def test_create_tables(): collection_names = conn.conn[dbname].collection_names() assert set(collection_names) == { 'transactions', 'assets', 'metadata', 'blocks', 'utxos', 'validators', - 'pre_commit'} + 'pre_commit', 'abci_chains', + } def test_create_secondary_indexes(): diff --git a/tests/tendermint/conftest.py b/tests/tendermint/conftest.py index 8efa8e76..fccd2ade 100644 --- a/tests/tendermint/conftest.py +++ b/tests/tendermint/conftest.py @@ -16,7 +16,8 @@ def validator_pub_key(): @pytest.fixture def init_chain_request(): addr = codecs.decode(b'9FD479C869C7D7E7605BF99293457AA5D80C3033', 'hex') - pk = codecs.decode(b'VAgFZtYw8bNR5TMZHFOBDWk9cAmEu3/c6JgRBmddbbI=', 'base64') + pk = codecs.decode(b'VAgFZtYw8bNR5TMZHFOBDWk9cAmEu3/c6JgRBmddbbI=', + 'base64') val_a = types.Validator(address=addr, power=10, pub_key=types.PubKey(type='ed25519', data=pk)) diff --git a/tests/tendermint/test_core.py b/tests/tendermint/test_core.py index 40958aa2..7aea9ec0 100644 --- a/tests/tendermint/test_core.py +++ b/tests/tendermint/test_core.py @@ -2,17 +2,27 @@ # SPDX-License-Identifier: (Apache-2.0 AND CC-BY-4.0) # Code is Apache-2.0 and docs are CC-BY-4.0 +import codecs import json import pytest +import random from abci.types_pb2 import ( + PubKey, + ResponseInitChain, + RequestInitChain, RequestBeginBlock, - RequestEndBlock + RequestEndBlock, + Validator, ) +from bigchaindb import App +from bigchaindb.backend.localmongodb import query +from bigchaindb.common.crypto import generate_key_pair from bigchaindb.core import (CodeTypeOk, CodeTypeError, ) +from bigchaindb.lib import Block from bigchaindb.upsert_validator.validator_utils import new_validator_set from bigchaindb.tendermint_utils import public_key_to_base64 @@ -24,6 +34,137 @@ def encode_tx_to_bytes(transaction): return json.dumps(transaction.to_dict()).encode('utf8') +def generate_address(): + return ''.join(random.choices('1,2,3,4,5,6,7,8,9,A,B,C,D,E,F'.split(','), + k=40)).encode() + + +def generate_validator(): + addr = codecs.decode(generate_address(), 'hex') + pk, _ = generate_key_pair() + pub_key = PubKey(type='ed25519', data=pk.encode()) + val = Validator(address=addr, power=10, pub_key=pub_key) + return val + + +def generate_init_chain_request(chain_id, vals=None): + vals = vals if vals is not None else [generate_validator()] + return RequestInitChain(validators=vals, chain_id=chain_id) + + +def test_init_chain_successfully_registers_chain(b): + request = generate_init_chain_request('chain-XYZ') + res = App(b).init_chain(request) + assert res == ResponseInitChain() + chain = query.get_latest_abci_chain(b.connection) + assert chain == {'height': 0, 'chain_id': 'chain-XYZ', 'is_synced': True} + assert query.get_latest_block(b.connection) == { + 'height': 0, + 'app_hash': '', + 'transactions': [], + } + + +def test_init_chain_ignores_invalid_init_chain_requests(b): + validators = [generate_validator()] + request = generate_init_chain_request('chain-XYZ', validators) + res = App(b).init_chain(request) + assert res == ResponseInitChain() + + validator_set = query.get_validator_set(b.connection) + + invalid_requests = [ + request, # the same request again + # different validator set + generate_init_chain_request('chain-XYZ'), + # different chain ID + generate_init_chain_request('chain-ABC', validators), + ] + for r in invalid_requests: + res = App(b).init_chain(r) + assert res == ResponseInitChain() + # assert nothing changed - neither validator set, nor chain ID + new_validator_set = query.get_validator_set(b.connection) + assert new_validator_set == validator_set + new_chain_id = query.get_latest_abci_chain(b.connection)['chain_id'] + assert new_chain_id == 'chain-XYZ' + assert query.get_latest_block(b.connection) == { + 'height': 0, + 'app_hash': '', + 'transactions': [], + } + + +def test_init_chain_recognizes_new_chain_after_migration(b): + validators = [generate_validator()] + request = generate_init_chain_request('chain-XYZ', validators) + res = App(b).init_chain(request) + assert res == ResponseInitChain() + + validator_set = query.get_validator_set(b.connection)['validators'] + + # simulate a migration + query.store_abci_chain(b.connection, 1, 'chain-XYZ-1', False) + query.store_block(b.connection, Block(app_hash='', height=1, + transactions=[])._asdict()) + + # the same or other mismatching requests are ignored + invalid_requests = [ + request, + generate_init_chain_request('unknown', validators), + generate_init_chain_request('chain-XYZ'), + generate_init_chain_request('chain-XYZ-1'), + ] + for r in invalid_requests: + res = App(b).init_chain(r) + assert res == ResponseInitChain() + assert query.get_latest_abci_chain(b.connection) == { + 'chain_id': 'chain-XYZ-1', + 'is_synced': False, + 'height': 1, + } + new_validator_set = query.get_validator_set(b.connection)['validators'] + assert new_validator_set == validator_set + + # a request with the matching chain ID and matching validator set + # completes the migration + request = generate_init_chain_request('chain-XYZ-1', validators) + res = App(b).init_chain(request) + assert res == ResponseInitChain() + assert query.get_latest_abci_chain(b.connection) == { + 'chain_id': 'chain-XYZ-1', + 'is_synced': True, + 'height': 1, + } + assert query.get_latest_block(b.connection) == { + 'height': 2, + 'app_hash': '', + 'transactions': [], + } + + # requests with old chain ID and other requests are ignored + invalid_requests = [ + request, + generate_init_chain_request('chain-XYZ', validators), + generate_init_chain_request('chain-XYZ-1'), + ] + for r in invalid_requests: + res = App(b).init_chain(r) + assert res == ResponseInitChain() + assert query.get_latest_abci_chain(b.connection) == { + 'chain_id': 'chain-XYZ-1', + 'is_synced': True, + 'height': 1, + } + new_validator_set = query.get_validator_set(b.connection)['validators'] + assert new_validator_set == validator_set + assert query.get_latest_block(b.connection) == { + 'height': 2, + 'app_hash': '', + 'transactions': [], + } + + def test_check_tx__signed_create_is_ok(b): from bigchaindb import App from bigchaindb.models import Transaction diff --git a/tests/tendermint/test_utils.py b/tests/tendermint/test_utils.py index ae34104f..46b36525 100644 --- a/tests/tendermint/test_utils.py +++ b/tests/tendermint/test_utils.py @@ -12,6 +12,7 @@ except ImportError: import pytest + pytestmark = pytest.mark.tendermint diff --git a/tests/utils.py b/tests/utils.py index 349f7e5a..87c07a79 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,6 +5,7 @@ from functools import singledispatch from bigchaindb.backend.localmongodb.connection import LocalMongoDBConnection +from bigchaindb.backend.schema import TABLES @singledispatch @@ -14,13 +15,8 @@ def flush_db(connection, dbname): @flush_db.register(LocalMongoDBConnection) def flush_localmongo_db(connection, dbname): - connection.conn[dbname].bigchain.delete_many({}) - connection.conn[dbname].blocks.delete_many({}) - connection.conn[dbname].transactions.delete_many({}) - connection.conn[dbname].assets.delete_many({}) - connection.conn[dbname].metadata.delete_many({}) - connection.conn[dbname].utxos.delete_many({}) - connection.conn[dbname].validators.delete_many({}) + for t in TABLES: + getattr(connection.conn[dbname], t).delete_many({}) def generate_block(bigchain):