From 831ec31c35963cca6c13e97cd90f9c861ee2f92e Mon Sep 17 00:00:00 2001 From: Haris Angelidakis <64154020+harisang@users.noreply.github.com> Date: Thu, 9 Nov 2023 17:50:17 +0200 Subject: [PATCH] Add settlement contract buffers value monitoring test (#75) This PR adds a test that monitors the value of buffers by using price feeds from ethplorer and coingecko, while restricting the tokens that are checked to the ones included in one of the following lists (whichever can be recovered at the time of the check): - "http://t2crtokens.eth.link" - "https://tokens.1inch.eth.link" - "https://tokenlist.aave.eth.link" It checks the value of buffers every `BUFFER_INTERVAL` settlements, a constant currently set to `150`, and if the value is above `BUFFER_VALUE_THRESHOLD` USD, a constant currently set to `200,000`, it generates an alert. The purpose of this PR is to get a better understanding of how quickly our buffers' value increases, since this might be relevant if we want to reduce the bonding pool size requirements. --------- Co-authored-by: Felix Henneke --- src/apis/coingeckoapi.py | 45 +++++++++ src/apis/tokenlistapi.py | 49 +++++++++ src/constants.py | 6 ++ src/daemon.py | 4 + .../buffers_monitoring_test.py | 99 +++++++++++++++++++ 5 files changed, 203 insertions(+) create mode 100644 src/apis/coingeckoapi.py create mode 100644 src/apis/tokenlistapi.py create mode 100644 src/monitoring_tests/buffers_monitoring_test.py diff --git a/src/apis/coingeckoapi.py b/src/apis/coingeckoapi.py new file mode 100644 index 0000000..b3b50c3 --- /dev/null +++ b/src/apis/coingeckoapi.py @@ -0,0 +1,45 @@ +""" +CoingeckoAPI for fetching the price in usd of a given token. +""" +# pylint: disable=logging-fstring-interpolation + +from typing import Optional +import requests +from src.helper_functions import get_logger +from src.constants import ( + header, + REQUEST_TIMEOUT, +) + + +class CoingeckoAPI: + """ + Class for fetching token prices from Coingecko. + """ + + def __init__(self) -> None: + self.logger = get_logger() + + def get_token_price_in_usd(self, address: str) -> Optional[float]: + """ + Returns the Coingecko price in usd of the given token. + """ + coingecko_url = ( + "https://api.coingecko.com/api/v3/simple/token_price/ethereum?contract_addresses=" + + address + + "&vs_currencies=usd" + ) + try: + coingecko_data = requests.get( + coingecko_url, + headers=header, + timeout=REQUEST_TIMEOUT, + ) + coingecko_rsp = coingecko_data.json() + coingecko_price_in_usd = float(coingecko_rsp[address]["usd"]) + except requests.RequestException as err: + self.logger.warning( + f"Connection error while fetching Coingecko price for token {address}, error: {err}" + ) + return None + return coingecko_price_in_usd diff --git a/src/apis/tokenlistapi.py b/src/apis/tokenlistapi.py new file mode 100644 index 0000000..6b1ad31 --- /dev/null +++ b/src/apis/tokenlistapi.py @@ -0,0 +1,49 @@ +""" +TokenListAPI for fetching a curated token list. +""" +# pylint: disable=logging-fstring-interpolation +from typing import Optional +import requests +from src.helper_functions import get_logger +from src.constants import ( + header, + REQUEST_TIMEOUT, +) + + +class TokenListAPI: + """ + Class for fetching a curated token list. + """ + + def __init__(self) -> None: + self.logger = get_logger() + self.token_lists = [ + "http://t2crtokens.eth.link", + "https://tokens.1inch.eth.link", + "https://tokenlist.aave.eth.link", + ] + + def get_token_list(self) -> Optional[list[str]]: + """ + Returns a token list. + """ + token_list: list[str] = [] + for url in self.token_lists: + try: + data = requests.get( + url, + headers=header, + timeout=REQUEST_TIMEOUT, + ) + rsp = data.json() + if "tokens" in rsp: + for token in rsp["tokens"]: + token_list.append(token["address"].lower()) + except requests.RequestException as err: + self.logger.warning( + f"Exception while fetching a token list: {err}" + ) + if len(token_list) > 0: + return token_list + return None diff --git a/src/constants.py b/src/constants.py index 87b8039..4b3c1ee 100644 --- a/src/constants.py +++ b/src/constants.py @@ -25,6 +25,12 @@ # cap parameter, per CIP-20, measured in ETH CAP_PARAMETER = 0.01 +# number of tx hashes before a new buffers check is ran +BUFFER_INTERVAL = 150 + +# threshold of value of buffers above which an alert is generated +BUFFERS_VALUE_USD_THRESHOLD = 200000 + # threshold parameter to generate an alert when receiving kickbacks KICKBACKS_ALERT_THRESHOLD = 0.03 diff --git a/src/daemon.py b/src/daemon.py index d40854f..9b956e4 100644 --- a/src/daemon.py +++ b/src/daemon.py @@ -27,6 +27,9 @@ from src.monitoring_tests.mev_blocker_kickbacks_test import ( MEVBlockerRefundsMonitoringTest, ) +from src.monitoring_tests.buffers_monitoring_test import ( + BuffersMonitoringTest, +) from src.constants import SLEEP_TIME_IN_SEC @@ -44,6 +47,7 @@ def main() -> None: PartialFillCostCoverageTest(), CostCoveragePerSolverTest(), MEVBlockerRefundsMonitoringTest(), + BuffersMonitoringTest(), ] start_block: Optional[int] = None diff --git a/src/monitoring_tests/buffers_monitoring_test.py b/src/monitoring_tests/buffers_monitoring_test.py new file mode 100644 index 0000000..050bd17 --- /dev/null +++ b/src/monitoring_tests/buffers_monitoring_test.py @@ -0,0 +1,99 @@ +""" +Checks the value of buffers every 150 settlements by invoking +the ehtplorer api, and in some cases, coingecko. +""" +# pylint: disable=logging-fstring-interpolation +import requests +from src.monitoring_tests.base_test import BaseTest +from src.apis.coingeckoapi import CoingeckoAPI +from src.apis.tokenlistapi import TokenListAPI +from src.constants import ( + BUFFER_INTERVAL, + header, + REQUEST_TIMEOUT, + BUFFERS_VALUE_USD_THRESHOLD, +) + + +class BuffersMonitoringTest(BaseTest): + """ + This test checks the value of the settlement contract buffers + every 150 settlements and generates an alert if it is higher than 200_000 USD. + Price feeds from ethplorer and coingecko (as backup) are used. + """ + + def __init__(self) -> None: + super().__init__() + self.coingecko_api = CoingeckoAPI() + self.tokenlist_api = TokenListAPI() + self.counter: int = 0 + + def compute_buffers_value(self) -> bool: + """ + Evaluates current state of buffers. + """ + # get all token balances of the smart contract + try: + ethplorer_data = requests.get( + "https://api.ethplorer.io/getAddressInfo/" + + "0x9008D19f58AAbD9eD0D60971565AA8510560ab41?apiKey=freekey", + headers=header, + timeout=REQUEST_TIMEOUT, + ) + ethplorer_rsp = ethplorer_data.json() + if "tokens" not in ethplorer_rsp: + return False + token_list = self.tokenlist_api.get_token_list() + if token_list is None: + return False + + value_in_usd = 0.0 + for token in ethplorer_rsp["tokens"]: + if token["tokenInfo"]["address"] not in token_list: + continue + balance = token["balance"] + decimals = int(token["tokenInfo"]["decimals"]) + if token["tokenInfo"]["price"] is not False: + price_in_usd = token["tokenInfo"]["price"]["rate"] + token_buffer_value_in_usd = ( + balance / 10**decimals + ) * price_in_usd + # in case some price is way off and it blows a lot the total value held in the + # smart contract we use a second price feed, from coingecko, to correct in case + # the initial price is indeed off + if token_buffer_value_in_usd > 10000: + coingecko_price_in_usd = ( + self.coingecko_api.get_token_price_in_usd( + token["tokenInfo"]["address"] + ) + ) + coingecko_value_in_usd = ( + balance / 10**decimals + ) * coingecko_price_in_usd + if coingecko_value_in_usd < token_buffer_value_in_usd: + token_buffer_value_in_usd = coingecko_value_in_usd + value_in_usd += token_buffer_value_in_usd + log_output = f"Buffer value is {value_in_usd} USD" + if value_in_usd > BUFFERS_VALUE_USD_THRESHOLD: + self.alert(log_output) + else: + self.logger.info(log_output) + + except requests.RequestException as err: + self.logger.warning( + f"Connection Error while fetching buffer tokens and prices, error: {err}" + ) + return False + return True + + def run(self, tx_hash: str) -> bool: + """ + Wrapper function for the whole test. Checks if 150 settlements have been observed, + in which case it invokes the main function that checks the current value of buffers. + """ + self.counter += 1 + if self.counter > BUFFER_INTERVAL: + success = self.compute_buffers_value() + if success: + self.counter = 0 + return True