diff --git a/src/telliot_feeds/cli/commands/conditional.py b/src/telliot_feeds/cli/commands/conditional.py new file mode 100644 index 00000000..65ad54cc --- /dev/null +++ b/src/telliot_feeds/cli/commands/conditional.py @@ -0,0 +1,152 @@ +from typing import Optional +from typing import TypeVar + +import click +from click.core import Context +from telliot_core.cli.utils import async_run + +from telliot_feeds.cli.utils import common_options +from telliot_feeds.cli.utils import common_reporter_options +from telliot_feeds.cli.utils import get_accounts_from_name +from telliot_feeds.cli.utils import reporter_cli_core +from telliot_feeds.feeds import CATALOG_FEEDS +from telliot_feeds.reporters.customized.conditional_reporter import ConditionalReporter +from telliot_feeds.utils.cfg import check_endpoint +from telliot_feeds.utils.cfg import setup_config +from telliot_feeds.utils.log import get_logger + +logger = get_logger(__name__) +T = TypeVar("T") + + +@click.group() +def conditional_reporter() -> None: + """Report data on-chain.""" + pass + + +@conditional_reporter.command() +@common_options +@common_reporter_options +@click.option( + "-pc", + "--percent-change", + help="Price change percentage for triggering a report. Default=0.01 (1%)", + type=float, + default=0.01, +) +@click.option( + "-st", + "--stale-timeout", + help="Triggers a report when the oracle value is stale. Default=85500 (23.75 hours)", + type=int, + default=85500, +) +@click.pass_context +@async_run +async def conditional( + ctx: Context, + tx_type: int, + gas_limit: int, + max_fee_per_gas: Optional[float], + priority_fee_per_gas: Optional[float], + base_fee_per_gas: Optional[float], + legacy_gas_price: Optional[int], + expected_profit: str, + submit_once: bool, + wait_period: int, + password: str, + min_native_token_balance: float, + stake: float, + account_str: str, + check_rewards: bool, + gas_multiplier: int, + max_priority_fee_range: int, + percent_change: float, + stale_timeout: int, + query_tag: str, + unsafe: bool, +) -> None: + """Report values to Tellor oracle if certain conditions are met.""" + click.echo("Starting Conditional Reporter...") + ctx.obj["ACCOUNT_NAME"] = account_str + ctx.obj["SIGNATURE_ACCOUNT_NAME"] = None + if query_tag is None: + raise click.UsageError("--query-tag (-qt) is required. Use --help for a list of feeds with API support.") + datafeed = CATALOG_FEEDS.get(query_tag) + if datafeed is None: + raise click.UsageError(f"Invalid query tag: {query_tag}, enter a valid query tag with API support, use --help") + + accounts = get_accounts_from_name(account_str) + if not accounts: + return + chain_id = accounts[0].chains[0] + ctx.obj["CHAIN_ID"] = chain_id # used in reporter_cli_core + # Initialize telliot core app using CLI context + async with reporter_cli_core(ctx) as core: + + core._config, account = setup_config(core.config, account_name=account_str, unsafe=unsafe) + + endpoint = check_endpoint(core._config) + + if not endpoint or not account: + click.echo("Accounts and/or endpoint unset.") + click.echo(f"Account: {account}") + click.echo(f"Endpoint: {core._config.get_endpoint()}") + return + + # Make sure current account is unlocked + if not account.is_unlocked: + account.unlock(password) + + click.echo("Reporter settings:") + click.echo(f"Max tolerated price change: {percent_change * 100}%") + click.echo(f"Value considered stale after: {stale_timeout} seconds") + click.echo(f"Transaction type: {tx_type}") + click.echo(f"Transaction type: {tx_type}") + click.echo(f"Gas Limit: {gas_limit}") + click.echo(f"Legacy gas price (gwei): {legacy_gas_price}") + click.echo(f"Max fee (gwei): {max_fee_per_gas}") + click.echo(f"Priority fee (gwei): {priority_fee_per_gas}") + click.echo(f"Desired stake amount: {stake}") + click.echo(f"Minimum native token balance (e.g. ETH if on Ethereum mainnet): {min_native_token_balance}") + click.echo("\n") + + _ = input("Press [ENTER] to confirm settings.") + + contracts = core.get_tellor360_contracts() + + common_reporter_kwargs = { + "endpoint": core.endpoint, + "account": account, + "datafeed": datafeed, + "gas_limit": gas_limit, + "max_fee_per_gas": max_fee_per_gas, + "priority_fee_per_gas": priority_fee_per_gas, + "base_fee_per_gas": base_fee_per_gas, + "legacy_gas_price": legacy_gas_price, + "chain_id": core.config.main.chain_id, + "wait_period": wait_period, + "oracle": contracts.oracle, + "autopay": contracts.autopay, + "token": contracts.token, + "expected_profit": expected_profit, + "stake": stake, + "transaction_type": tx_type, + "min_native_token_balance": int(min_native_token_balance * 10**18), + "check_rewards": check_rewards, + "gas_multiplier": gas_multiplier, + "max_priority_fee_range": max_priority_fee_range, + } + + reporter = ConditionalReporter( + stale_timeout=stale_timeout, + max_price_change=percent_change, + **common_reporter_kwargs, + ) + + if submit_once: + reporter.wait_period = 0 + await reporter.report(report_count=1) + else: + await reporter.report() diff --git a/src/telliot_feeds/cli/main.py b/src/telliot_feeds/cli/main.py index 2d6be747..0432f317 100644 --- a/src/telliot_feeds/cli/main.py +++ b/src/telliot_feeds/cli/main.py @@ -9,6 +9,7 @@ from telliot_feeds.cli.commands.account import account from telliot_feeds.cli.commands.catalog import catalog +from telliot_feeds.cli.commands.conditional import conditional from telliot_feeds.cli.commands.config import config from telliot_feeds.cli.commands.integrations import integrations from telliot_feeds.cli.commands.liquity import liquity @@ -51,6 +52,7 @@ def main( main.add_command(liquity) main.add_command(request_withdraw) main.add_command(withdraw) +main.add_command(conditional) if __name__ == "__main__": main() diff --git a/src/telliot_feeds/reporters/customized/conditional_reporter.py b/src/telliot_feeds/reporters/customized/conditional_reporter.py new file mode 100644 index 00000000..1803c135 --- /dev/null +++ b/src/telliot_feeds/reporters/customized/conditional_reporter.py @@ -0,0 +1,145 @@ +import asyncio +from dataclasses import dataclass +from typing import Any +from typing import Optional +from typing import TypeVar + +from web3 import Web3 + +from telliot_feeds.feeds import DataFeed +from telliot_feeds.reporters.tellor_360 import Tellor360Reporter +from telliot_feeds.utils.log import get_logger +from telliot_feeds.utils.reporter_utils import current_time + +logger = get_logger(__name__) +T = TypeVar("T") + + +@dataclass +class GetDataBefore: + retrieved: bool + value: bytes + timestampRetrieved: int + + +@dataclass +class ConditionalReporter(Tellor360Reporter): + """Backup Reporter that inherits from Tellor360Reporter and + implements conditions when intended as backup to chainlink""" + + def __init__( + self, + stale_timeout: int, + max_price_change: float, + datafeed: Optional[DataFeed[Any]] = None, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.stale_timeout = stale_timeout + self.max_price_change = max_price_change + self.datafeed = datafeed + + async def get_tellor_latest_data(self) -> Optional[GetDataBefore]: + """Get latest data from tellor oracle (getDataBefore with current time) + + Returns: + - Optional[GetDataBefore]: latest data from tellor oracle + """ + if self.datafeed is None: + logger.debug(f"no datafeed set: {self.datafeed}") + return None + data, status = await self.oracle.read("getDataBefore", self.datafeed.query.query_id, current_time()) + if not status.ok: + logger.warning(f"error getting tellor data: {status.e}") + return None + return GetDataBefore(*data) + + async def get_telliot_feed_data(self, datafeed: DataFeed[Any]) -> Optional[float]: + """Fetch spot price data from API sources and calculate a value + Returns: + - Optional[GetDataBefore]: latest data from tellor oracle + """ + v, _ = await datafeed.source.fetch_new_datapoint() + logger.info(f"telliot feeds value: {v}") + return v + + def tellor_price_change_above_max( + self, tellor_latest_data: GetDataBefore, telliot_feed_data: Optional[float] + ) -> bool: + """Check if spot price change since last report is above max price deviation + params: + - tellor_latest_data: latest data from tellor oracle + - telliot_feed_data: latest data from API sources + + Returns: + - bool: True if price change is above max price deviation, False otherwise + """ + oracle_price = (Web3.toInt(tellor_latest_data.value)) / 10**18 + feed_price = telliot_feed_data if telliot_feed_data else None + + if feed_price is None: + logger.warning("No feed data available") + return False + + min_price = min(oracle_price, feed_price) + max_price = max(oracle_price, feed_price) + logger.info(f"Latest Tellor price = {oracle_price}") + percent_change = (max_price - min_price) / max_price + logger.info(f"feed price change = {percent_change}") + if percent_change > self.max_price_change: + logger.info("Feed price change above max") + return True + else: + return False + + async def conditions_met(self) -> bool: + """Trigger methods to check conditions if reporting spot is necessary + + Returns: + - bool: True if conditions are met, False otherwise + """ + logger.info("checking conditions and reporting if necessary") + if self.datafeed is None: + logger.info(f"no datafeed was setß: {self.datafeed}. Please provide a spot-price query type (see --help)") + return False + tellor_latest_data = await self.get_tellor_latest_data() + telliot_feed_data = await self.get_telliot_feed_data(datafeed=self.datafeed) + time = current_time() + time_passed_since_tellor_report = time - tellor_latest_data.timestampRetrieved if tellor_latest_data else time + if tellor_latest_data is None: + logger.debug("tellor data returned None") + return True + elif not tellor_latest_data.retrieved: + logger.debug(f"No oracle submissions in tellor for query: {self.datafeed.query.descriptor}") + return True + elif time_passed_since_tellor_report > self.stale_timeout: + logger.debug(f"tellor data is stale, time elapsed since last report: {time_passed_since_tellor_report}") + return True + elif self.tellor_price_change_above_max(tellor_latest_data, telliot_feed_data): + logger.debug("tellor price change above max") + return True + else: + logger.debug(f"tellor {self.datafeed.query.descriptor} data is recent enough") + return False + + async def report(self, report_count: Optional[int] = None) -> None: + """Submit values to Tellor oracles on an interval.""" + + while report_count is None or report_count > 0: + online = await self.is_online() + if online: + if self.has_native_token(): + if await self.conditions_met(): + _, _ = await self.report_once() + else: + logger.info("feeds are recent enough, no need to report") + + else: + logger.warning("Unable to connect to the internet!") + + logger.info(f"Sleeping for {self.wait_period} seconds") + await asyncio.sleep(self.wait_period) + + if report_count is not None: + report_count -= 1 diff --git a/src/telliot_feeds/sources/price/spot/coingecko.py b/src/telliot_feeds/sources/price/spot/coingecko.py index 3a021934..2653292c 100644 --- a/src/telliot_feeds/sources/price/spot/coingecko.py +++ b/src/telliot_feeds/sources/price/spot/coingecko.py @@ -49,7 +49,7 @@ "uni": "uniswap", "usdt": "tether", "yfi": "yearn-finance", - "steth": "staked-ether", + "steth": "lido-staked-ether", "reth": "rocket-pool-eth", "op": "optimism", "grt": "the-graph", diff --git a/tests/conftest.py b/tests/conftest.py index 020b50f4..2133b832 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -158,8 +158,8 @@ def mumbai_test_cfg(): @pytest.fixture(scope="function", autouse=True) -def goerli_test_cfg(): - return local_node_cfg(chain_id=5) +def sepolia_test_cfg(): + return local_node_cfg(chain_id=11155111) @pytest.fixture(scope="function", autouse=True) diff --git a/tests/reporters/test_conditional_reporter.py b/tests/reporters/test_conditional_reporter.py new file mode 100644 index 00000000..89d1f057 --- /dev/null +++ b/tests/reporters/test_conditional_reporter.py @@ -0,0 +1,92 @@ +from contextlib import ExitStack +from unittest.mock import patch + +import pytest + +from telliot_feeds.feeds import eth_usd_median_feed +from telliot_feeds.reporters.customized.conditional_reporter import ConditionalReporter +from telliot_feeds.reporters.customized.conditional_reporter import GetDataBefore +from tests.utils.utils import chain_time + + +@pytest.fixture(scope="function") +async def reporter(tellor_360, guaranteed_price_source): + contracts, account = tellor_360 + feed = eth_usd_median_feed + feed.source = guaranteed_price_source + + return ConditionalReporter( + oracle=contracts.oracle, + token=contracts.token, + autopay=contracts.autopay, + endpoint=contracts.oracle.node, + account=account, + chain_id=80001, + transaction_type=0, + min_native_token_balance=0, + datafeed=feed, + check_rewards=False, + stale_timeout=100, + max_price_change=0.5, + wait_period=0, + ) + + +module = "telliot_feeds.reporters.customized.conditional_reporter." + + +@pytest.mark.asyncio +async def test_tellor_data_none(reporter, caplog): + """Test when tellor data is None""" + r = await reporter + + def patch_tellor_data_return(return_value): + return patch(f"{module}ConditionalReporter.get_tellor_latest_data", return_value=return_value) + + with patch_tellor_data_return(None): + await r.report(report_count=1) + assert "tellor data returned None" in caplog.text + + +@pytest.mark.asyncio +async def test_tellor_data_not_retrieved(reporter, caplog): + """Test when tellor data is None""" + r = await reporter + + def patch_tellor_data_not_retrieved(return_value): + return patch(f"{module}ConditionalReporter.get_tellor_latest_data", return_value=return_value) + + with patch_tellor_data_not_retrieved(GetDataBefore(False, b"", 0)): + await r.report(report_count=1) + assert "No oracle submissions in tellor for query" in caplog.text + + +@pytest.mark.asyncio +async def test_tellor_data_is_stale(reporter, caplog): + """Test when tellor data is None""" + r = await reporter + + def patch_tellor_data_is_stale(return_value): + return patch(f"{module}ConditionalReporter.get_tellor_latest_data", return_value=return_value) + + with patch_tellor_data_is_stale(GetDataBefore(True, b"", 0)): + await r.report(report_count=1) + assert "tellor data is stale, time elapsed since last report" in caplog.text + + +@pytest.mark.asyncio +async def test_tellor_price_change_above_max(reporter, chain, caplog): + r = await reporter + + tellor_latest_data = GetDataBefore(True, b"", chain_time(chain)) + telliot_feed_data = 1 + chain.mine(1, timedelta=1) + with ExitStack() as stack: + stack.enter_context(patch(f"{module}current_time", new=lambda: chain_time(chain))) + stack.enter_context( + patch(f"{module}ConditionalReporter.get_tellor_latest_data", return_value=tellor_latest_data) + ) + stack.enter_context(patch(f"{module}ConditionalReporter.get_telliot_feed_data", return_value=telliot_feed_data)) + await r.report(report_count=1) + assert "tellor price change above max" in caplog.text + assert "Sending submitValue transaction" in caplog.text