diff --git a/.gitignore b/.gitignore index 12b32f23..4a8db117 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ docs/_build # IDE crud .idea *.iml +.vscode diff --git a/pyethapp/eth_service.py b/pyethapp/eth_service.py index 56216b00..a93f6388 100644 --- a/pyethapp/eth_service.py +++ b/pyethapp/eth_service.py @@ -166,21 +166,16 @@ def __init__(self, app): super(ChainService, self).__init__(app) log.info('initializing chain') coinbase = app.services.accounts.coinbase - # env = Env(self.db, sce['block']) - - # genesis_data = sce.get('genesis_data', {}) - # if not genesis_data: - # genesis_data = mk_genesis_data(env) - # self.chain = Chain( - # env=env, genesis=genesis_data, coinbase=coinbase, - # new_head_cb=self._on_new_head) - ALLOC = {} - # TODO: Remove this dumb default alloc - ALLOC[decode_hex('7d577a597b2742b498cb5cf0c26cdcd726d39e6e')] = {'balance': 50000*10**19} - ALLOC[decode_hex('b96611e02f9eff3c8afc6226d4ebfa81a821547c')] = {'balance': 50000*10**19} - ALLOC[decode_hex('b42e5cafe87d951c5cf0022bfdab06fe56ba2ad2')] = {'balance': 5 * 10**9 * 10**19} - genesis_data = casper_utils.make_casper_genesis(ALLOC, 10, 100, 0.02, 0.002) - self.chain = Chain(genesis=genesis_data, reset_genesis=True, coinbase=coinbase, new_head_cb=self._on_new_head) + env = Env(self.db, sce['block']) + + genesis_data = sce.get('genesis_data', {}) + if not genesis_data: + genesis_data = casper_utils.make_casper_genesis(env) + self.chain = Chain( + genesis=genesis_data, + reset_genesis=True, + coinbase=coinbase, + new_head_cb=self._on_new_head) header = self.chain.state.prev_headers[0] log.info('chain at', number=header.number) if 'genesis_hash' in sce: diff --git a/pyethapp/tests/test_validator_service.py b/pyethapp/tests/test_validator_service.py new file mode 100644 index 00000000..e5f0f403 --- /dev/null +++ b/pyethapp/tests/test_validator_service.py @@ -0,0 +1,429 @@ +from itertools import count +import os +import pytest +import rlp +import shutil +import tempfile +from devp2p.service import BaseService +from ethereum.config import default_config +from pyethapp.config import update_config_with_defaults, get_default_config +from ethereum.hybrid_casper import casper_utils +from ethereum.pow.ethpow import mine +from ethereum.slogging import get_logger, configure_logging +from ethereum.tools import tester +from ethereum.utils import encode_hex, decode_int +from pyethapp.app import EthApp +from pyethapp.db_service import DBService +from pyethapp.eth_service import ChainService +from pyethapp.validator_service import ValidatorService +from pyethapp.pow_service import PoWService + +log = get_logger('tests.validator_service') +configure_logging('tests.validator_service:debug,validator:debug,eth.chainservice:debug') + +class MockAccount(object): + def __init__(self, account): + for i in range(0, len(tester.accounts)): + if encode_hex(tester.accounts[i]) == account: + self.address = tester.accounts[i] + self.privkey = tester.keys[i] + return + raise Exception("Bad account") + + def sign_tx(self, tx): + return tx.sign(self.privkey) + +class AccountsServiceMock(BaseService): + name = 'accounts' + + def __init__(self, app): + super(AccountsServiceMock, self).__init__(app) + self.coinbase = None + + def find(self, account): + self.coinbase = MockAccount(account) + return self.coinbase + +class PeerManagerMock(BaseService): + name = 'peermanager' + + def broadcast(*args, **kwargs): + pass + +def _test_app(account): + class TestApp(EthApp): + def mine_blocks(self, n): + for i in range(0, n): + self.mine_one_block() + + def mine_to_next_epoch(self, number_of_epochs=1): + epoch_length = self.config['eth']['block']['EPOCH_LENGTH'] + distance_to_next_epoch = (epoch_length - self.services.chain.chain.state.block_number) % epoch_length + number_of_blocks = distance_to_next_epoch + epoch_length*(number_of_epochs-1) + 2 + return self.mine_blocks(number_of_blocks) + + def mine_one_block(self): + """Mine until a valid nonce is found. + :returns: the new head + """ + log.debug('mining next block') + block = self.services.chain.head_candidate + chain = self.services.chain.chain + head_number = chain.head.number + delta_nonce = 10**6 + for start_nonce in count(0, delta_nonce): + bin_nonce, mixhash = mine(block.number, block.difficulty, block.mining_hash, + start_nonce=start_nonce, rounds=delta_nonce) + if bin_nonce: + break + self.services.chain.add_mined_block(block) + self.services.pow.recv_found_nonce(bin_nonce, mixhash, block.mining_hash) + if len(chain.time_queue) > 0: + # If we mine two blocks within one second, pyethereum will + # force the new block's timestamp to be in the future (see + # ethereum1_setup_block()), and when we try to add that block + # to the chain (via Chain.add_block()), it will be put in a + # queue for later processing. Since we need to ensure the + # block has been added before we continue the test, we + # have to manually process the time queue. + log.debug('block mined too fast, processing time queue') + chain.process_time_queue(new_time=block.timestamp) + log.debug('block mined') + assert chain.head.difficulty == 1 + assert chain.head.number == head_number + 1 + return chain.head + + @property + def casper(self): + v = self.services.validator + t = tester.State(v.chain.state.ephemeral_clone()) + return tester.ABIContract(t, casper_utils.casper_abi, v.chain.casper_address) + + config = { + 'db': {'implementation': 'EphemDB'}, + 'eth': { + 'block': { # reduced difficulty, increased gas limit, allocations to test accounts + 'GENESIS_DIFFICULTY': 1, + 'BLOCK_DIFF_FACTOR': 2, # greater than difficulty, thus difficulty is constant + 'GENESIS_GAS_LIMIT': 3141592, + 'GENESIS_INITIAL_ALLOC': { + encode_hex(tester.accounts[0]): {'balance': 10**24}, + encode_hex(tester.accounts[1]): {'balance': 10**24}, + }, + # Casper FFG stuff: set these arbitrarily short to facilitate testing + 'EPOCH_LENGTH': 10, + 'WITHDRAWAL_DELAY': 5, + 'BASE_INTEREST_FACTOR': 0.02, + 'BASE_PENALTY_FACTOR': 0.002, + 'DEPOSIT_SIZE': 5000 * 10**18, + } + }, + 'validate': [encode_hex(account)], + } + + services = [ + AccountsServiceMock, + DBService, + ChainService, + PoWService, + PeerManagerMock, + ValidatorService, + ] + update_config_with_defaults(config, get_default_config([TestApp] + services)) + update_config_with_defaults(config, {'eth': {'block': default_config}}) + app = TestApp(config) + + for service in services: + service.register_with_app(app) + + return app + +@pytest.fixture() +def test_app(): + return _test_app(tester.accounts[0]) + +@pytest.fixture() +def test_app2(): + return _test_app(tester.accounts[1]) + +def test_login_logout_withdraw(test_app): + """ + Basic full-circle test of normal validator behavior in normal circumstances + (i.e., no slashing). Login, wait to vote, begin voting, logout, wait to withdraw + deposit, then withdraw. + """ + v = test_app.services.validator + epoch_length = test_app.config['eth']['block']['EPOCH_LENGTH'] + withdrawal_delay = test_app.config['eth']['block']['WITHDRAWAL_DELAY'] + base_interest_factor = test_app.config['eth']['block']['BASE_INTEREST_FACTOR'] + base_penalty_factor = test_app.config['eth']['block']['BASE_PENALTY_FACTOR'] + deposit_size = test_app.config['eth']['block']['DEPOSIT_SIZE'] + initial_balance = test_app.config['eth']['block']['GENESIS_INITIAL_ALLOC'][encode_hex(v.coinbase.address)]['balance'] + + assert not v.did_broadcast_valcode + assert not v.did_broadcast_deposit + assert v.chain.state.get_balance(v.coinbase.address) == initial_balance + + # We get opcode errors if this isn't true + assert v.chain.state.is_METROPOLIS() + + assert test_app.casper.get_current_epoch() == 0 + + # Mining these first three blocks does the following: + # 1. validator sends the valcode tx + test_app.mine_blocks(1) + assert v.did_broadcast_valcode + assert not v.did_broadcast_deposit + assert v.valcode_addr is not None + assert not v.chain.state.get_code(v.valcode_addr) + + # 2. validator sends the deposit tx + test_app.mine_blocks(1) + assert v.did_broadcast_valcode + assert v.did_broadcast_deposit + assert v.chain.state.get_code(v.valcode_addr) + + # This should still fail + validator_index = v.get_validator_index(v.chain.state) + assert validator_index is None + + # 3. validator becomes active + test_app.mine_blocks(3) + + # Make sure the deposit moved + assert v.chain.state.get_balance(v.coinbase.address) == initial_balance - deposit_size + + # Check validator index + validator_index = v.get_validator_index(v.chain.state) + assert validator_index == 1 + + # Go from epoch 0 -> epoch 1 + test_app.mine_to_next_epoch() + # Check that epoch 0 was finalized (no validators logged in) + assert test_app.casper.get_current_epoch() == 1 + assert test_app.casper.get_votes__is_finalized(0) + + # Go from epoch 1 -> epoch 2 + test_app.mine_to_next_epoch() + # Check that epoch 1 was finalized (no validators logged in) + assert test_app.casper.get_current_epoch() == 2 + assert test_app.casper.get_votes__is_finalized(1) + + # Make sure we're not logged in yet + target_epoch = v.chain.state.block_number // epoch_length + assert not v.is_logged_in(test_app.casper, target_epoch, validator_index) + + # Mine one more epoch and we should be logged in + test_app.mine_to_next_epoch() + assert test_app.casper.get_current_epoch() == 3 + target_epoch = v.chain.state.block_number // epoch_length + source_epoch = test_app.casper.get_recommended_source_epoch() + assert v.is_logged_in(test_app.casper, target_epoch, validator_index) + + # Make sure the vote transaction was generated + vote = v.votes[target_epoch] + assert vote is not None + vote_decoded = rlp.decode(vote) + # validator index + assert decode_int(vote_decoded[0]) == validator_index + # target + assert decode_int(vote_decoded[2]) == target_epoch + # source + assert decode_int(vote_decoded[3]) == source_epoch + + # Check deposit level + assert test_app.casper.get_total_curdyn_deposits() == deposit_size + + # This should still fail + with pytest.raises(tester.TransactionFailed): + test_app.casper.get_main_hash_voted_frac() + + # One more epoch and the vote_frac has a value (since it requires there + # to be at least one vote for both the current and the prev epoch) + test_app.mine_to_next_epoch() + assert test_app.casper.get_current_epoch() == 4 + + # One more block to mine the vote + test_app.mine_blocks(1) + + # Check deposit level (gone up) and vote_frac + voted_frac = test_app.casper.get_main_hash_voted_frac() + assert test_app.casper.get_total_curdyn_deposits() > deposit_size + assert voted_frac > 0.99 + + # Finally, test logout and withdraw + # Send logout + v.broadcast_logout() + test_app.mine_blocks(1) + + # Make sure we can't withdraw yet + with pytest.raises(tester.TransactionFailed): + v.broadcast_withdraw() + + # Make sure we are still logged in, can still vote, etc. + test_app.mine_to_next_epoch() + + # One more block to mine the vote + test_app.mine_blocks(1) + assert test_app.casper.get_current_epoch() == 5 + voted_frac = test_app.casper.get_main_hash_voted_frac() + assert test_app.casper.get_total_curdyn_deposits() > deposit_size + assert voted_frac > 0.99 + target_epoch = v.chain.state.block_number // epoch_length + assert v.is_logged_in(test_app.casper, target_epoch, validator_index) + assert v.votes[target_epoch] + + # Mine two epochs + test_app.mine_to_next_epoch() + test_app.mine_to_next_epoch() + + # Make sure we don't vote, are not logged in + assert test_app.casper.get_current_epoch() == 7 + + # This fails because of division by zero + with pytest.raises(tester.TransactionFailed): + test_app.casper.get_main_hash_voted_frac() + assert test_app.casper.get_total_curdyn_deposits() == 0 + target_epoch = v.chain.state.block_number // epoch_length + assert not v.is_logged_in(test_app.casper, target_epoch, validator_index) + with pytest.raises(KeyError): + v.votes[target_epoch] + + # Mine until the epoch before we can withdraw + test_app.mine_to_next_epoch(withdrawal_delay) + assert test_app.casper.get_current_epoch() == 12 + + # Make sure we cannot withdraw yet + with pytest.raises(tester.TransactionFailed): + v.broadcast_withdraw() + + # Make sure we can withdraw exactly at this epoch, not before + test_app.mine_to_next_epoch() + assert test_app.casper.get_current_epoch() == 13 + v.broadcast_withdraw() + test_app.mine_blocks(1) + + # Make sure deposit was refunded (along with interest) + assert v.chain.state.get_balance(v.coinbase.address) > initial_balance + + # Make sure finalization is still happening with no validators + assert test_app.casper.get_votes__is_finalized(12) + +def test_double_deposit(test_app): + """ + Make sure we cannot login a second time if already logged in. Make sure + second deposit tx fails. + """ + v = test_app.services.validator + initial_balance = test_app.config['eth']['block']['GENESIS_INITIAL_ALLOC'][encode_hex(v.coinbase.address)]['balance'] + epoch_length = test_app.config['eth']['block']['EPOCH_LENGTH'] + withdrawal_delay = test_app.config['eth']['block']['WITHDRAWAL_DELAY'] + assert test_app.casper.get_current_epoch() == 0 + + # Broadcast valcode and deposits, log in, begin voting + test_app.mine_to_next_epoch(3) + + # Try to login again, it should fail + # To test this, we have break abstraction somewhat and simulate it here. + deposit_tx = v.mk_deposit_tx(v.deposit_size, v.valcode_addr) + with pytest.raises(tester.TransactionFailed): + v.broadcast_tx(deposit_tx) + +def test_login_logout_login(test_app): + """ + Make sure we can login, logout, withdraw funds, then subsequently login + and deposit again. + """ + v = test_app.services.validator + initial_balance = test_app.config['eth']['block']['GENESIS_INITIAL_ALLOC'][encode_hex(v.coinbase.address)]['balance'] + epoch_length = test_app.config['eth']['block']['EPOCH_LENGTH'] + withdrawal_delay = test_app.config['eth']['block']['WITHDRAWAL_DELAY'] + assert test_app.casper.get_current_epoch() == 0 + + # Broadcast valcode and deposits, log in, begin voting + test_app.mine_to_next_epoch(3) + target_epoch = v.chain.state.block_number // epoch_length + source_epoch = test_app.casper.get_recommended_source_epoch() + assert v.is_logged_in(test_app.casper, target_epoch, validator_index) + assert v.votes[target_epoch] + + # Broadcast logout + v.broadcast_logout() + test_app.mine_to_next_epoch(3+withdrawal_delay) + + # Redeem deposit + v.broadcast_withdraw() + test_app.mine_blocks(1) + balance1 = v.chain.state.get_balance(v.coinbase.address) + assert balance1 > initial_balance + + # Reset the validator state + test_app.deregister_service(v) + ValidatorService.register_with_app(test_app) + v = test_app.services.validator + + # Log in again, begin voting + test_app.mine_to_next_epoch(3) + target_epoch = v.chain.state.block_number // epoch_length + source_epoch = test_app.casper.get_recommended_source_epoch() + assert v.is_logged_in(test_app.casper, target_epoch, validator_index) + assert v.votes[target_epoch] + + # Broadcast logout again + v.broadcast_logout() + test_app.mine_to_next_epoch(3+withdrawal_delay) + + # Redeem deposit again + v.broadcast_withdraw() + test_app.mine_blocks(1) + assert v.chain.state.get_balance(v.coinbase.address) > balance_1 + +def test_multiple_validators(test_app, test_app2): + """ + Test how multiple validators behave in each others' presence. Make sure + they both deposit and vote. + """ + deposit_size = test_app.config['eth']['block']['DEPOSIT_SIZE'] + + # Link the two validators + v1 = test_app.services.validator + v2 = test_app2.services.validator + test_app2.services.chain = test_app.services.chain + v2.chainservice = test_app.services.chain + v2.chain = test_app.services.chain.chain + test_app.services.chain.on_new_head_cbs.append(test_app2.services.validator.on_new_head) + + # Make sure the valcode txs worked + test_app.mine_blocks(2) + assert v1.chain.state.get_code(v1.valcode_addr) + assert v2.chain.state.get_code(v2.valcode_addr) + + # Vote a bunch until vote frac is calculable + test_app.mine_to_next_epoch(6) + # One more block to mine the vote + test_app.mine_blocks(1) + voted_frac = test_app.casper.get_main_hash_voted_frac() + assert test_app.casper.get_total_curdyn_deposits() > deposit_size * 2 + assert voted_frac > 0.99 + +# Test slashing conditions--make sure that we don't violate them, and also +# make sure that we can catch slashable behavior on the part of another validator. + +def test_prevent_double_vote(test_app): + """ + Make sure the validator service never votes for the same target epoch twice. + """ + pass + +def test_no_surround(test_app): + """ + Make sure the validator service never casts a vote surrounding another. + """ + pass + +def test_catch_violation(test_app): + """ + Make sure the validator service recognizes and reports slashable behavior + on the part of another validator. + """ + pass \ No newline at end of file diff --git a/pyethapp/validator_service.py b/pyethapp/validator_service.py index bc748721..7360dc79 100644 --- a/pyethapp/validator_service.py +++ b/pyethapp/validator_service.py @@ -25,16 +25,14 @@ def __init__(self, app): self.chainservice = app.services.chain self.chain = self.chainservice.chain - self.valcode_tx = None - self.deposit_tx = None - self.deposit_size = 5000 * 10**18 + self.deposit_size = self.config['eth']['block']['DEPOSIT_SIZE'] self.valcode_addr = None - self.has_broadcasted_deposit = False + self.did_broadcast_valcode = False + self.did_broadcast_deposit = False self.votes = dict() self.latest_target_epoch = -1 self.latest_source_epoch = -1 self.epoch_length = self.chain.env.config['EPOCH_LENGTH'] - # self.chain.time = lambda: int(time.time()) if app.config['validate']: self.coinbase = app.services.accounts.find(app.config['validate'][0]) @@ -42,98 +40,134 @@ def __init__(self, app): else: self.validating = False - app.services.chain.on_new_head_cbs.append(self.on_new_head) + self.chainservice.on_new_head_cbs.append(self.on_new_head) def on_new_head(self, block): if not self.validating: + log.info('[hybrid_casper] not validating, not updating') return - if self.app.services.chain.is_syncing: + if self.chainservice.is_syncing: + log.info('[hybrid_casper] chain syncing, not updating') return self.update() - def broadcast_deposit(self): - if not self.valcode_tx or not self.deposit_tx: - nonce = self.chain.state.get_nonce(self.coinbase.address) - # Generate transactions - valcode_tx = self.mk_validation_code_tx(nonce) - valcode_addr = utils.mk_contract_address(self.coinbase.address, nonce) - deposit_tx = self.mk_deposit_tx(self.deposit_size, valcode_addr, nonce+1) - # Verify the transactions pass - temp_state = self.chain.state.ephemeral_clone() - valcode_success, o1 = apply_transaction(temp_state, valcode_tx) - deposit_success, o2 = apply_transaction(temp_state, deposit_tx) - self.valcode_tx = valcode_tx - log.info('Valcode Tx generated: {}'.format(str(valcode_tx))) - self.valcode_addr = valcode_addr - self.deposit_tx = deposit_tx - log.info('Deposit Tx generated: {}'.format(str(deposit_tx))) - self.chainservice.broadcast_transaction(valcode_tx) - - def broadcast_logout(self, login_logout_flag): + def broadcast_tx(self, tx): + """ + Make sure that a transaction succeeds, and "broadcast" it to the chain service. + """ + temp_state = self.chain.state.ephemeral_clone() + success, output = apply_transaction(temp_state, tx) + if not success: + raise tester.TransactionFailed('Transaction failed, not broadcasting: {}'.format(str(tx))) + log.info('[hybrid_casper] Broadcasting successful tx: {}'.format(str(tx))) + self.chainservice.add_transaction(tx) + + def broadcast_logout(self): epoch = self.chain.state.block_number // self.epoch_length # Generage the message logout_msg = casper_utils.mk_logout(self.get_validator_index(self.chain.state), epoch, self.coinbase.privkey) # Generate transactions logout_tx = self.mk_logout_tx(logout_msg) - # Verify the transactions pass - temp_state = self.chain.state.ephemeral_clone() - logout_success, o1 = apply_transaction(temp_state, logout_tx) - if not logout_success: - raise Exception('Valcode tx or deposit tx failed') - log.info('Login/logout Tx generated: {}'.format(str(logout_tx))) - self.chainservice.broadcast_transaction(logout_tx) + self.broadcast_tx(logout_tx) + + def broadcast_withdraw(self): + casper = tester.ABIContract(tester.State(self.chain.state.ephemeral_clone()), casper_utils.casper_abi, + self.chain.casper_address) + validator_index = self.get_validator_index(self.chain.state) + withdrawal_delay = casper.get_withdrawal_delay() + end_dynasty = casper.get_validators__end_dynasty(validator_index) + end_epoch = casper.get_dynasty_start_epoch(end_dynasty + 1) + current_dynasty = casper.get_dynasty() + current_epoch = casper.get_current_epoch() + log.info(('[hybrid_casper] Attempting withdraw at dynasty {} - end dynasty {} - ' + + 'current_epoch {} - end_epoch {} - withdrawal_delay {} - validator idx {}').format( + current_dynasty, end_dynasty, current_epoch, end_epoch, withdrawal_delay, validator_index)) + + withdraw_tx = self.mk_withdraw_tx(validator_index) + self.broadcast_tx(withdraw_tx) def update(self): - if self.chain.state.get_balance(self.coinbase.address) < self.deposit_size: - log.info('Cannot login as validator: Not enough ETH!') + log.info('[hybrid_casper] validator {} updating'.format(self)) + + # Note about valcode and deposit transactions: these need to be broadcast in + # a particular order, valcode first. In order to ensure this, we check that + # the valcode tx exists before we attempt to broadcast the deposit tx. + + # Generate valcode and deposit transactions + if not self.did_broadcast_valcode: + valcode_tx = self.mk_validation_code_tx() + nonce = self.chain.state.get_nonce(self.coinbase.address) + self.valcode_addr = utils.mk_contract_address(self.coinbase.address, nonce) + log.info('[hybrid_casper] Broadcasting valcode tx: {}'.format(str(valcode_tx))) + self.broadcast_tx(valcode_tx) + self.did_broadcast_valcode = True + + # Wait for it to be mined + return + + # No point in going beyond this point until the valcode tx is mined + elif not self.chain.state.get_code(self.valcode_addr): + log.info('[hybrid_casper] Waiting for valcode tx to be mined') + return + + # Now we can broadcast the deposit + elif not self.did_broadcast_deposit: + # Make sure we have enough ETH to deposit + if self.chain.state.get_balance(self.coinbase.address) < self.deposit_size: + log.info('[hybrid_casper] Cannot login as validator: insufficient balance') + return + + deposit_tx = self.mk_deposit_tx(self.deposit_size, self.valcode_addr) + log.info('[hybrid_casper] Broadcasting deposit tx: {}'.format(str(deposit_tx))) + self.broadcast_tx(deposit_tx) + self.did_broadcast_deposit = True + + # Wait for it to be mined return - if not self.valcode_tx or not self.deposit_tx: - self.broadcast_deposit() - if not self.has_broadcasted_deposit and self.chain.state.get_code(self.valcode_addr): - log.info('Found code!') - self.chainservice.broadcast_transaction(self.deposit_tx) - self.has_broadcasted_deposit = True - log.info('Validator index: {}'.format(self.get_validator_index(self.chain.state))) + # We are clear to vote! casper = tester.ABIContract(tester.State(self.chain.state.ephemeral_clone()), casper_utils.casper_abi, self.chain.casper_address) + validator_index = self.get_validator_index(self.chain.state) + log.info('[hybrid_casper] Active validator index: {}'.format(validator_index)) + + # This fails if there are zero deposits (div. by zero), e.g. if we are the first + # validator to deposit try: - log.info('&&& '.format()) - log.info('Vote percent: {} - Deposits: {} - Recommended Source: {} - Current Epoch: {}' - .format(casper.get_main_hash_voted_frac(), casper.get_total_curdyn_deposits(), - casper.get_recommended_source_epoch(), casper.get_current_epoch())) + voted_frac = casper.get_main_hash_voted_frac() + except tester.TransactionFailed: + voted_frac = "NaN" + + try: + log.info('[hybrid_casper] Vote percent: {:.4%} - Deposits: {} - Recommended Source: {}'.format( + float(voted_frac), + casper.get_total_curdyn_deposits(), + casper.get_recommended_source_epoch(), + )) + log.info('[hybrid_casper] Current epoch: {} - Current dynasty: {} - Start dynasty: {} - End dynasty: {}'.format( + casper.get_current_epoch(), + casper.get_dynasty(), + casper.get_validators__start_dynasty(validator_index), + casper.get_validators__end_dynasty(validator_index), + )) is_justified = casper.get_votes__is_justified(casper.get_current_epoch()) is_finalized = casper.get_votes__is_finalized(casper.get_current_epoch()-1) if is_justified: - log.info('Justified epoch: {}'.format(casper.get_current_epoch())) + log.info('[hybrid_casper] Justified epoch: {}'.format(casper.get_current_epoch())) if is_finalized: - log.info('Finalized epoch: {}'.format(casper.get_current_epoch()-1)) - except: - log.info('&&& Vote frac failed') + log.info('[hybrid_casper] Finalized epoch: {}'.format(casper.get_current_epoch()-1)) + except tester.TransactionFailed as e: + log.info('[hybrid_casper] Casper contract call failed: {}'.format(e)) - # Vote # Generate vote messages and broadcast if possible vote_msg = self.generate_vote_message() if vote_msg: vote_tx = self.mk_vote_tx(vote_msg) - self.chainservice.broadcast_transaction(vote_tx) - log.info('Sent vote! Tx: {}'.format(str(vote_tx))) - - def get_recommended_casper_msg_contents(self, casper, validator_index): - current_epoch = casper.get_current_epoch() - if current_epoch == 0: - return None, None, None - # NOTE: Using `epoch_blockhash` because currently calls to `blockhash` within contracts - # in the ephemeral state are off by one, so we can't use `get_recommended_target_hash()` :( - target_hash = self.epoch_blockhash(current_epoch) - source_epoch = casper.get_recommended_source_epoch() - return target_hash, current_epoch, source_epoch - - def epoch_blockhash(self, epoch): - if epoch == 0: - return b'\x00' * 32 - return self.chain.get_block_by_number(epoch*self.epoch_length-1).hash + log.info('[hybrid_casper] Broadcasting vote: {}'.format(str(vote_tx))) + self.chainservice.add_transaction(vote_tx) + else: + log.info('[hybrid_casper] Not voting this round') def is_logged_in(self, casper, target_epoch, validator_index): start_dynasty = casper.get_validators__start_dynasty(validator_index) @@ -142,78 +176,103 @@ def is_logged_in(self, casper, target_epoch, validator_index): past_dynasty = current_dynasty - 1 in_current_dynasty = ((start_dynasty <= current_dynasty) and (current_dynasty < end_dynasty)) in_prev_dynasty = ((start_dynasty <= past_dynasty) and (past_dynasty < end_dynasty)) - if not (in_current_dynasty or in_prev_dynasty): - return False - return True + return (in_current_dynasty or in_prev_dynasty) def generate_vote_message(self): state = self.chain.state.ephemeral_clone() - epoch = state.block_number // self.epoch_length - # TODO: Add logic which waits until a specific blockheight before submitting vote, something like: - # if state.block_number % self.epoch_length < 1: - # return None + target_epoch = state.block_number // self.epoch_length + # Wait until a specific blockheight before submitting vote + wait_blocks = self.epoch_length // 4 + epoch_block_number = state.block_number % self.epoch_length + if epoch_block_number < wait_blocks: + log.info('[hybrid_casper] Waiting for epoch block {} to submit vote, now block {}'.format(wait_blocks, epoch_block_number)) + return None # NO_DBL_VOTE: Don't vote if we have already - if epoch in self.votes: + if target_epoch in self.votes: + log.info('[hybrid_casper] Already voted for this epoch as target ({}), not voting again'.format(target_epoch)) return None # Create a Casper contract which we can use to get related values casper = tester.ABIContract(tester.State(state), casper_utils.casper_abi, self.chain.casper_address) # Get the ancestry hash and source ancestry hash validator_index = self.get_validator_index(state) - target_hash, epoch, source_epoch = self.get_recommended_casper_msg_contents(casper, validator_index) + target_hash, target_epoch, source_epoch = self.get_recommended_casper_msg_contents(casper, validator_index) if target_hash is None: + log.info('[hybrid_casper] Failed to get target hash, not voting') return None - # Prevent NO_SURROUND slash - if epoch < self.latest_target_epoch or source_epoch < self.latest_source_epoch: + # Prevent NO_SURROUND slash. Note that this is a little over-conservative and thus suboptimal. + # It strictly only allows votes for later sources and targets than we've already voted for. + # It avoids voting in (rare) cases where it would be safe to do so, e.g., with an earlier + # source _and_ an earlier target. + if target_epoch < self.latest_target_epoch or source_epoch < self.latest_source_epoch: + log.info('[hybrid_casper] Not voting to avoid NO_SURROUND slash') return None - # Verify that we are either in the current dynasty or prev dynasty -- assert in_current_dynasty or in_prev_dynasty - if not self.is_logged_in(casper, epoch, validator_index): - log.info('Validator not logged in yet!') + # Assert that we are logged in + if not self.is_logged_in(casper, target_epoch, validator_index): + log.info('[hybrid_casper] Validator not logged in, not voting') return None - vote_msg = casper_utils.mk_vote(validator_index, target_hash, epoch, source_epoch, self.coinbase.privkey) + vote_msg = casper_utils.mk_vote(validator_index, target_hash, target_epoch, source_epoch, self.coinbase.privkey) # Save the vote message we generated - self.votes[epoch] = vote_msg - self.latest_target_epoch = epoch + self.votes[target_epoch] = vote_msg + self.latest_target_epoch = target_epoch self.latest_source_epoch = source_epoch - log.info('Vote submitted: validator %d - epoch %d - source_epoch %d - hash %s' % - (self.get_validator_index(state), epoch, source_epoch, utils.encode_hex(target_hash))) + log.info('[hybrid_casper] Generated vote: validator %d - epoch %d - source_epoch %d - hash %s' % + (self.get_validator_index(state), target_epoch, source_epoch, utils.encode_hex(target_hash))) return vote_msg + def get_recommended_casper_msg_contents(self, casper, validator_index): + current_epoch = casper.get_current_epoch() + if current_epoch == 0: + return None, None, None + # NOTE: Using `epoch_blockhash` because currently calls to `blockhash` within contracts + # in the ephemeral state are off by one, so we can't use `get_recommended_target_hash()` :( + target_hash = self.epoch_blockhash(current_epoch) + source_epoch = casper.get_recommended_source_epoch() + return target_hash, current_epoch, source_epoch + + def epoch_blockhash(self, epoch): + if epoch == 0: + return b'\x00' * 32 + return self.chain.get_block_by_number(epoch*self.epoch_length-1).hash + def get_validator_index(self, state): t = tester.State(state.ephemeral_clone()) casper = tester.ABIContract(t, casper_utils.casper_abi, self.chain.casper_address) if self.valcode_addr is None: raise Exception('Valcode address not set') try: - return casper.get_validator_indexes(self.coinbase.address) + vidx = casper.get_validator_indexes(self.coinbase.address) except tester.TransactionFailed: return None + # Zero represents failure + return None if vidx == 0 else vidx + def mk_transaction(self, to=b'\x00' * 20, value=0, data=b'', - gasprice=tester.GASPRICE, startgas=tester.STARTGAS, nonce=None): - if nonce is None: - nonce = self.chain.state.get_nonce(self.coinbase.address) + gasprice=tester.GASPRICE, startgas=tester.STARTGAS): + nonce = self.chain.state.get_nonce(self.coinbase.address) tx = transactions.Transaction(nonce, gasprice, startgas, to, value, data) self.coinbase.sign_tx(tx) return tx - def mk_validation_code_tx(self, nonce): - valcode_tx = self.mk_transaction('', 0, casper_utils.mk_validation_code(self.coinbase.address), nonce=nonce) - return valcode_tx + def mk_validation_code_tx(self): + return self.mk_transaction('', 0, casper_utils.mk_validation_code(self.coinbase.address)) - def mk_deposit_tx(self, value, valcode_addr, nonce): + def mk_deposit_tx(self, value, valcode_addr): casper_ct = abi.ContractTranslator(casper_utils.casper_abi) deposit_func = casper_ct.encode('deposit', [valcode_addr, self.coinbase.address]) - deposit_tx = self.mk_transaction(self.chain.casper_address, value, deposit_func, nonce=nonce) - return deposit_tx + return self.mk_transaction(self.chain.casper_address, value, deposit_func) + + def mk_withdraw_tx(self, validator_idx): + casper_ct = abi.ContractTranslator(casper_utils.casper_abi) + withdraw_func = casper_ct.encode('withdraw', [validator_idx]) + return self.mk_transaction(self.chain.casper_address, data=withdraw_func) def mk_logout_tx(self, login_logout_msg): casper_ct = abi.ContractTranslator(casper_utils.casper_abi) logout_func = casper_ct.encode('logout', [login_logout_msg]) - logout_tx = self.mk_transaction(self.chain.casper_address, data=logout_func) - return logout_tx + return self.mk_transaction(self.chain.casper_address, data=logout_func) def mk_vote_tx(self, vote_msg): casper_ct = abi.ContractTranslator(casper_utils.casper_abi) vote_func = casper_ct.encode('vote', [vote_msg]) - vote_tx = self.mk_transaction(to=self.chain.casper_address, value=0, startgas=1000000, data=vote_func) - return vote_tx + return self.mk_transaction(to=self.chain.casper_address, value=0, startgas=1000000, data=vote_func)