def _query_and_save_deposits( self, dbeth2: DBEth2, indices_or_pubkeys: Union[List[int], List[Eth2PubKey]], ) -> List[Eth2Deposit]: new_deposits = self.beaconchain.get_validator_deposits(indices_or_pubkeys) dbeth2.add_eth2_deposits(new_deposits) return new_deposits
def _query_services_for_validator_daily_stats( self, to_ts: Timestamp, msg_aggregator: MessagesAggregator, ) -> None: """Goes through all saved validators and sees which need to have their stats requeried""" now = ts_now() dbeth2 = DBEth2(self.database) result = dbeth2.get_validators_to_query_for_stats(up_to_ts=to_ts) for validator_index, last_ts in result: should_backoff = (now - self.last_stats_query_ts < VALIDATOR_STATS_QUERY_BACKOFF_TIME_RANGE and self.validator_stats_queried >= VALIDATOR_STATS_QUERY_BACKOFF_EVERY_N_VALIDATORS) if should_backoff: log.debug( f'Queried {self.validator_stats_queried} validators in the last ' f'{VALIDATOR_STATS_QUERY_BACKOFF_TIME_RANGE} seconds. Backing off for ' f'{VALIDATOR_STATS_QUERY_BACKOFF_TIME} seconds.', ) self.validator_stats_queried = 0 gevent.sleep(VALIDATOR_STATS_QUERY_BACKOFF_TIME) new_stats = scrape_validator_daily_stats( validator_index=validator_index, last_known_timestamp=last_ts, msg_aggregator=msg_aggregator, ) self.validator_stats_queried += 1 self.last_stats_query_ts = now if len(new_stats) != 0: dbeth2.add_validator_daily_stats(stats=new_stats)
def get_validator_daily_stats( self, validator_index: int, msg_aggregator: MessagesAggregator, from_timestamp: Optional[Timestamp] = None, to_timestamp: Optional[Timestamp] = None, ) -> List[ValidatorDailyStats]: """Gets the daily stats of an ETH2 validator by index First queries the DB for the already known stats and then if needed also scrapes the beacocha.in website for more. Saves all new entries to the DB. """ dbeth2 = DBEth2(self.database) known_stats = dbeth2.get_validator_daily_stats( validator_index=validator_index, from_ts=from_timestamp, to_ts=to_timestamp, ) last_ts = Timestamp(0) if len( known_stats) == 0 else known_stats[-1].timestamp limit_ts = to_timestamp if to_timestamp else ts_now() if limit_ts - last_ts <= DAY_IN_SECONDS: return known_stats # no need to requery if less than a day passed now = ts_now() should_backoff = (now - self.last_stats_query_ts < VALIDATOR_STATS_QUERY_BACKOFF_TIME_RANGE and self.validator_stats_queried >= VALIDATOR_STATS_QUERY_BACKOFF_EVERY_N_VALIDATORS) if should_backoff: log.debug( f'Queried {self.validator_stats_queried} validators in the last ' f'{VALIDATOR_STATS_QUERY_BACKOFF_TIME_RANGE} seconds. Backing off for ' f'{VALIDATOR_STATS_QUERY_BACKOFF_TIME} seconds.', ) self.validator_stats_queried = 0 gevent.sleep(VALIDATOR_STATS_QUERY_BACKOFF_TIME) new_stats = scrape_validator_daily_stats( validator_index=validator_index, last_known_timestamp=last_ts, msg_aggregator=msg_aggregator, ) self.validator_stats_queried += 1 self.last_stats_query_ts = now if len(new_stats) != 0: dbeth2.add_validator_daily_stats(stats=new_stats) return dbeth2.get_validator_daily_stats( validator_index=validator_index, from_ts=from_timestamp, to_ts=to_timestamp, )
def get_balances( self, addresses: List[ChecksumEthAddress], fetch_validators_for_eth1: bool, ) -> Dict[Eth2PubKey, Balance]: """ Returns a mapping of validator public key to eth balance. If fetch_validators_for_eth1 is true then each eth1 address is also checked for the validators it has deposited and the deposits are fetched. May Raise: - RemoteError from beaconcha.in api """ usd_price = Inquirer().find_usd_price(A_ETH) dbeth2 = DBEth2(self.database) balance_mapping: Dict[Eth2PubKey, Balance] = defaultdict(Balance) validators: Union[List[ValidatorID], List[Eth2Validator]] if fetch_validators_for_eth1: validators = self.fetch_eth1_validator_data(addresses) else: validators = dbeth2.get_validators() if validators == []: return {} # nothing detected pubkeys = [] index_to_pubkey = {} index_to_ownership = {} for validator in validators: # create a mapping of indices to pubkeys since the performance call returns indices if validator.index is not None: index_to_pubkey[validator.index] = validator.public_key pubkeys.append(validator.public_key) index_to_ownership[ validator.index] = validator.ownership_proportion # Get current balance of all validators. This may miss some balance if it's # in the deposit queue but it's too much work to get it right and should be # visible as soon as deposit clears the queue performance = self.beaconchain.get_performance(pubkeys) for validator_index, entry in performance.items(): pubkey = index_to_pubkey.get(validator_index) if pubkey is None: log.error( f'At eth2 get_balances could not find matching pubkey for validator index {validator_index}' ) # noqa: E501 continue # should not happen ownership_proportion = index_to_ownership.get(validator_index, ONE) amount = from_gwei(entry.balance) * ownership_proportion balance_mapping[pubkey] += Balance(amount, amount * usd_price) # noqa: E501 return balance_mapping
def get_staking_deposits( self, addresses: List[ChecksumEthAddress], ) -> List[Eth2Deposit]: """Get the eth2 deposits for all tracked validators and all validators associated with any given eth1 address. Also write them all in the DB. """ relevant_pubkeys = set() relevant_validators = set() now = ts_now() for address in addresses: range_key = f'{ETH2_DEPOSITS_PREFIX}_{address}' query_range = self.database.get_used_query_range(range_key) if query_range is not None and now - query_range[ 1] <= REQUEST_DELTA_TS: continue # recently queried, skip result = self.beaconchain.get_eth1_address_validators(address) relevant_validators.update(result) relevant_pubkeys.update([x.public_key for x in result]) self.database.update_used_query_range(range_key, Timestamp(0), now) dbeth2 = DBEth2(self.database) saved_deposits = dbeth2.get_eth2_deposits() saved_deposits_pubkeys = {x.pubkey for x in saved_deposits} new_validators = [] pubkeys_query_deposits = set() for validator in relevant_validators: if validator.public_key not in saved_deposits_pubkeys and validator.index is not None: new_validators.append( Eth2Validator( index=validator.index, public_key=validator.public_key, ownership_proportion=ONE, )) pubkeys_query_deposits.add(validator.public_key) dbeth2.add_validators(new_validators) saved_validators = dbeth2.get_validators() for saved_validator in saved_validators: if saved_validator.public_key not in saved_deposits_pubkeys: pubkeys_query_deposits.add(saved_validator.public_key) new_deposits = self._query_and_save_deposits( dbeth2, list(pubkeys_query_deposits)) result_deposits = saved_deposits + new_deposits result_deposits.sort( key=lambda deposit: (deposit.timestamp, deposit.tx_index)) return result_deposits
def fetch_eth1_validator_data( self, addresses: List[ChecksumEthAddress], ) -> List[ValidatorID]: """Query all eth1 addresses for their validators and get all corresponding deposits. Returns the list of all tracked validators. It's ValidatorID since for validators that are in the deposit queue we don't get a finalized validator index yet. So index may be missing for some validators. This is the only function that will also return validators in the deposit queue. May raise: - RemoteError """ dbeth2 = DBEth2(self.database) all_validators = [] pubkeys = set() for address in addresses: validators = self.beaconchain.get_eth1_address_validators(address) if len(validators) == 0: continue pubkeys.update([x.public_key for x in validators]) all_validators.extend(validators) # if we already have any of those validators in the DB, no need to query deposits tracked_validators = dbeth2.get_validators() tracked_pubkeys = [x.public_key for x in tracked_validators] new_validators = [ Eth2Validator(index=x.index, public_key=x.public_key, ownership_proportion=ONE) for x in validators if x.public_key not in tracked_pubkeys and x.index is not None ] dbeth2.add_validators(new_validators) self.beaconchain.get_validator_deposits( [x.public_key for x in new_validators]) for x in dbeth2.get_validators(): if x.public_key not in pubkeys: all_validators.append( ValidatorID( index=x.index, public_key=x.public_key, ownership_proportion=x.ownership_proportion, ), ) return all_validators
def get_validator_daily_stats( db: 'DBHandler', validator_index: int, msg_aggregator: MessagesAggregator, from_timestamp: Optional[Timestamp] = None, to_timestamp: Optional[Timestamp] = None, ) -> List[ValidatorDailyStats]: """Gets the daily stats of an ETH2 validator by index First queries the DB for the already known stats and then if needed also scrapes the beacocha.in website for more. Saves all new entries to the DB. """ dbeth2 = DBEth2(db) known_stats = dbeth2.get_validator_daily_stats( validator_index=validator_index, from_ts=from_timestamp, to_ts=to_timestamp, ) last_ts = Timestamp(0) if len( known_stats) == 0 else known_stats[-1].timestamp limit_ts = to_timestamp if to_timestamp else ts_now() if limit_ts - last_ts <= DAY_IN_SECONDS: return known_stats # no need to requery if less than a day passed new_stats = _scrape_validator_daily_stats( validator_index=validator_index, last_known_timestamp=last_ts, msg_aggregator=msg_aggregator, ) if len(new_stats) != 0: dbeth2.add_validator_daily_stats(validator_index=validator_index, stats=new_stats) return dbeth2.get_validator_daily_stats( validator_index=validator_index, from_ts=from_timestamp, to_ts=to_timestamp, )
def get_validator_daily_stats( self, filter_query: Eth2DailyStatsFilterQuery, only_cache: bool, msg_aggregator: MessagesAggregator, ) -> Tuple[List[ValidatorDailyStats], int, FVal, FVal]: """Gets the daily stats eth2 validators depending on the given filter. This won't detect new validators Will query for new validator daily stats if only_cache is False. May raise: - RemoteError due to problems with beaconcha.in """ if only_cache is False: self._query_services_for_validator_daily_stats( to_ts=filter_query.to_ts, msg_aggregator=msg_aggregator, ) dbeth2 = DBEth2(self.database) return dbeth2.get_validator_daily_stats_and_limit_info(filter_query=filter_query)
def get_eth2_staking_deposits( ethereum: 'EthereumManager', addresses: List[ChecksumEthAddress], msg_aggregator: MessagesAggregator, database: 'DBHandler', ) -> List[Eth2Deposit]: """Get the addresses' ETH2 staking deposits For any given new address query on-chain from the ETH2 deposit contract deployment timestamp until now. For any existing address query on-chain from the minimum last used query range "end_ts" (among all the existing addresses) until now, as long as the difference between both is gte than REQUEST_DELTA_TS. Then write in DB all the new deposits and finally return them all. """ new_deposits: List[Eth2Deposit] = [] new_addresses: List[ChecksumEthAddress] = [] existing_addresses: List[ChecksumEthAddress] = [] to_ts = ts_now() min_from_ts = to_ts # Get addresses' last used query range for ETH2 deposits for address in addresses: entry_name = f'{ETH2_DEPOSITS_PREFIX}_{address}' deposits_range = database.get_used_query_range(name=entry_name) if not deposits_range: new_addresses.append(address) else: existing_addresses.append(address) min_from_ts = min(min_from_ts, deposits_range[1]) # Get deposits for new addresses if new_addresses: deposits_ = _get_eth2_staking_deposits_onchain( ethereum=ethereum, addresses=new_addresses, msg_aggregator=msg_aggregator, from_ts=ETH2_DEPLOYED_TS, to_ts=to_ts, ) new_deposits.extend(deposits_) for address in new_addresses: entry_name = f'{ETH2_DEPOSITS_PREFIX}_{address}' database.update_used_query_range( name=entry_name, start_ts=ETH2_DEPLOYED_TS, end_ts=to_ts, ) # Get new deposits for existing addresses if existing_addresses and min_from_ts + REQUEST_DELTA_TS <= to_ts: deposits_ = _get_eth2_staking_deposits_onchain( ethereum=ethereum, addresses=existing_addresses, msg_aggregator=msg_aggregator, from_ts=Timestamp(min_from_ts), to_ts=to_ts, ) new_deposits.extend(deposits_) for address in existing_addresses: entry_name = f'{ETH2_DEPOSITS_PREFIX}_{address}' database.update_used_query_range( name=entry_name, start_ts=Timestamp(min_from_ts), end_ts=to_ts, ) dbeth2 = DBEth2(database) # Insert new deposits in DB if new_deposits: dbeth2.add_eth2_deposits(new_deposits) # Fetch all DB deposits for the given addresses deposits: List[Eth2Deposit] = [] for address in addresses: db_deposits = dbeth2.get_eth2_deposits(address=address) deposits.extend(db_deposits) deposits.sort(key=lambda deposit: (deposit.timestamp, deposit.log_index)) return deposits
def test_validator_daily_stats_with_db_interaction( # pylint: disable=unused-argument # noqa: E501 price_historian, database, function_scope_messages_aggregator, eth2, ): stats_call_patch = patch( 'requests.get', wraps=requests.get, ) validator_index = 33710 public_key = '0x9882b4c33c0d5394205b12d62952c50fe03c6c9fe08faa36425f70afb7caac0689dcd981af35d0d03defb8286d50911d' # noqa: E501 dbeth2 = DBEth2(database) dbeth2.add_validators([ Eth2Validator( index=validator_index, public_key=public_key, ownership_proportion=ONE, ), ]) with stats_call_patch as stats_call: filter_query = Eth2DailyStatsFilterQuery.make( validators=[validator_index], from_ts=1613606300, to_ts=1614038500, ) stats, filter_total_found, sum_pnl, sum_usd_value = eth2.get_validator_daily_stats( filter_query=filter_query, only_cache=False, msg_aggregator=function_scope_messages_aggregator, ) assert stats_call.call_count == 1 assert len(stats) >= 6 assert filter_total_found >= 6 expected_stats = [ ValidatorDailyStats( validator_index=validator_index, timestamp=1613606400, # 2021/02/18 start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=FVal('0.00784'), start_amount=FVal('32.66'), end_amount=FVal('32.67'), missed_attestations=1, ), ValidatorDailyStats( validator_index=validator_index, timestamp=1613692800, # 2021/02/19 start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=FVal('0.00683'), start_amount=FVal('32.67'), end_amount=FVal('32.68'), missed_attestations=19, ), ValidatorDailyStats( validator_index=validator_index, timestamp=1613779200, # 2021/02/20 start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=FVal('0.00798'), start_amount=FVal('32.68'), end_amount=FVal('32.68'), ), ValidatorDailyStats( validator_index=validator_index, timestamp=1613865600, # 2021/02/21 start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=FVal('0.01114'), start_amount=FVal('32.68'), end_amount=FVal('32.69'), missed_attestations=3, proposed_blocks=1, ), ValidatorDailyStats( validator_index=validator_index, timestamp=1613952000, # 2021/02/22 start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=FVal('0.00782'), start_amount=FVal('32.69'), end_amount=FVal('32.7'), missed_attestations=1, ), ValidatorDailyStats( validator_index=validator_index, timestamp=1614038400, # 2021/02/23 start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=FVal('0.00772'), start_amount=FVal('32.7'), end_amount=FVal('32.71'), missed_attestations=1, ) ] assert stats[:len(expected_stats)] == expected_stats assert sum_pnl >= sum(x.pnl for x in expected_stats) assert sum_usd_value >= sum(x.pnl * ((x.start_usd_price + x.end_usd_price) / 2) for x in expected_stats) # noqa: E501 # Make sure that calling it again does not make an external call stats, filter_total_found, _, _ = eth2.get_validator_daily_stats( filter_query=filter_query, only_cache=False, msg_aggregator=function_scope_messages_aggregator, ) assert stats_call.call_count == 1 assert stats[:len(expected_stats)] == expected_stats # Check that changing ownership proportion works dbeth2.edit_validator( validator_index=validator_index, ownership_proportion=FVal(0.45), ) stats, filter_total_found, _, _ = eth2.get_validator_daily_stats( filter_query=filter_query, only_cache=False, msg_aggregator=function_scope_messages_aggregator, ) last_stat = stats[:len(expected_stats)][-1] assert last_stat.pnl_balance.amount == expected_stats[ -1].pnl_balance.amount * FVal(0.45)
def test_get_validators_to_query_for_stats(database): db = DBEth2(database) now = ts_now() assert db.get_validators_to_query_for_stats(now) == [] db.add_validators([ Eth2Validator(index=1, public_key='0xfoo1', ownership_proportion=ONE) ]) assert db.get_validators_to_query_for_stats(now) == [(1, 0)] db.add_validator_daily_stats([ ValidatorDailyStats( validator_index=1, timestamp=1607126400, start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=ZERO, start_amount=ZERO, end_amount=FVal(32), deposits_number=1, amount_deposited=FVal(32), ), ValidatorDailyStats( validator_index=1, timestamp=1607212800, start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=ZERO, start_amount=FVal(32), end_amount=FVal(32), ) ]) assert db.get_validators_to_query_for_stats(now) == [(1, 1607212800)] # now add a daily stats entry closer than a day in the past and see we don't query anything db.add_validator_daily_stats([ ValidatorDailyStats( validator_index=1, timestamp=now - 3600, start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=ZERO, start_amount=ZERO, end_amount=FVal(32), deposits_number=1, amount_deposited=FVal(32), ) ]) assert db.get_validators_to_query_for_stats(now) == [] # Now add multiple validators and daily stats and assert on result db.add_validators([ Eth2Validator(index=2, public_key='0xfoo2', ownership_proportion=ONE), Eth2Validator(index=3, public_key='0xfoo3', ownership_proportion=ONE), Eth2Validator(index=4, public_key='0xfoo4', ownership_proportion=ONE), ]) db.add_validator_daily_stats([ ValidatorDailyStats( validator_index=3, timestamp=1607126400, start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=ZERO, start_amount=ZERO, end_amount=FVal(32), deposits_number=1, amount_deposited=FVal(32), ), ValidatorDailyStats( validator_index=3, timestamp=1617512800, start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=ZERO, start_amount=FVal(32), end_amount=FVal(32), ), ValidatorDailyStats( validator_index=4, timestamp=1617512800, start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=ZERO, start_amount=FVal(32), end_amount=FVal(32), ), ValidatorDailyStats( validator_index=4, timestamp=now - 7200, start_usd_price=FVal(1.55), end_usd_price=FVal(1.55), pnl=ZERO, start_amount=FVal(32), end_amount=FVal(32), ) ]) assert db.get_validators_to_query_for_stats(now) == [(2, 0), (3, 1617512800)] assert db.get_validators_to_query_for_stats(1617512800 + 100000) == [ (2, 0), (3, 1617512800) ]
def add_validator( self, validator_index: Optional[int], public_key: Optional[Eth2PubKey], ownership_proportion: FVal, ) -> None: """Adds the given validator to the DB. Due to marshmallow here at least either validator_index or public key is not None. May raise: - RemoteError if there is a problem with querying beaconcha.in for more info - InputError if the validator is already in the DB """ valid_index: int valid_pubkey: Eth2PubKey dbeth2 = DBEth2(self.database) if self.premium is None: tracked_validators = dbeth2.get_validators() if len(tracked_validators) >= FREE_VALIDATORS_LIMIT: raise PremiumPermissionError( f'Adding validator {validator_index} {public_key} would take you ' f'over the free limit of {FREE_VALIDATORS_LIMIT} for tracked validators', ) if validator_index is not None and public_key is not None: field = 'validator_index' valid_index = validator_index valid_pubkey = public_key if dbeth2.validator_exists(field=field, arg=valid_index): raise InputError( f'Validator {valid_index} already exists in the DB') else: # we are missing one of the 2 if validator_index is None: field = 'public_key' arg = public_key else: # we should have valid index field = 'validator_index' arg = validator_index # type: ignore if dbeth2.validator_exists(field=field, arg=arg): # type: ignore raise InputError(f'Validator {arg} already exists in the DB') # at this point we gotta query for one of the two result = self.beaconchain._query( module='validator', endpoint=None, encoded_args=arg, # type: ignore ) if not isinstance(result, dict): raise RemoteError( f'Validator data for {arg} could not be found. Likely invalid validator.' ) try: valid_index = result['validatorindex'] valid_pubkey = Eth2PubKey(result['pubkey']) except KeyError as e: msg = str(e) if isinstance(e, KeyError): msg = f'Missing key entry for {msg}.' raise RemoteError( f'Failed to query beaconcha.in for validator data due to: {msg}' ) from e # noqa: E501 # by now we have a valid index and pubkey. Add to DB dbeth2.add_validators([ Eth2Validator( index=valid_index, public_key=valid_pubkey, ownership_proportion=ownership_proportion, ), ])
def get_details( self, addresses: List[ChecksumEthAddress], ) -> List[ValidatorDetails]: """Go through the list of eth1 addresses and find all eth2 validators associated with them along with their details. May raise RemoteError due to beaconcha.in API""" indices = [] index_to_address = {} index_to_pubkey = {} pubkey_to_index = {} result = [] assert self.beaconchain.db is not None, 'Beaconchain db should be populated' address_validators = [] for address in addresses: validators = self.beaconchain.get_eth1_address_validators(address) for validator in validators: if validator.index is None: # for validators that are so early in the depositing queue that no # validator index is confirmed yet let's return only the most basic info result.append( ValidatorDetails( validator_index=None, public_key=validator.public_key, eth1_depositor=address, performance=DEPOSITING_VALIDATOR_PERFORMANCE, )) continue index_to_address[validator.index] = address address_validators.append( Eth2Validator(index=validator.index, public_key=validator.public_key, ownership_proportion=ONE)) # noqa: E501 # make sure all validators we deal with are saved in the DB dbeth2 = DBEth2(self.database) dbeth2.add_validators(address_validators) # Also get all manually input validators all_validators = dbeth2.get_validators() saved_deposits = dbeth2.get_eth2_deposits() pubkey_to_deposit = {x.pubkey: x for x in saved_deposits} validators_to_query_for_deposits = [] for v in all_validators: index_to_pubkey[v.index] = v.public_key pubkey_to_index[v.public_key] = v.index indices.append(v.index) depositor = index_to_address.get(v.index) if depositor is None: if v.public_key not in pubkey_to_deposit: validators_to_query_for_deposits.append(v.public_key) # Get new deposits if needed, and populate index_to_address new_deposits = self._query_and_save_deposits( dbeth2, validators_to_query_for_deposits) for deposit in saved_deposits + new_deposits: index = pubkey_to_index.get(Eth2PubKey(deposit.pubkey)) if index is None: # should never happen, unless returned data is off log.error( f'At eth2 staking details could not find index for pubkey ' f'{deposit.pubkey} at deposit {deposit}.', ) continue index_to_address[index] = deposit.from_address # Get current balance of all validator indices performance_result = self.beaconchain.get_performance(indices) for validator_index, entry in performance_result.items(): depositor = index_to_address.get(validator_index) if depositor is None: # should never happen, unless returned data is off log.error( f'At eth2 staking details could not find depositor for index ' f'{validator_index} at index_to_address', ) continue result.append( ValidatorDetails( validator_index=validator_index, public_key=index_to_pubkey[validator_index], eth1_depositor=depositor, performance=entry, )) # Performance call does not return validators that are not active and are still depositing depositing_indices = set(index_to_address.keys()) - set( performance_result.keys()) for index in depositing_indices: depositor = index_to_address.get(index) if depositor is None: # should never happen, unless returned data is off log.error( f'At eth2 staking details could not find depositor for index ' f'{index} at index_to_address for depositing indices', ) continue result.append( ValidatorDetails( validator_index=index, public_key=index_to_pubkey[index], eth1_depositor=depositor, performance=DEPOSITING_VALIDATOR_PERFORMANCE, )) return result