From 2552490f53c24b9195c3bd91d9a83c6dc90b920f Mon Sep 17 00:00:00 2001 From: akrem Date: Sun, 11 Jun 2023 11:24:51 -0400 Subject: [PATCH 01/28] consolidate and add stake command --- contracts/TellorX/TellorXMasterMock.sol | 13 - contracts/TellorX/TellorXOracleMock.sol | 60 -- src/telliot_feeds/cli/commands/report.py | 24 +- src/telliot_feeds/cli/commands/stake.py | 175 +++++ src/telliot_feeds/cli/main.py | 2 + src/telliot_feeds/cli/utils.py | 2 +- .../integrations/diva_protocol/report.py | 4 +- .../reporters/custom_reporter.py | 275 ------- src/telliot_feeds/reporters/flashbot.py | 143 +--- src/telliot_feeds/reporters/interval.py | 519 ------------- .../reporters/reporter_autopay_utils.py | 571 -------------- src/telliot_feeds/reporters/rng_interval.py | 23 +- src/telliot_feeds/reporters/tellor_360.py | 708 ++++++++++++++---- src/telliot_feeds/reporters/tellor_flex.py | 344 --------- src/telliot_feeds/utils/stake_info.py | 96 +++ tests/conftest.py | 20 +- tests/reporters/test_360_reporter.py | 20 +- tests/reporters/test_autopay_multicalls.py | 140 ---- tests/reporters/test_flex_reporter.py | 48 +- tests/reporters/test_interval_reporter.py | 4 +- tests/reporters/test_stake_info.py | 15 + tests/test_autopay.py | 238 ------ tests/test_bct_usd.py | 6 +- tests/test_dai_usd.py | 6 +- tests/test_numeric_api_response_feed.py | 6 +- 25 files changed, 928 insertions(+), 2534 deletions(-) delete mode 100644 contracts/TellorX/TellorXMasterMock.sol delete mode 100644 contracts/TellorX/TellorXOracleMock.sol create mode 100644 src/telliot_feeds/cli/commands/stake.py delete mode 100644 src/telliot_feeds/reporters/custom_reporter.py delete mode 100644 src/telliot_feeds/reporters/interval.py delete mode 100644 src/telliot_feeds/reporters/reporter_autopay_utils.py delete mode 100644 src/telliot_feeds/reporters/tellor_flex.py create mode 100644 src/telliot_feeds/utils/stake_info.py delete mode 100644 tests/reporters/test_autopay_multicalls.py create mode 100644 tests/reporters/test_stake_info.py delete mode 100644 tests/test_autopay.py diff --git a/contracts/TellorX/TellorXMasterMock.sol b/contracts/TellorX/TellorXMasterMock.sol deleted file mode 100644 index d58dff12..00000000 --- a/contracts/TellorX/TellorXMasterMock.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-Licence-Identifier: MIT -pragma solidity ^0.8.10; - -contract TellorXMasterMock { - - function getStakerInfo(address _staker) - external view - returns (uint256, uint256) - { - return (uint256(1), uint256(123456789)); - } - -} diff --git a/contracts/TellorX/TellorXOracleMock.sol b/contracts/TellorX/TellorXOracleMock.sol deleted file mode 100644 index 57771c64..00000000 --- a/contracts/TellorX/TellorXOracleMock.sol +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-Licence-Identifier: MIT -pragma solidity ^0.8.10; - -contract TellorXOracleMock { - function getReportTimestampByIndex(bytes32 _queryId, uint256 _index) - public - pure - returns (uint256) - { - return 1234; - } - - function getReportingLock() public pure returns (uint256) { - return 12; - } - - function getTimeBasedReward() public pure returns (uint256) { - return 1e18; - } - - function getCurrentReward(bytes32 _queryId) - public - pure - returns (uint256[2] memory) - { - return [uint256(1e18), uint256(2e18)]; - } - - function getTimestampCountById(bytes32 _queryId) - public - pure - returns (uint256) - { - return 30; - } - - function getTimeOfLastNewValue() public pure returns (uint256) { - return 123456789; - } - - function getTipsById(bytes32 _queryId) public pure returns (uint256) { - return 3e18; - } - - function getReporterLastTimestamp(address _reporter) - external - view - returns (uint256) - { - return 123456789; - } - - function submitValue( - bytes32 _queryId, - bytes calldata _value, - uint256 _nonce, - bytes memory _queryData - ) external { - } -} diff --git a/src/telliot_feeds/cli/commands/report.py b/src/telliot_feeds/cli/commands/report.py index de4dd45c..8af27ad8 100644 --- a/src/telliot_feeds/cli/commands/report.py +++ b/src/telliot_feeds/cli/commands/report.py @@ -1,5 +1,6 @@ from typing import Any from typing import Optional +from typing import Union import click from chained_accounts import find_accounts @@ -26,7 +27,6 @@ from telliot_feeds.reporters.flashbot import FlashbotsReporter from telliot_feeds.reporters.rng_interval import RNGReporter from telliot_feeds.reporters.tellor_360 import Tellor360Reporter -from telliot_feeds.reporters.tellor_flex import TellorFlexReporter from telliot_feeds.utils.cfg import check_endpoint from telliot_feeds.utils.cfg import setup_config from telliot_feeds.utils.log import get_logger @@ -226,13 +226,6 @@ def reporter() -> None: callback=validate_address, prompt=False, ) -@click.option( - "--tellor-360/--tellor-flex", - "-360/-flex", - "tellor_360", - default=True, - help="Choose between Tellor 360 or Flex contracts", -) @click.option( "--stake", "-s", @@ -317,7 +310,6 @@ async def report( custom_token_contract: Optional[ChecksumAddress], custom_oracle_contract: Optional[ChecksumAddress], custom_autopay_contract: Optional[ChecksumAddress], - tellor_360: bool, stake: float, account_str: str, signature_account: str, @@ -406,7 +398,7 @@ async def report( _ = input("Press [ENTER] to confirm settings.") - contracts = core.get_tellor360_contracts() if tellor_360 else core.get_tellorflex_contracts() + contracts = core.get_tellor360_contracts() if custom_oracle_contract: contracts.oracle.connect() # set telliot_core.contract.Contract.contract attribute @@ -467,7 +459,7 @@ async def report( "max_priority_fee_range": max_priority_fee_range, "ignore_tbr": ignore_tbr, } - + reporter: Union[FlashbotsReporter, RNGReporter, Tellor360Reporter] if sig_acct_addr: reporter = FlashbotsReporter( signature_account=sig_account, @@ -475,7 +467,7 @@ async def report( ) elif rng_auto: common_reporter_kwargs["wait_period"] = 120 if wait_period < 120 else wait_period - reporter = RNGReporter( # type: ignore + reporter = RNGReporter( **common_reporter_kwargs, ) elif reporting_diva_protocol: @@ -488,14 +480,10 @@ async def report( **common_reporter_kwargs, **diva_reporter_kwargs, # type: ignore ) - elif tellor_360: - reporter = Tellor360Reporter( - **common_reporter_kwargs, - ) # type: ignore else: - reporter = TellorFlexReporter( + reporter = Tellor360Reporter( **common_reporter_kwargs, - ) # type: ignore + ) if submit_once: _, _ = await reporter.report_once() diff --git a/src/telliot_feeds/cli/commands/stake.py b/src/telliot_feeds/cli/commands/stake.py new file mode 100644 index 00000000..3e16cfec --- /dev/null +++ b/src/telliot_feeds/cli/commands/stake.py @@ -0,0 +1,175 @@ +import click +from click.core import Context + +from eth_utils import to_checksum_address + +from typing import Optional + +from telliot_core.cli.utils import async_run + +from telliot_feeds.utils.cfg import check_endpoint +from telliot_feeds.utils.cfg import setup_config +from telliot_feeds.cli.utils import get_accounts_from_name +from telliot_feeds.cli.utils import valid_transaction_type +from telliot_feeds.cli.utils import reporter_cli_core +from telliot_feeds.utils.reporter_utils import has_native_token_funds +from telliot_feeds.reporters.tellor_360 import Tellor360Reporter + + +@click.group() +def deposit_stake() -> None: + """Deposit tokens to the Tellor oracle.""" + pass + + +@click.option( + "--account", + "-a", + "account_str", + help="Name of account used for reporting, staking, etc. More info: run `telliot account --help`", + required=True, + nargs=1, + type=str, +) +@click.option("--amount", "-amt", "amount", help="Amount of tokens to stake", nargs=1, type=float, required=True) +@click.option( + "--gas-limit", + "-gl", + "gas_limit", + help="use custom gas limit", + nargs=1, + type=int, +) +@click.option( + "--max-fee", + "-mf", + "max_fee", + help="use custom maxFeePerGas (gwei)", + nargs=1, + type=float, + required=False, +) +@click.option( + "--priority-fee", + "-pf", + "priority_fee", + help="use custom maxPriorityFeePerGas (gwei)", + nargs=1, + type=float, + required=False, +) +@click.option( + "--gas-price", + "-gp", + "legacy_gas_price", + help="use custom legacy gasPrice (gwei)", + nargs=1, + type=int, + required=False, +) +@click.option( + "--tx-type", + "-tx", + "tx_type", + help="choose transaction type (0 for legacy txs, 2 for EIP-1559)", + type=click.UNPROCESSED, + required=False, + callback=valid_transaction_type, + default=2, +) +@click.option( + "--min-native-token-balance", + "-mnb", + "min_native_token_balance", + help="Minimum native token balance required to report. Denominated in ether.", + nargs=1, + type=float, + default=0.25, +) +@click.option( + "--gas-multiplier", + "-gm", + "gas_multiplier", + help="increase gas price by this percentage (default 1%) ie 5 = 5%", + nargs=1, + type=int, + default=1, # 1% above the gas price by web3 +) +@click.option( + "--max-priority-fee-range", + "-mpfr", + "max_priority_fee_range", + help="the maximum range of priority fees to use in gwei (default 80 gwei)", + nargs=1, + type=int, + default=80, # 80 gwei +) +@click.option("-pwd", "--password", type=str) +@deposit_stake.command() +@click.pass_context +@async_run +async def stake( + ctx: Context, + account_str: str, + amount: float, + tx_type: int, + gas_limit: int, + max_fee: Optional[float], + priority_fee: Optional[float], + legacy_gas_price: Optional[int], + password: str, + min_native_token_balance: float, + gas_multiplier: int, + max_priority_fee_range: int, +) -> None: + """Deposit tokens to oracle""" + ctx.obj["ACCOUNT_NAME"] = account_str + + accounts = get_accounts_from_name(account_str) + if not accounts: + return + + ctx.obj["CHAIN_ID"] = accounts[0].chains[0] # used in reporter_cli_core + # if max_fee flag is set, then priority_fee must also be set + if (max_fee is not None and priority_fee is None) or (max_fee is None and priority_fee is not None): + raise click.UsageError("Must specify both max fee and priority fee") + # 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) + + 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) + + contracts = core.get_tellor360_contracts() + # set private key for token approval txn via token contract + contracts.token._private_key = account.local_account.privateKey + # set private key for oracle stake deposit txn + contracts.oracle._private_key = account.local_account.privateKey + + common_reporter_kwargs = { + "endpoint": core.endpoint, + "account": account, + "gas_limit": gas_limit, + "max_fee": max_fee, + "priority_fee": priority_fee, + "legacy_gas_price": legacy_gas_price, + "chain_id": core.config.main.chain_id, + "transaction_type": tx_type, + "gas_multiplier": gas_multiplier, + "max_priority_fee_range": max_priority_fee_range, + "oracle": contracts.oracle, + "autopay": contracts.autopay, + "token": contracts.token, + } + if has_native_token_funds(to_checksum_address(account.address), core.endpoint.web3, min_balance=int(min_native_token_balance * 10**18)): + _ = await Tellor360Reporter(**common_reporter_kwargs).deposit_stake(int(amount * 1e18)) diff --git a/src/telliot_feeds/cli/main.py b/src/telliot_feeds/cli/main.py index 3c5de0ad..2e141d74 100644 --- a/src/telliot_feeds/cli/main.py +++ b/src/telliot_feeds/cli/main.py @@ -14,6 +14,7 @@ from telliot_feeds.cli.commands.query import query from telliot_feeds.cli.commands.report import report from telliot_feeds.cli.commands.settle import settle +from telliot_feeds.cli.commands.stake import stake from telliot_feeds.utils.log import get_logger @@ -43,6 +44,7 @@ def main( main.add_command(integrations) main.add_command(config) main.add_command(account) +main.add_command(stake) if __name__ == "__main__": main() diff --git a/src/telliot_feeds/cli/utils.py b/src/telliot_feeds/cli/utils.py index cd3ca07b..d3e9720b 100644 --- a/src/telliot_feeds/cli/utils.py +++ b/src/telliot_feeds/cli/utils.py @@ -89,7 +89,7 @@ def reporter_cli_core(ctx: click.Context) -> TelliotCore: core = cli_core(ctx) # Ensure chain id compatible with flashbots relay - if ctx.obj["SIGNATURE_ACCOUNT_NAME"] is not None: + if ctx.obj.get("SIGNATURE_ACCOUNT_NAME") is not None: # Only supports mainnet assert core.config.main.chain_id in (1, 5) diff --git a/src/telliot_feeds/integrations/diva_protocol/report.py b/src/telliot_feeds/integrations/diva_protocol/report.py index 675fe0ed..fd0dfe5a 100644 --- a/src/telliot_feeds/integrations/diva_protocol/report.py +++ b/src/telliot_feeds/integrations/diva_protocol/report.py @@ -11,7 +11,7 @@ from telliot_core.utils.response import error_status from telliot_core.utils.response import ResponseStatus from web3 import Web3 -from web3.datastructures import AttributeDict +from web3.types import TxReceipt from telliot_feeds.datafeed import DataFeed from telliot_feeds.integrations.diva_protocol import DIVA_DIAMOND_ADDRESS @@ -215,7 +215,7 @@ async def settle_pools(self) -> ResponseStatus: async def report_once( self, - ) -> Tuple[Optional[AttributeDict[Any, Any]], ResponseStatus]: + ) -> Tuple[Optional[TxReceipt], ResponseStatus]: """Report query response to a TellorFlex oracle.""" staked, status = await self.ensure_staked() if not staked or not status.ok: diff --git a/src/telliot_feeds/reporters/custom_reporter.py b/src/telliot_feeds/reporters/custom_reporter.py deleted file mode 100644 index 76fc7d39..00000000 --- a/src/telliot_feeds/reporters/custom_reporter.py +++ /dev/null @@ -1,275 +0,0 @@ -from typing import Any -from typing import Optional -from typing import Tuple -from typing import Union - -from chained_accounts import ChainedAccount -from eth_utils import to_checksum_address -from telliot_core.contract.contract import Contract -from telliot_core.model.endpoints import RPCEndpoint -from telliot_core.utils.key_helpers import lazy_unlock_account -from telliot_core.utils.response import error_status -from telliot_core.utils.response import ResponseStatus -from web3.datastructures import AttributeDict - -from telliot_feeds.datafeed import DataFeed -from telliot_feeds.feeds.eth_usd_feed import eth_usd_median_feed -from telliot_feeds.feeds.trb_usd_feed import trb_usd_median_feed -from telliot_feeds.reporters.interval import IntervalReporter -from telliot_feeds.utils.log import get_logger - - -logger = get_logger(__name__) - - -class CustomXReporter(IntervalReporter): - """Custom reporter contract - Use by entering an abi and contract address through the command line - requires depositstake and submitValue signature functions to work - """ - - def __init__( - self, - custom_contract: Contract, - endpoint: RPCEndpoint, - account: ChainedAccount, - chain_id: int, - master: Contract, - oracle: Contract, - datafeed: Optional[DataFeed[Any]] = None, - expected_profit: Union[str, float] = 100.0, - transaction_type: int = 0, - gas_limit: Optional[int] = None, - max_fee: Optional[float] = None, - priority_fee: Optional[float] = None, - legacy_gas_price: Optional[int] = None, - ) -> None: - - self.endpoint = endpoint - self.account = account - self.master = master - self.oracle = oracle - self.datafeed = datafeed - self.chain_id = chain_id - self.acct_addr = to_checksum_address(custom_contract.address) - self.last_submission_timestamp = 0 - self.expected_profit = expected_profit - self.transaction_type = transaction_type - self.gas_limit = gas_limit - self.max_fee = max_fee - self.priority_fee = priority_fee - self.legacy_gas_price = legacy_gas_price - self.trb_usd_median_feed = trb_usd_median_feed - self.eth_usd_median_feed = eth_usd_median_feed - self.custom_contract = custom_contract - self.gas_info: dict[str, Union[float, int]] = {} - - async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: - """Make sure the current user is staked - Returns a bool signifying whether the current address is - staked. If the address is not initially, it attempts to stake with - the address's funds.""" - status = ResponseStatus() - - gas_price_gwei = await self.fetch_gas_price() - if not gas_price_gwei: - note = "Unable to fetch gas price during during ensure_staked()" - return False, error_status(note=note, log=logger.warning) - - staker_info, read_status = await self.master.read(func_name="getStakerInfo", _staker=self.acct_addr) - - if (not read_status.ok) or (staker_info is None): - msg = "Unable to read reporters staker status: " + read_status.error # error won't be none # noqa: E501 - status = error_status(msg, log=logger.info) - status.e = read_status.e - return False, status - - logger.info(f"Stake status: {staker_info[0]}") - - # Status 1: staked - if staker_info[0] == 1: - return True, status - - # Status 0: not yet staked - elif staker_info[0] == 0: - logger.info("Address not yet staked. Depositing stake.") - - _, write_status = await self.custom_contract.write( - func_name="depositStake", - gas_limit=350000, - legacy_gas_price=gas_price_gwei, - ) - - if write_status.ok: - return True, status - else: - msg = ( - "Unable to stake deposit: " - + write_status.error - + f"Make sure {self.acct_addr} has enough ETH & TRB (100)" - ) # error won't be none # noqa: E501 - return False, error_status(msg, log=logger.info) - - # Status 3: disputed - if staker_info[0] == 3: - msg = "Current address disputed. Switch address to continue reporting." # noqa: E501 - return False, error_status(msg, log=logger.info) - - # Statuses 2, 4, and 5: stake transition - else: - msg = "Current address is locked in dispute or for withdrawal." # noqa: E501 - return False, error_status(msg, log=logger.info) - - async def report_once( - self, - ) -> Tuple[Optional[AttributeDict[Any, Any]], ResponseStatus]: - """Report query value once - This method checks to see if a user is able to submit - values to the TellorX oracle, given their staker status - and last submission time. Also, this method does not - submit values if doing so won't make a profit.""" - # Check staker status - staked, status = await self.ensure_staked() - if not staked or not status.ok: - logger.warning(status.error) - return None, status - - status = await self.check_reporter_lock() - if not status.ok: - return None, status - - # Get suggested datafeed if none provided - datafeed = await self.fetch_datafeed() - if not datafeed: - msg = "Unable to suggest datafeed" - return None, error_status(note=msg, log=logger.info) - - logger.info(f"Current query: {datafeed.query.descriptor}") - # Update datafeed value - await datafeed.source.fetch_new_datapoint() - latest_data = datafeed.source.latest - if latest_data[0] is None: - msg = "Unable to retrieve updated datafeed value." - return None, error_status(msg, log=logger.info) - # Get query info & encode value to bytes - query = datafeed.query - query_id = query.query_id - query_data = query.query_data - try: - value = query.value_type.encode(latest_data[0]) - except Exception as e: - msg = f"Error encoding response value {latest_data[0]}" - return None, error_status(msg, e=e, log=logger.error) - # Get nonce - report_count, read_status = await self.get_num_reports_by_id(query_id) - if not read_status.ok: - status.error = "Unable to retrieve report count: " + read_status.error # error won't be none # noqa: E501 - logger.error(status.error) - status.e = read_status.e - return None, status - # Start transaction build - submit_val_func = self.custom_contract.contract.get_function_by_name("submitValue") - submit_val_tx = submit_val_func( - _queryId=query_id, - _value=value, - _nonce=report_count, - _queryData=query_data, - ) - # Estimate gas usage amount - gas_limit, status = self.submit_val_tx_gas_limit(submit_val_tx=submit_val_tx) - if not status.ok or gas_limit is None: - return None, status - # Set gas limit to dict - self.gas_info["gas_limit"] = gas_limit - - acc_nonce, nonce_status = self.get_acct_nonce() - if not nonce_status.ok: - return None, nonce_status - - # Add transaction type 2 (EIP-1559) data - if self.transaction_type == 2: - priority_fee, max_fee = self.get_fee_info() - if priority_fee is None or max_fee is None: - return None, error_status("Unable to suggest type 2 txn fees", log=logger.error) - - # Set gas price to max fee used for profitability check - self.gas_info["type"] = 2 - self.gas_info["max_fee"] = max_fee - self.gas_info["priority_fee"] = priority_fee - self.gas_info["base_fee"] = max_fee - priority_fee - - built_submit_val_tx = submit_val_tx.buildTransaction( - { - "nonce": acc_nonce, - "gas": gas_limit, - "maxFeePerGas": self.web3.toWei(max_fee, "gwei"), - # TODO: Investigate more why etherscan txs using Flashbots have - # the same maxFeePerGas and maxPriorityFeePerGas. Example: - # https://etherscan.io/tx/0x0bd2c8b986be4f183c0a2667ef48ab1d8863c59510f3226ef056e46658541288 # noqa: E501 - "maxPriorityFeePerGas": self.web3.toWei(priority_fee, "gwei"), # noqa: E501 - "chainId": self.chain_id, - } - ) - # Add transaction type 0 (legacy) data - else: - # Fetch legacy gas price if not provided by user - if not self.legacy_gas_price: - gas_price = await self.fetch_gas_price() - if not gas_price: - note = "Unable to fetch gas price for tx type 0" - return None, error_status(note, log=logger.warning) - else: - gas_price = self.legacy_gas_price - - self.gas_info["type"] = 0 - self.gas_info["gas_price"] = gas_price - - built_submit_val_tx = submit_val_tx.buildTransaction( - { - "nonce": acc_nonce, - "gas": gas_limit, - "gasPrice": self.web3.toWei(gas_price, "gwei"), - "chainId": self.chain_id, - } - ) - - status = await self.ensure_profitable(datafeed) - if not status.ok: - return None, status - - status = ResponseStatus() - - lazy_unlock_account(self.account) - local_account = self.account.local_account - tx_signed = local_account.sign_transaction(built_submit_val_tx) - - # Ensure reporter lock is checked again after attempting to submit val - self.last_submission_timestamp = 0 - - try: - logger.debug("Sending submitValue transaction") - tx_hash = self.web3.eth.send_raw_transaction(tx_signed.rawTransaction) - except Exception as e: - note = "Send transaction failed" - return None, error_status(note, log=logger.error, e=e) - - try: - # Confirm transaction - tx_receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash, timeout=360) - - tx_url = f"{self.endpoint.explorer}/tx/{tx_hash.hex()}" - - if tx_receipt["status"] == 0: - msg = f"Transaction reverted. ({tx_url})" - return tx_receipt, error_status(msg, log=logger.error) - - except Exception as e: - note = "Failed to confirm transaction" - return None, error_status(note, log=logger.error, e=e) - - if status.ok and not status.error: - logger.info(f"View reported data: \n{tx_url}") - else: - logger.error(status) - - return tx_receipt, status diff --git a/src/telliot_feeds/reporters/flashbot.py b/src/telliot_feeds/reporters/flashbot.py index 3858ddb3..6d15ecc2 100644 --- a/src/telliot_feeds/reporters/flashbot.py +++ b/src/telliot_feeds/reporters/flashbot.py @@ -3,7 +3,6 @@ Example of a subclassed Reporter. """ from typing import Any -from typing import Optional from typing import Tuple from chained_accounts import ChainedAccount @@ -13,9 +12,8 @@ from requests.exceptions import HTTPError from telliot_core.utils.response import error_status from telliot_core.utils.response import ResponseStatus -from web3 import Web3 -from web3.datastructures import AttributeDict from web3.exceptions import TransactionNotFound +from web3.types import TxReceipt from telliot_feeds.flashbots import flashbot # type: ignore from telliot_feeds.flashbots.provider import get_default_endpoint # type: ignore @@ -32,7 +30,7 @@ class FlashbotsReporter(Tellor360Reporter): def __init__(self, signature_account: ChainedAccount, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.account: LocalAccount = Account.from_key(self.account.key) + self.account._local_account = Account.from_key(self.account.key) self.signature_account: LocalAccount = Account.from_key(signature_account.key) self.sig_acct_addr = to_checksum_address(signature_account.address) @@ -43,131 +41,11 @@ def __init__(self, signature_account: ChainedAccount, *args: Any, **kwargs: Any) logger.info(f"Flashbots provider endpoint: {flashbots_uri}") flashbot(self.endpoint._web3, self.signature_account, flashbots_uri) - async def report_once( - self, - ) -> Tuple[Optional[AttributeDict[Any, Any]], ResponseStatus]: - """Report query value once - - This method checks to see if a user is able to submit - values to the TellorX oracle, given their staker status - and last submission time. Also, this method does not - submit values if doing so won't make a profit.""" - staked, status = await self.ensure_staked() - if not staked and status.ok: - return None, status - - status = await self.check_reporter_lock() - if not status.ok: - return None, status - - datafeed = await self.fetch_datafeed() - if datafeed is None: - return None, error_status(note="Unable to fetch datafeed", log=logger.warning) - - logger.info(f"Current query: {datafeed.query.descriptor}") - - # Update datafeed value - await datafeed.source.fetch_new_datapoint() - latest_data = datafeed.source.latest - if latest_data[0] is None: - msg = "Unable to retrieve updated datafeed value." - return None, error_status(msg, log=logger.info) - - # Get query info & encode value to bytes - query = datafeed.query - query_id = query.query_id - query_data = query.query_data - try: - value = query.value_type.encode(latest_data[0]) - except Exception as e: - msg = f"Error encoding response value {latest_data[0]}" - return None, error_status(msg, e=e, log=logger.error) - - # Get nonce - timestamp_count, read_status = await self.oracle.read(func_name="getNewValueCountbyQueryId", _queryId=query_id) - if not read_status.ok: - status.error = "Unable to retrieve newValueCount: " + read_status.error # error won't be none # noqa: E501 - logger.error(status.error) - status.e = read_status.e - return None, status - - # Start transaction build - submit_val_func = self.oracle.contract.get_function_by_name("submitValue") - submit_val_tx = submit_val_func( - _queryId=query_id, - _value=value, - _nonce=timestamp_count, - _queryData=query_data, - ) - # Estimate gas usage amount - gas_limit, status = self.submit_val_tx_gas_limit(submit_val_tx=submit_val_tx) - if not status.ok or gas_limit is None: - return None, status - - self.gas_info["gas_limit"] = gas_limit - # Get account nonce - acc_nonce, nonce_status = self.get_acct_nonce() - if not nonce_status.ok: - return None, nonce_status - - # Add transaction type 2 (EIP-1559) data - if self.transaction_type == 2: - priority_fee, max_fee = self.get_fee_info() - if priority_fee is None or max_fee is None: - return None, error_status("Unable to suggest type 2 txn fees", log=logger.error) - - logger.info(f"maxFeePerGas: {max_fee}") - logger.info(f"maxPriorityFeePerGas: {priority_fee}") - - # Set gas price to max fee used for profitability check - self.gas_info["type"] = 2 - self.gas_info["max_fee"] = max_fee - self.gas_info["priority_fee"] = priority_fee - self.gas_info["base_fee"] = max_fee - priority_fee - - built_submit_val_tx = submit_val_tx.buildTransaction( - { - "nonce": acc_nonce, - "gas": gas_limit, - "maxFeePerGas": Web3.toWei(max_fee, "gwei"), - # TODO: Investigate more why etherscan txs using Flashbots have - # the same maxFeePerGas and maxPriorityFeePerGas. Example: - # https://etherscan.io/tx/0x0bd2c8b986be4f183c0a2667ef48ab1d8863c59510f3226ef056e46658541288 # noqa: E501 - "maxPriorityFeePerGas": Web3.toWei(priority_fee, "gwei"), # noqa: E501 - "chainId": self.chain_id, - } - ) - # Add transaction type 0 (legacy) data - else: - if not self.legacy_gas_price: - gas_price = await self.fetch_gas_price() - if gas_price is None: - note = "Unable to fetch gas price for tx type 0" - return None, error_status(note, log=logger.warning) - else: - gas_price = self.legacy_gas_price - - self.gas_info["type"] = 0 - self.gas_info["gas_price"] = gas_price - built_submit_val_tx = submit_val_tx.buildTransaction( - { - "nonce": acc_nonce, - "gas": gas_limit, - "gasPrice": Web3.toWei(gas_price, "gwei"), - "chainId": self.chain_id, - } - ) - - status = await self.ensure_profitable(datafeed) - if not status.ok: - return None, status + def send_transaction(self, tx_signed) -> Tuple[TxReceipt, ResponseStatus]: status = ResponseStatus() - - submit_val_tx_signed = self.account.sign_transaction(built_submit_val_tx) # type: ignore - # Create bundle of one pre-signed, EIP-1559 (type 2) transaction bundle = [ - {"signed_transaction": submit_val_tx_signed.rawTransaction}, + {"signed_transaction": tx_signed.rawTransaction}, ] # Send bundle to be executed in the next block @@ -185,6 +63,7 @@ async def report_once( except HTTPError as e: msg = "Unable to send bundle to miners due to HTTP error" return None, error_status(note=msg, e=e, log=logger.error) + logger.info(f"Bundle sent to miners in block {block}") # Wait for transaction confirmation @@ -198,14 +77,8 @@ async def report_once( status.e = e return None, status - status = ResponseStatus() - if status.ok and not status.error: - # Reset previous submission timestamp - self.last_submission_timestamp = 0 - tx_hash = tx_receipt["transactionHash"].hex() - # Point to relevant explorer - logger.info(f"View reported data: \n{self.endpoint.explorer}/tx/{tx_hash}") - else: - logger.error(status) + tx_hash = tx_receipt["transactionHash"].hex() + # Point to relevant explorer + logger.info(f"View reported data: \n{self.endpoint.explorer}/tx/{tx_hash}") return tx_receipt, status diff --git a/src/telliot_feeds/reporters/interval.py b/src/telliot_feeds/reporters/interval.py deleted file mode 100644 index 3e9b57b4..00000000 --- a/src/telliot_feeds/reporters/interval.py +++ /dev/null @@ -1,519 +0,0 @@ -""" BTCUSD Price Reporter -Example of a subclassed Reporter. -""" -import asyncio -import time -from typing import Any -from typing import Optional -from typing import Tuple -from typing import Union - -from chained_accounts import ChainedAccount -from eth_utils import to_checksum_address -from telliot_core.contract.contract import Contract -from telliot_core.model.endpoints import RPCEndpoint -from telliot_core.utils.key_helpers import lazy_unlock_account -from telliot_core.utils.response import error_status -from telliot_core.utils.response import ResponseStatus -from web3.contract import ContractFunction -from web3.datastructures import AttributeDict - -from telliot_feeds.datafeed import DataFeed -from telliot_feeds.feeds import CATALOG_FEEDS -from telliot_feeds.feeds.eth_usd_feed import eth_usd_median_feed -from telliot_feeds.feeds.trb_usd_feed import trb_usd_median_feed -from telliot_feeds.utils.log import get_logger -from telliot_feeds.utils.reporter_utils import fee_history_priority_fee_estimate -from telliot_feeds.utils.reporter_utils import has_native_token_funds -from telliot_feeds.utils.reporter_utils import is_online -from telliot_feeds.utils.reporter_utils import tellor_suggested_report -from telliot_feeds.utils.reporter_utils import tkn_symbol - - -logger = get_logger(__name__) - - -class IntervalReporter: - """Reports values from given datafeeds to a TellorX Oracle - every 7 seconds.""" - - def __init__( - self, - endpoint: RPCEndpoint, - account: ChainedAccount, - chain_id: int, - master: Contract, - oracle: Contract, - datafeed: Optional[DataFeed[Any]] = None, - expected_profit: Union[str, float] = 100.0, - transaction_type: int = 0, - gas_limit: Optional[int] = None, - max_fee: Optional[float] = None, - priority_fee: Optional[float] = None, - legacy_gas_price: Optional[int] = None, - gas_multiplier: int = 1, - max_priority_fee_range: int = 80, # 80 gwei - wait_period: int = 10, - min_native_token_balance: int = 10**18, - ) -> None: - - self.endpoint = endpoint - self.account = account - self.master = master - self.oracle = oracle - self.datafeed = datafeed - self.chain_id = chain_id - self.acct_addr = to_checksum_address(account.address) - self.last_submission_timestamp = 0 - self.expected_profit = expected_profit - self.transaction_type = transaction_type - self.gas_limit = gas_limit - self.max_fee = max_fee - self.priority_fee = priority_fee - self.legacy_gas_price = legacy_gas_price - self.gas_multiplier = gas_multiplier - self.max_priority_fee_range = max_priority_fee_range - self.trb_usd_median_feed = trb_usd_median_feed - self.eth_usd_median_feed = eth_usd_median_feed - self.wait_period = wait_period - self.min_native_token_balance = min_native_token_balance - self.web3 = self.endpoint._web3 - - self.gas_info: dict[str, Union[float, int]] = {} - - logger.info(f"Reporting with account: {self.acct_addr}") - - async def check_reporter_lock(self) -> ResponseStatus: - """Ensure enough time has passed since last report - Returns a bool signifying whether a given address is in a - reporter lock or not (TellorX oracle users cannot submit - multiple times within 12 hours).""" - status = ResponseStatus() - - # Save last submission timestamp to reduce web3 calls - if self.last_submission_timestamp == 0: - last_timestamp, read_status = await self.oracle.read("getReporterLastTimestamp", _reporter=self.acct_addr) - - # Log web3 errors - if (not read_status.ok) or (last_timestamp is None): - status.ok = False - status.error = "Unable to retrieve reporter's last report timestamp:" + read_status.error - logger.error(status.error) - status.e = read_status.e - return status - - self.last_submission_timestamp = last_timestamp - logger.info(f"Last submission timestamp: {self.last_submission_timestamp}") - - if time.time() < self.last_submission_timestamp + 43200: # 12 hours in seconds - status.ok = False - status.error = "Current address is in reporter lock." - logger.info(status.error) - return status - - return status - - async def fetch_gas_price(self) -> Optional[float]: - """Fetches the current gas price from an EVM network and returns - an adjusted gas price. - - Returns: - An optional integer representing the adjusted gas price in wei, or - None if the gas price could not be retrieved. - """ - try: - price = self.web3.eth.gas_price - priceGwei = self.web3.fromWei(price, "gwei") - except Exception as e: - logger.error(f"Error fetching gas price: {e}") - return None - # increase gas price by 1.0 + gas_multiplier - multiplier = 1.0 + (self.gas_multiplier / 100.0) - gas_price = (float(priceGwei) * multiplier) if priceGwei else None - return gas_price - - async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: - """Make sure the current user is staked - Returns a bool signifying whether the current address is - staked. If the address is not initially, it attempts to stake with - the address's funds.""" - status = ResponseStatus() - - gas_price_gwei = await self.fetch_gas_price() - if not gas_price_gwei: - note = "Unable to fetch gas price during during ensure_staked()" - return False, error_status(note=note, log=logger.warning) - - staker_info, read_status = await self.master.read(func_name="getStakerInfo", _staker=self.acct_addr) - - if (not read_status.ok) or (staker_info is None): - msg = "Unable to read reporters staker status: " + read_status.error # error won't be none # noqa: E501 - status = error_status(msg, log=logger.info) - status.e = read_status.e - return False, status - - logger.info(f"Stake status: {staker_info[0]}") - - # Status 1: staked - if staker_info[0] == 1: - return True, status - - # Status 0: not yet staked - elif staker_info[0] == 0: - logger.info("Address not yet staked. Depositing stake.") - - _, write_status = await self.master.write( - func_name="depositStake", - gas_limit=350000, - legacy_gas_price=gas_price_gwei, - ) - - if write_status.ok: - return True, status - else: - msg = ( - "Unable to stake deposit: " - + write_status.error - + f"Make sure {self.acct_addr} has enough ETH & TRB (100)" - ) # error won't be none # noqa: E501 - return False, error_status(msg, log=logger.info) - - # Status 3: disputed - if staker_info[0] == 3: - msg = "Current address disputed. Switch address to continue reporting." # noqa: E501 - return False, error_status(msg, log=logger.info) - - # Statuses 2, 4, and 5: stake transition - else: - msg = "Current address is locked in dispute or for withdrawal." # noqa: E501 - return False, error_status(msg, log=logger.info) - - async def ensure_profitable(self, datafeed: DataFeed[Any]) -> ResponseStatus: - """Estimate profitability - - Returns a bool signifying whether submitting for a given - queryID would generate a net profit.""" - status = ResponseStatus() - - # Get current tips and time-based reward for given queryID - rewards, read_status = await self.oracle.read("getCurrentReward", _queryId=datafeed.query.query_id) - - # Log web3 errors - if (not read_status.ok) or (rewards is None): - status.ok = False - status.error = "Unable to retrieve queryID's current rewards:" + read_status.error - logger.error(status.error) - status.e = read_status.e - return status - - # Fetch token prices - price_feeds = [self.eth_usd_median_feed, self.trb_usd_median_feed] - _ = await asyncio.gather(*[feed.source.fetch_new_datapoint() for feed in price_feeds]) - - price_eth_usd = self.eth_usd_median_feed.source.latest[0] - price_trb_usd = self.trb_usd_median_feed.source.latest[0] - - if price_eth_usd is None: - note = "Unable to fetch ETH/USD price for profit calculation" - return error_status(note=note, log=logger.warning) - if price_trb_usd is None: - note = "Unable to fetch TRB/USD price for profit calculation" - return error_status(note=note, log=logger.warning) - - tips, tb_reward = rewards - - if not self.gas_info: - return error_status("Gas info not set", log=logger.warning) - - gas_info = self.gas_info - txn_fee = gas_info["gas_price"] * gas_info["gas_limit"] - - if gas_info["type"] == 0: - txn_fee = gas_info["gas_price"] * gas_info["gas_limit"] - logger.info( - f""" - - Tips: {tips / 1e18} - Time-based reward: {tb_reward / 1e18} TRB - Transaction fee: {self.web3.fromWei(txn_fee, 'gwei'):.09f} {tkn_symbol(self.chain_id)} - Gas price: {gas_info["gas_price"]} gwei - Gas limit: {gas_info["gas_limit"]} - Txn type: 0 (Legacy) - """ - ) - if gas_info["type"] == 2: - txn_fee = gas_info["max_fee"] * gas_info["gas_limit"] - logger.info( - f""" - - Tips: {tips / 1e18} - Time-based reward: {tb_reward / 1e18} TRB - Max transaction fee: {self.web3.fromWei(txn_fee, 'gwei')} {tkn_symbol(self.chain_id)} - Max fee per gas: {gas_info["max_fee"]} gwei - Max priority fee per gas: {gas_info["priority_fee"]} gwei - Gas limit: {gas_info["gas_limit"]} - Txn type: 2 (EIP-1559) - """ - ) - - # Calculate profit - revenue = tb_reward + tips - rev_usd = revenue / 1e18 * price_trb_usd - costs_usd = txn_fee / 1e9 * price_eth_usd - profit_usd = rev_usd - costs_usd - logger.info(f"Estimated profit: ${round(profit_usd, 2)}") - - percent_profit = ((profit_usd) / costs_usd) * 100 - logger.info(f"Estimated percent profit: {round(percent_profit, 2)}%") - - if (self.expected_profit != "YOLO") and (percent_profit < self.expected_profit): - status.ok = False - status.error = "Estimated profitability below threshold." - logger.info(status.error) - return status - - return status - - def get_fee_info(self) -> Tuple[Optional[float], Optional[float]]: - """Calculate max fee and priority fee if not set - for more info: - https://web3py.readthedocs.io/en/v5/web3.eth.html?highlight=fee%20history#web3.eth.Eth.fee_history - """ - if self.max_fee is None: - try: - fee_history = self.web3.eth.fee_history( - block_count=5, newest_block="latest", reward_percentiles=[25, 50, 75] - ) - # "base fee for the next block after the newest of the returned range" - base_fee = fee_history.baseFeePerGas[-1] / 1e9 - # estimate priority fee from fee history - priority_fee_max = int(self.max_priority_fee_range * 1e9) # convert to wei - priority_fee = fee_history_priority_fee_estimate(fee_history, priority_fee_max=priority_fee_max) / 1e9 - max_fee = base_fee + priority_fee - return priority_fee, max_fee - except Exception as e: - logger.warning(f"Error in calculating gas fees: {e}") - return None, None - return self.priority_fee, self.max_fee - - async def fetch_datafeed(self) -> Optional[DataFeed[Any]]: - if self.datafeed is None: - suggested_qtag = await tellor_suggested_report(self.oracle) - if suggested_qtag is None: - logger.warning("Could not get suggested query") - return None - self.datafeed = CATALOG_FEEDS[suggested_qtag] - - return self.datafeed - - async def get_num_reports_by_id(self, query_id: bytes) -> Tuple[int, ResponseStatus]: - count, read_status = await self.oracle.read(func_name="getNewValueCountbyQueryId", _queryId=query_id) - return count, read_status - - async def is_online(self) -> bool: - return await is_online() - - def has_native_token(self) -> bool: - """Check if account has native token funds for a network for gas fees - of at least min_native_token_balance that is set in the cli""" - return has_native_token_funds(self.acct_addr, self.web3, min_balance=self.min_native_token_balance) - - def get_acct_nonce(self) -> tuple[Optional[int], ResponseStatus]: - """Get transaction count for an address""" - try: - return self.web3.eth.get_transaction_count(self.acct_addr), ResponseStatus() - except ValueError as e: - return None, error_status("Account nonce request timed out", e=e, log=logger.warning) - except Exception as e: - return None, error_status("Unable to retrieve account nonce", e=e, log=logger.error) - - # Estimate gas usage and set the gas limit if not provided - def submit_val_tx_gas_limit(self, submit_val_tx: ContractFunction) -> tuple[Optional[int], ResponseStatus]: - """Estimate gas usage for submitValue transaction - Args: - submit_val_tx: The submitValue transaction object - Returns a tuple of the gas limit and a ResponseStatus object""" - if self.gas_limit is None: - try: - gas_limit: int = submit_val_tx.estimateGas({"from": self.acct_addr}) - if not gas_limit: - return None, error_status("Unable to estimate gas for submitValue transaction") - return gas_limit, ResponseStatus() - except Exception as e: - msg = "Unable to estimate gas for submitValue transaction" - return None, error_status(msg, e=e, log=logger.error) - return self.gas_limit, ResponseStatus() - - async def report_once( - self, - ) -> Tuple[Optional[AttributeDict[Any, Any]], ResponseStatus]: - """Report query value once - This method checks to see if a user is able to submit - values to the TellorX oracle, given their staker status - and last submission time. Also, this method does not - submit values if doing so won't make a profit.""" - # Check staker status - staked, status = await self.ensure_staked() - if not staked or not status.ok: - logger.warning(status.error) - return None, status - - status = await self.check_reporter_lock() - if not status.ok: - return None, status - - # Get suggested datafeed if none provided - datafeed = await self.fetch_datafeed() - if not datafeed: - msg = "Unable to suggest datafeed" - return None, error_status(note=msg, log=logger.info) - - logger.info(f"Current query: {datafeed.query.descriptor}") - - # Update datafeed value - await datafeed.source.fetch_new_datapoint() - latest_data = datafeed.source.latest - if latest_data[0] is None: - msg = "Unable to retrieve updated datafeed value." - return None, error_status(msg, log=logger.info) - - # Get query info & encode value to bytes - query = datafeed.query - query_id = query.query_id - query_data = query.query_data - try: - value = query.value_type.encode(latest_data[0]) - logger.debug(f"IntervalReporter Encoded value: {value.hex()}") - except Exception as e: - msg = f"Error encoding response value {latest_data[0]}" - return None, error_status(msg, e=e, log=logger.error) - - # Get nonce - report_count, read_status = await self.get_num_reports_by_id(query_id) - - if not read_status.ok: - status.error = "Unable to retrieve report count: " + read_status.error # error won't be none # noqa: E501 - logger.error(status.error) - status.e = read_status.e - return None, status - - # Start transaction build - submit_val_func = self.oracle.contract.get_function_by_name("submitValue") - submit_val_tx = submit_val_func( - _queryId=query_id, - _value=value, - _nonce=report_count, - _queryData=query_data, - ) - # Estimate gas usage amount - gas_limit, status = self.submit_val_tx_gas_limit(submit_val_tx=submit_val_tx) - if not status.ok or gas_limit is None: - return None, status - - self.gas_info["gas_limit"] = gas_limit - # Get account nonce - acc_nonce, nonce_status = self.get_acct_nonce() - if not nonce_status.ok: - return None, nonce_status - # Add transaction type 2 (EIP-1559) data - if self.transaction_type == 2: - priority_fee, max_fee = self.get_fee_info() - if priority_fee is None or max_fee is None: - return None, error_status("Unable to suggest type 2 txn fees", log=logger.error) - # Set gas price to max fee used for profitability check - self.gas_info["type"] = 2 - self.gas_info["max_fee"] = max_fee - self.gas_info["priority_fee"] = priority_fee - self.gas_info["base_fee"] = max_fee - priority_fee - - built_submit_val_tx = submit_val_tx.buildTransaction( - { - "nonce": acc_nonce, - "gas": gas_limit, - "maxFeePerGas": self.web3.toWei(max_fee, "gwei"), - # TODO: Investigate more why etherscan txs using Flashbots have - # the same maxFeePerGas and maxPriorityFeePerGas. Example: - # https://etherscan.io/tx/0x0bd2c8b986be4f183c0a2667ef48ab1d8863c59510f3226ef056e46658541288 # noqa: E501 - "maxPriorityFeePerGas": self.web3.toWei(priority_fee, "gwei"), # noqa: E501 - "chainId": self.chain_id, - } - ) - # Add transaction type 0 (legacy) data - else: - # Fetch legacy gas price if not provided by user - if not self.legacy_gas_price: - gas_price = await self.fetch_gas_price() - if not gas_price: - note = "Unable to fetch gas price for tx type 0" - return None, error_status(note, log=logger.warning) - - else: - gas_price = self.legacy_gas_price - # Set gas price to legacy gas price used for profitability check - self.gas_info["type"] = 0 - self.gas_info["gas_price"] = gas_price - built_submit_val_tx = submit_val_tx.buildTransaction( - { - "nonce": acc_nonce, - "gas": gas_limit, - "gasPrice": self.web3.toWei(gas_price, "gwei"), - "chainId": self.chain_id, - } - ) - - # Check if profitable if not YOLO - status = await self.ensure_profitable(datafeed) - if not status.ok: - return None, status - - lazy_unlock_account(self.account) - local_account = self.account.local_account - tx_signed = local_account.sign_transaction(built_submit_val_tx) - - # Ensure reporter lock is checked again after attempting to submit val - self.last_submission_timestamp = 0 - - try: - logger.debug("Sending submitValue transaction") - tx_hash = self.web3.eth.send_raw_transaction(tx_signed.rawTransaction) - except Exception as e: - note = "Send transaction failed" - return None, error_status(note, log=logger.error, e=e) - - try: - # Confirm transaction - tx_receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash, timeout=360) - - tx_url = f"{self.endpoint.explorer}/tx/{tx_hash.hex()}" - - if tx_receipt["status"] == 0: - msg = f"Transaction reverted. ({tx_url})" - return tx_receipt, error_status(msg, log=logger.error) - - except Exception as e: - note = "Failed to confirm transaction" - return None, error_status(note, log=logger.error, e=e) - - if status.ok and not status.error: - logger.info(f"View reported data: \n{tx_url}") - else: - logger.error(status) - - return tx_receipt, status - - 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(): - _, _ = await self.report_once() - 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/reporters/reporter_autopay_utils.py b/src/telliot_feeds/reporters/reporter_autopay_utils.py deleted file mode 100644 index c88ec206..00000000 --- a/src/telliot_feeds/reporters/reporter_autopay_utils.py +++ /dev/null @@ -1,571 +0,0 @@ -""" -Uses Python's interface from https://github.com/banteg/multicall.py.git for MakerDAO's multicall contract. -Multicall contract helps reduce node calls by combining contract function calls -and returning the values all together. This is helpful especially if API nodes like Infura are being used. -""" -import math -from dataclasses import dataclass -from typing import Any -from typing import Dict -from typing import List -from typing import Optional -from typing import Tuple - -from clamfig.base import Registry -from eth_abi import decode_single -from multicall import Call -from multicall import Multicall -from telliot_core.tellor.tellorflex.autopay import TellorFlexAutopayContract -from telliot_core.utils.response import error_status -from telliot_core.utils.timestamp import TimeStamp -from web3.exceptions import ContractLogicError -from web3.main import Web3 - -from telliot_feeds.feeds import CATALOG_FEEDS -from telliot_feeds.queries.query_catalog import query_catalog -from telliot_feeds.reporters.tips import CATALOG_QUERY_IDS -from telliot_feeds.utils.log import get_logger - -logger = get_logger(__name__) - - -# chains where autopay contract is deployed -AUTOPAY_CHAINS = ( - 137, - 80001, - 69, - 1666600000, - 1666700000, - 421611, - 42161, - 10200, - 100, - 10, - 420, - 421613, - 3141, - 314159, - 314, - 11155111, -) - - -@dataclass -class Tag: - query_tag: str - feed_id: str - - -@dataclass -class FeedDetails: - """Data types for feed details contract response""" - - reward: int - balance: int - startTime: int - interval: int - window: int - priceThreshold: int - feedsWithFundingIndex: int - - -class AutopayCalls: - def __init__(self, autopay: TellorFlexAutopayContract, catalog: Dict[bytes, str] = CATALOG_QUERY_IDS): - self.autopay = autopay - self.w3: Web3 = autopay.node._web3 - self.catalog = catalog - - async def get_current_feeds(self, require_success: bool = False) -> Optional[Dict[str, Any]]: - """ - Getter for: - - feed ids list for each query id in catalog - - a report's timestamp index from oracle for current timestamp and three months ago - (used for getting all timestamps for the past three months) - - Reason of why three months: reporters can't claim tips from funded feeds past three months - getting three months of timestamp is useful to determine if there will be a balance after every eligible - timestamp claims a tip thus draining the feeds balance as a result - - Return: - - {'tag': (feed_id_bytes,), ('tag', 'current_time'): index_at_timestamp, - ('tag', 'three_mos_ago'): index_at_timestamp} - """ - calls = [] - current_time = TimeStamp.now().ts - three_mos_ago = current_time - 7889238 # 3 months in seconds - for query_id, tag in self.catalog.items(): - if "legacy" in tag or "spot" in tag: - calls.append( - Call( - self.autopay.address, - ["getCurrentFeeds(bytes32)(bytes32[])", query_id], - [[tag, None]], - ) - ) - calls.append( - Call( - self.autopay.address, - ["getIndexForDataBefore(bytes32,uint256)(bool,uint256)", query_id, current_time], - [["disregard_boolean", None], [(tag, "current_time"), None]], - ) - ) - calls.append( - Call( - self.autopay.address, - ["getIndexForDataBefore(bytes32,uint256)(bool,uint256)", query_id, three_mos_ago], - [["disregard_boolean", None], [(tag, "three_mos_ago"), None]], - ) - ) - data = await safe_multicall(calls, self.w3, require_success) - if not data: - return None - try: - data.pop("disregard_boolean") - except KeyError as e: - msg = f"No feeds returned by multicall, KeyError: {e}" - logger.warning(msg) - return data - - async def get_feed_details(self, require_success: bool = False) -> Any: - """ - Getter for: - - timestamps for three months of reports to oracle using queryId and index - - feed details of all feedIds for every queryId - - current values from oracle for every queryId in catalog (used to determine - can submit now in eligible window) - - Return: - - {('current_feeds', 'tag', 'feed_id'): [feed_details], ('current_values', 'tag'): True, - ('current_values', 'tag', 'current_price'): float(price), - ('current_values', 'tag', 'timestamp'): 1655137179} - """ - - current_feeds = await self.get_current_feeds() - - if not current_feeds: - logger.info("No available feeds") - return None - - # separate items from current feeds - # multicall for multiple different functions returns different types of data at once - # ie the response is {"tag": (feedids, ), ('trb-usd-spot', 'current_time'): 0, - # ('trb-usd-spot', 'three_mos_ago'): 0,eth-jpy-spot: (),'eth-jpy-spot', 'current_time'): 0, - # ('eth-jpy-spot', 'three_mos_ago'): 0} - # here we filter out the tag key if it is string and its value is of length > 0 - - tags_with_feed_ids = { - tag: feed_id - for tag, feed_id in current_feeds.items() - if (not isinstance(tag, tuple) and (current_feeds[tag])) - } - idx_current: List[int] = [] # indices for every query id reports' current timestamps - idx_three_mos_ago: List[int] = [] # indices for every query id reports' three months ago timestamps - tags: List[str] = [] # query tags from catalog - for key in current_feeds: - if isinstance(key, tuple) and key[0] in tags_with_feed_ids: - if key[1] == "current_time": - idx_current.append(current_feeds[key]) - tags.append((key[0], tags_with_feed_ids[key[0]])) - else: - idx_three_mos_ago.append(current_feeds[key]) - - merged_indices = list(zip(idx_current, idx_three_mos_ago)) - merged_query_idx = dict(zip(tags, merged_indices)) - - get_timestampby_query_id_n_idx_call = [] - - tag: str - for (tag, _), (end, start) in merged_query_idx.items(): - if start and end: - for idx in range(start, end): - - get_timestampby_query_id_n_idx_call.append( - Call( - self.autopay.address, - [ - "getTimestampbyQueryIdandIndex(bytes32,uint256)(uint256)", - query_catalog._entries[tag].query.query_id, - idx, - ], - [[(tag, idx), None]], - ) - ) - - def _to_list(_: bool, val: Any) -> List[Any]: - """Helper function, converts feed_details from tuple to list""" - return list(val) - - get_data_feed_call = [] - - feed_ids: List[bytes] - for tag, feed_ids in merged_query_idx: - for feed_id in feed_ids: - c = Call( - self.autopay.address, - ["getDataFeed(bytes32)((uint256,uint256,uint256,uint256,uint256,uint256,uint256))", feed_id], - [[("current_feeds", tag, feed_id.hex()), _to_list]], - ) - - get_data_feed_call.append(c) - - get_current_values_call = [] - - for tag, _ in merged_query_idx: - - c = Call( - self.autopay.address, - ["getCurrentValue(bytes32)(bool,bytes,uint256)", query_catalog._entries[tag].query.query_id], - [ - [("current_values", tag), None], - [("current_values", tag, "current_price"), None], - [("current_values", tag, "timestamp"), None], - ], - ) - - get_current_values_call.append(c) - - calls = get_data_feed_call + get_current_values_call + get_timestampby_query_id_n_idx_call - return await safe_multicall(calls, self.w3, require_success) - - async def reward_claim_status( - self, require_success: bool = False - ) -> Tuple[Optional[Dict[Any, Any]], Optional[Dict[Any, Any]], Optional[Dict[Any, Any]]]: - """ - Getter that checks if a timestamp's tip has been claimed - """ - feed_details_before_check = await self.get_feed_details() - if not feed_details_before_check: - logger.info("No feeds balance to check") - return None, None, None - # create a key to use for the first timestamp since it doesn't have a before value that needs to be checked - feed_details_before_check[(0, 0)] = 0 - timestamp_before_key = (0, 0) - - feeds = {} - current_values = {} - for i, j in feed_details_before_check.items(): - if "current_feeds" in i: - feeds[i] = j - elif "current_values" in i: - current_values[i] = j - - reward_claimed_status_call = [] - for _, tag, feed_id in feeds: - details = FeedDetails(*feeds[(_, tag, feed_id)]) - for keys in list(feed_details_before_check): - if "current_feeds" not in keys and "current_values" not in keys: - if tag in keys: - is_first = _is_timestamp_first_in_window( - feed_details_before_check[timestamp_before_key], - feed_details_before_check[keys], - details.startTime, - details.window, - details.interval, - ) - timestamp_before_key = keys - if is_first: - reward_claimed_status_call.append( - Call( - self.autopay.address, - [ - "getRewardClaimedStatus(bytes32,bytes32,uint256)(bool)", - bytes.fromhex(feed_id), - query_catalog._entries[tag].query.query_id, - feed_details_before_check[keys], - ], - [[(tag, feed_id, feed_details_before_check[keys]), None]], - ) - ) - - data = await safe_multicall(reward_claimed_status_call, self.w3, require_success) - if data is not None: - return feeds, current_values, data - else: - return None, None, None - - async def get_current_tip(self, require_success: bool = False) -> Optional[Dict[str, Any]]: - """ - Return response from autopay getCurrenTip call. - Default value for require_success is False because AutoPay returns an - error if tip amount is zero. - """ - calls = [ - Call(self.autopay.address, ["getCurrentTip(bytes32)(uint256)", query_id], [[self.catalog[query_id], None]]) - for query_id in self.catalog - ] - return await safe_multicall(calls, self.w3, require_success) - - -async def get_feed_tip(query: bytes, autopay: TellorFlexAutopayContract) -> Optional[int]: - """ - Get total tips for a query id with funded feeds - - - query: if the query exists in the telliot queries catalog then input should be the query id, - otherwise input should be the query data for newly generated ids in order to determine if submission - for the query is supported by telliot - """ - if not autopay.connect().ok: - msg = "can't suggest feed, autopay contract not connected" - error_status(note=msg, log=logger.critical) - return None - - if query in CATALOG_QUERY_IDS: - query_id = query - single_query = {query_id: CATALOG_QUERY_IDS[query_id]} - else: - try: - query_data = query - qtype_name, _ = decode_single("(string,bytes)", query_data) - except OverflowError: - logger.warning("Query data not available to decode") - return None - if qtype_name not in Registry.registry: - logger.warning(f"Unsupported query type: {qtype_name}") - return None - else: - query_id = Web3.keccak(query_data) - CATALOG_QUERY_IDS[query_id] = query_id.hex() - single_query = {query_id: CATALOG_QUERY_IDS[query_id]} - - autopay_calls = AutopayCalls(autopay, catalog=single_query) - feed_tips = await get_continuous_tips(autopay, autopay_calls) - if feed_tips is None: - tips = 0 - msg = "No feeds available for query id" - logger.warning(msg) - return tips - try: - tips = feed_tips[CATALOG_QUERY_IDS[query_id]] - except KeyError: - msg = f"Tips for {CATALOG_QUERY_IDS[query_id]} not showing" - logger.warning(CATALOG_QUERY_IDS[query_id]) - return None - return tips - - -async def get_one_time_tips( - autopay: TellorFlexAutopayContract, -) -> Any: - """ - Check query ids in catalog for one-time-tips and return query id with the most tips - """ - one_time_tips = AutopayCalls(autopay=autopay, catalog=CATALOG_QUERY_IDS) - return await one_time_tips.get_current_tip() - - -async def get_continuous_tips(autopay: TellorFlexAutopayContract, tipping_feeds: Any = None) -> Any: - """ - Check query ids in catalog for funded feeds, combine tips, and return query id with most tips - """ - if tipping_feeds is None: - tipping_feeds = AutopayCalls(autopay=autopay, catalog=CATALOG_QUERY_IDS) - response = await tipping_feeds.reward_claim_status() - if response == (None, None, None): - logger.info("No feeds to check") - return None - current_feeds, current_values, claim_status = response - # filter out feeds that don't have a remaining balance after accounting for claimed tips - current_feeds = _remaining_feed_balance(current_feeds, claim_status) - # remove "current_feeds" word from tuple key in current_feeds dict to help when checking - # current_values dict for corresponding current values - current_feeds = {(key[1], key[2]): value for key, value in current_feeds.items()} - values_filtered = {} - for key, value in current_values.items(): - if len(key) > 2: - values_filtered[(key[1], key[2])] = value - else: - values_filtered[key[1]] = value - return await _get_feed_suggestion(current_feeds, values_filtered) - - -async def autopay_suggested_report( - autopay: TellorFlexAutopayContract, -) -> Tuple[Optional[str], Any]: - """ - Gets one-time tips and continuous tips then extracts query id with the most tips for a report suggestion - - Return: query id, tip amount - """ - chain = autopay.node.chain_id - if chain in AUTOPAY_CHAINS: - - # get query_ids with one time tips - singletip_dict = await get_one_time_tips(autopay) - # get query_ids with active feeds - datafeed_dict = await get_continuous_tips(autopay) - - # remove none type from dict - single_tip_suggestion = {} - if singletip_dict is not None: - single_tip_suggestion = {i: j for i, j in singletip_dict.items() if j} - - datafeed_suggestion = {} - if datafeed_dict is not None: - datafeed_suggestion = {i: j for i, j in datafeed_dict.items() if j} - - # combine feed dicts and add tips for duplicate query ids - combined_dict = { - key: _add_values(single_tip_suggestion.get(key), datafeed_suggestion.get(key)) - for key in single_tip_suggestion | datafeed_suggestion - } - # get feed with most tips - tips_sorted = sorted(combined_dict.items(), key=lambda item: item[1], reverse=True) # type: ignore - if tips_sorted: - suggested_feed = tips_sorted[0] - return suggested_feed[0], suggested_feed[1] - else: - return None, None - else: - logger.warning(f"Chain {chain} does not have Autopay contract support") - return None, None - - -async def _get_feed_suggestion(feeds: Any, current_values: Any) -> Any: - """ - Calculates tips and checks if a submission is in an eligible window for a feed submission - for a given query_id and feed_ids - - Return: a dict tag:tip amount - """ - current_time = TimeStamp.now().ts - query_id_with_tips = {} - - for query_tag, feed_id in feeds: # i is (query_id,feed_id) - if feeds[(query_tag, feed_id)] is not None: # feed_detail[i] is (details) - try: - feed_details = FeedDetails(*feeds[(query_tag, feed_id)]) - except TypeError: - msg = "couldn't decode feed details from contract" - continue - except Exception as e: - msg = f"unknown error decoding feed details from contract: {e}" - continue - - if feed_details.balance <= 0: - continue - num_intervals = math.floor((current_time - feed_details.startTime) / feed_details.interval) - # Start time of latest submission window - current_window_start = feed_details.startTime + (feed_details.interval * num_intervals) - - if not current_values[query_tag]: - value_before_now = 0 - timestamp_before_now = 0 - else: - value_before_now = current_values[(query_tag, "current_price")] - timestamp_before_now = current_values[(query_tag, "timestamp")] - - rules = [ - (current_time - current_window_start) < feed_details.window, - timestamp_before_now < current_window_start, - ] - if not all(rules): - msg = f"{query_tag}, isn't eligible for a tip" - error_status(note=msg, log=logger.info) - continue - - if feed_details.priceThreshold == 0: - if query_tag not in query_id_with_tips: - query_id_with_tips[query_tag] = feed_details.reward - else: - query_id_with_tips[query_tag] += feed_details.reward - else: - datafeed = CATALOG_FEEDS[query_tag] - value_now = await datafeed.source.fetch_new_datapoint() - # value is always a number for a price oracle submission - # convert bytes value to int - try: - value_before_now = int(int(current_values[(query_tag, "current_price")].hex(), 16) / 1e18) - except ValueError: - logger.info("Can't check price threshold, oracle price submission not a number") - continue - if value_now[0] is None or value_before_now is None: - note = f"Unable to fetch {datafeed} price for tip calculation" - error_status(note=note, log=logger.warning) - continue - current_value = value_now[0] - - if value_before_now == 0: - price_change = 10000 - - elif current_value >= value_before_now: - price_change = (10000 * (current_value - value_before_now)) / value_before_now - - else: - price_change = (10000 * (value_before_now - current_value)) / value_before_now - - if price_change > feed_details.priceThreshold: - if query_tag not in query_id_with_tips: - query_id_with_tips[query_tag] = feed_details.reward - else: - query_id_with_tips[query_tag] += feed_details.reward - - return query_id_with_tips - - -async def safe_multicall(calls: List[Call], endpoint: Web3, require_success: bool) -> Optional[Dict[str, Any]]: - """ - Multicall...call with error handling - - Args: - calls: list of Call objects, representing calls made by request - endpoint: web3 RPC endpoint connection - require_success: throws error if True and contract logic reverts - - Returns: - data if multicall is successful - """ - mc = Multicall(calls=calls, _w3=endpoint, require_success=require_success) - - try: - data: Dict[str, Any] = await mc.coroutine() - return data - except ContractLogicError as e: - msg = f"Contract reversion in multicall request, ContractLogicError: {e}" - logger.warning(msg) - return None - except ValueError as e: - if "unsupported block number" in str(e): - msg = f"Unsupported block number in multicall request, ValueError: {e}" - logger.warning(msg) - return None - else: - msg = f"Error in multicall request, ValueError: {e}" - return None - - -def _add_values(x: Optional[int], y: Optional[int]) -> Optional[int]: - """Helper function to add values when combining dicts with same key""" - return sum((num for num in (x, y) if num is not None)) - - -def _is_timestamp_first_in_window( - timestamp_before: int, timestamp_to_check: int, feed_start_timestamp: int, feed_window: int, feed_interval: int -) -> bool: - """ - Calculates to check if timestamp(timestamp_to_check) is first in window - - Return: bool - """ - # Number of intervals since start time - num_intervals = math.floor((timestamp_to_check - feed_start_timestamp) / feed_interval) - # Start time of latest submission window - current_window_start = feed_start_timestamp + (feed_interval * num_intervals) - eligible = [(timestamp_to_check - current_window_start) < feed_window, timestamp_before < current_window_start] - return all(eligible) - - -def _remaining_feed_balance(current_feeds: Any, reward_claimed_status: Any) -> Any: - """ - Checks if a feed has a remaining balance - - """ - for _, tag, feed_id in current_feeds: - details = FeedDetails(*current_feeds[_, tag, feed_id]) - balance = details.balance - if balance > 0: - for _tag, _feed_id, timestamp in reward_claimed_status: - if balance > 0 and tag == _tag and feed_id == _feed_id: - if not reward_claimed_status[tag, feed_id, timestamp]: - balance -= details.reward - current_feeds[_, tag, feed_id][1] = max(balance, 0) - return current_feeds diff --git a/src/telliot_feeds/reporters/rng_interval.py b/src/telliot_feeds/reporters/rng_interval.py index 27ab8e6d..f183348c 100644 --- a/src/telliot_feeds/reporters/rng_interval.py +++ b/src/telliot_feeds/reporters/rng_interval.py @@ -12,7 +12,6 @@ from telliot_feeds.datafeed import DataFeed from telliot_feeds.feeds.tellor_rng_feed import assemble_rng_datafeed from telliot_feeds.queries.tellor_rng import TellorRNG -from telliot_feeds.reporters.reporter_autopay_utils import get_feed_tip from telliot_feeds.reporters.tellor_360 import Tellor360Reporter from telliot_feeds.utils.log import get_logger @@ -59,22 +58,8 @@ async def fetch_datafeed(self) -> Optional[DataFeed[Any]]: error_status(note=msg, log=logger.warning) return None self.datafeed = datafeed - tip = 0 - - single_tip, status = await self.autopay.get_current_tip(datafeed.query.query_id) - if not status.ok: - msg = "Unable to fetch single tip" - error_status(msg, log=logger.warning) - return None - tip += single_tip - - feed_tip = await get_feed_tip( - datafeed.query.query_data, self.autopay - ) # input query data instead of query id to use tip listener - if feed_tip is None: - msg = "Unable to fetch feed tip" - error_status(msg, log=logger.warning) - return None - tip += feed_tip - logger.debug(f"Current tip for RNG query: {tip}") + # if check_rewards is True, autopay and TBR(if mainnet) are checked + if self.check_rewards: + tip = await self.rewards() + logger.info(f"Tip found for RNG feed: {tip}") return datafeed diff --git a/src/telliot_feeds/reporters/tellor_360.py b/src/telliot_feeds/reporters/tellor_360.py index 94055c50..94407be0 100644 --- a/src/telliot_feeds/reporters/tellor_360.py +++ b/src/telliot_feeds/reporters/tellor_360.py @@ -1,3 +1,4 @@ +import asyncio import math import time from dataclasses import dataclass @@ -5,20 +6,34 @@ from typing import Any from typing import Optional from typing import Tuple +from typing import Union +from chained_accounts import ChainedAccount from eth_abi.exceptions import EncodingTypeError from eth_utils import to_checksum_address +from telliot_core.contract.contract import Contract +from telliot_core.model.endpoints import RPCEndpoint +from telliot_core.utils.key_helpers import lazy_unlock_account from telliot_core.utils.response import error_status from telliot_core.utils.response import ResponseStatus +from web3 import Web3 +from web3.contract import ContractFunction +from web3.types import TxReceipt from telliot_feeds.constants import CHAINS_WITH_TBR from telliot_feeds.feeds import DataFeed +from telliot_feeds.feeds.trb_usd_feed import trb_usd_median_feed from telliot_feeds.reporters.rewards.time_based_rewards import get_time_based_rewards -from telliot_feeds.reporters.tellor_flex import TellorFlexReporter from telliot_feeds.reporters.tips.suggest_datafeed import get_feed_and_tip from telliot_feeds.reporters.tips.tip_amount import fetch_feed_tip +from telliot_feeds.utils.stake_info import StakeInfo from telliot_feeds.utils.log import get_logger +from telliot_feeds.utils.reporter_utils import fee_history_priority_fee_estimate +from telliot_feeds.utils.reporter_utils import get_native_token_feed +from telliot_feeds.utils.reporter_utils import has_native_token_funds +from telliot_feeds.utils.reporter_utils import is_online from telliot_feeds.utils.reporter_utils import suggest_random_feed +from telliot_feeds.utils.reporter_utils import tkn_symbol logger = get_logger(__name__) @@ -48,16 +63,189 @@ class StakerInfo: in_total_stakers: bool -class Tellor360Reporter(TellorFlexReporter): - def __init__(self, stake: float = 0, use_random_feeds: bool = False, *args: Any, **kwargs: Any) -> None: - self.stake_amount: Optional[int] = None - self.staker_info: Optional[StakerInfo] = None - self.allowed_stake_amount = 0 - super().__init__(*args, **kwargs) - self.stake: float = stake +@dataclass +class GasParams: + priority_fee: Optional[float] = None + max_fee: Optional[float] = None + gas_price_in_gwei: Union[float, int, None] = None + + +class Tellor360Reporter: + """Reports values from given datafeeds to a TellorFlex.""" + + def __init__( + self, + endpoint: RPCEndpoint, + account: ChainedAccount, + chain_id: int, + oracle: Contract, + token: Contract, + autopay: Contract, + datafeed: Optional[DataFeed[Any]] = None, + expected_profit: Union[str, float] = "YOLO", + transaction_type: int = 2, + gas_limit: Optional[int] = None, + max_fee: int = 0, + priority_fee: float = 0.0, + legacy_gas_price: Optional[int] = None, + gas_multiplier: int = 1, # 1 percent + max_priority_fee_range: int = 80, # 80 gwei + wait_period: int = 7, + min_native_token_balance: int = 10**18, + check_rewards: bool = True, + ignore_tbr: bool = False, # relevant only for eth-mainnet and eth-testnets + stake: float = 0, + use_random_feeds: bool = False, + ) -> None: + self.endpoint = endpoint + self.account = account + self.chain_id = chain_id + self.oracle = oracle + self.token = token + self.autopay = autopay + # datafeed stuff + self.datafeed = datafeed self.use_random_feeds: bool = use_random_feeds + self.qtag_selected = False if self.datafeed is None else True + # profitibility stuff + self.expected_profit = expected_profit + # stake amount stuff + self.stake: float = stake + self.stake_info = StakeInfo() - assert self.acct_addr == to_checksum_address(self.account.address) + self.min_native_token_balance = min_native_token_balance + # check rewards bool flag + self.check_rewards: bool = check_rewards + self.autopaytip = 0 + self.web3: Web3 = self.endpoint.web3 + # ignore tbr bool flag to optionally disregard time based rewards + self.ignore_tbr = ignore_tbr + self.last_submission_timestamp = 0 + # gas stuff + self.transaction_type = transaction_type + self.gas_limit = gas_limit + self.max_fee = max_fee + self.wait_period = wait_period + self.priority_fee = priority_fee + self.legacy_gas_price = legacy_gas_price + self.gas_multiplier = gas_multiplier + self.max_priority_fee_range = max_priority_fee_range + self.gas_info: dict[str, Union[float, int]] = {} + + self.acct_addr = to_checksum_address(account.address) + logger.info(f"Reporting with account: {self.acct_addr}") + # TODO: why is this here? + # assert self.acct_addr == to_checksum_address(self.account.address) + + async def get_stake_amount(self) -> Tuple[Optional[int], ResponseStatus]: + """Reads the current stake amount from the oracle contract + + Returns: + - (int, ResponseStatus) the current stake amount in TellorFlex + """ + response, status = await self.oracle.read("getStakeAmount") + if not status.ok: + msg = "Unable to read current stake amount" + return None, error_status(msg, log=logger.error) + stake_amount: int = response + return stake_amount, status + + async def get_staker_details(self) -> Tuple[Optional[StakerInfo], ResponseStatus]: + """Reads the staker details for the account from the oracle contract + + Returns: + - (StakerInfo, ResponseStatus) the staker details for the account + """ + response, status = await self.oracle.read("getStakerInfo", _stakerAddress=self.acct_addr) + if not status.ok: + msg = "Unable to read account staker info" + return None, error_status(msg, log=logger.error) + staker_details = StakerInfo(*response) + return staker_details, status + + async def get_current_balance(self) -> Tuple[Optional[int], ResponseStatus]: + """Reads the current balance of the account""" + response, status = await self.token.read("balanceOf", account=self.acct_addr) + if not status.ok: + msg = "Unable to read account balance" + return None, error_status(msg, log=logger.error) + wallet_balance: int = response + logger.info(f"Current wallet TRB balance: {wallet_balance / 1e18!r}") + return wallet_balance, status + + async def gas_params(self) -> Tuple[Optional[GasParams], ResponseStatus]: + """Returns the gas params for the transaction + + Returns: + - priority_fee: float, the priority fee in gwei + - max_fee: int, the max fee in wei + - gas_price_in_gwei: float, the gas price in gwei + """ + if self.transaction_type == 2: + priority_fee, max_fee = self.get_fee_info() + if priority_fee is None or max_fee is None: + return None, error_status("Unable to suggest type 2 txn fees", log=logger.error) + return GasParams(priority_fee=priority_fee, max_fee=max_fee), ResponseStatus() + + else: + # Fetch legacy gas price if not provided by user + if self.legacy_gas_price is None: + gas_price_in_gwei = await self.fetch_gas_price() + if not gas_price_in_gwei: + note = "Unable to fetch gas price for staking tx type 0" + return None, error_status(note, log=logger.warning) + else: + gas_price_in_gwei = self.legacy_gas_price + return GasParams(gas_price_in_gwei=gas_price_in_gwei), ResponseStatus() + + async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: + """Deposits stake into the oracle contract""" + # check allowance to avoid unnecessary approval transactions + allowance, allowance_status = await self.token.read( + "allowance", owner=self.acct_addr, spender=self.oracle.address + ) + if not allowance_status.ok: + msg = "Unable to check allowance" + return False, error_status(msg, log=logger.error) + logger.debug(f"Current allowance: {allowance / 1e18!r}") + gas_params, status = await self.gas_params() + if not status.ok or not gas_params: + return False, status + # if allowance is less than amount_to_stake then approve + if allowance < amount: + # Approve token spending + logger.info("Approving token spending") + approve_receipt, approve_status = await self.token.write( + func_name="approve", + gas_limit=self.gas_limit, + max_priority_fee_per_gas=gas_params.priority_fee, + max_fee_per_gas=gas_params.max_fee, + legacy_gas_price=gas_params.gas_price_in_gwei, + spender=self.oracle.address, + amount=amount, + ) + if not approve_status.ok: + msg = "Unable to approve staking" + return False, error_status(msg, log=logger.error) + logger.debug(f"Approve transaction status: {approve_receipt.status}, block: {approve_receipt.blockNumber}") + # Add this to avoid nonce error from txn happening too fast + time.sleep(1) + + # deposit stake + logger.info("Depositing stake") + deposit_receipt, deposit_status = await self.oracle.write( + func_name="depositStake", + gas_limit=self.gas_limit, + max_priority_fee_per_gas=gas_params.priority_fee, + max_fee_per_gas=gas_params.max_fee, + legacy_gas_price=gas_params.gas_price_in_gwei, + _amount=amount, + ) + if not deposit_status.ok: + msg = "Unable to deposit stake" + return False, error_status(msg, log=logger.error) + logger.debug(f"Deposit transaction status: {deposit_receipt.status}, block: {deposit_receipt.blockNumber}") + return True, deposit_status async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: """Compares stakeAmount and stakerInfo every loop to monitor changes to the stakeAmount or stakerInfo @@ -67,152 +255,71 @@ async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: - (bool, ResponseStatus) """ # get oracle required stake amount - stake_amount: int - stake_amount, status = await self.oracle.read("getStakeAmount") - - if (not status.ok) or (stake_amount is None): - msg = "Unable to read current stake amount" - return False, error_status(msg, log=logger.info) - - logger.info(f"Current Oracle stakeAmount: {stake_amount / 1e18!r}") + stake_amount, status = await self.get_stake_amount() + if not status.ok or stake_amount is None: + return False, status + # store stake amount + self.stake_info.store_stake_amount(stake_amount) + # check if stake amount changed (logs when it does) + self.stake_info.stake_amount_change() # get accounts current stake total - staker_info, status = await self.oracle.read( - "getStakerInfo", - _stakerAddress=self.acct_addr, - ) - if (not status.ok) or (staker_info is None): - msg = "Unable to read reporters staker info" - return False, error_status(msg, log=logger.info) - - # first when reporter start set stakerInfo - if self.staker_info is None: - self.staker_info = StakerInfo(*staker_info) - - # on subsequent loops keeps checking if staked balance in oracle contract decreased - # if it decreased account is probably dispute barring withdrawal - if self.staker_info.stake_balance > staker_info[1]: - # update balance in script - self.staker_info.stake_balance = staker_info[1] - logger.info("your staked balance has decreased and account might be in dispute") - - # after the first loop keep track of the last report's timestamp to calculate reporter lock - self.staker_info.last_report = staker_info[4] - self.staker_info.reports_count = staker_info[5] + staker_details, status = await self.get_staker_details() + if not status.ok or staker_details is None: + return False, status + # store staker balance + self.stake_info.store_staker_balance(staker_details.stake_balance) + # update report count + self.stake_info.update_last_report_time(staker_details.last_report) + # check if staker balance changed which means a value they submitted has been disputed + # (logs when it does) + self.stake_info.is_in_dispute() logger.info( f""" STAKER INFO - start date: {staker_info[0]} - stake_balance: {staker_info[1] / 1e18!r} - locked_balance: {staker_info[2]} - last report: {staker_info[4]} - reports count: {staker_info[5]} + start date: {staker_details.start_date} + stake_balance: {staker_details.stake_balance / 1e18!r} + locked_balance: {staker_details.locked_balance} + last report: {staker_details.last_report} + reports count: {staker_details.reports_count} """ ) - account_staked_bal = self.staker_info.stake_balance - - # after the first loop, logs if stakeAmount has increased or decreased - if self.stake_amount is not None: - if self.stake_amount < stake_amount: - logger.info("Stake amount has increased possibly due to TRB price change.") - elif self.stake_amount > stake_amount: - logger.info("Stake amount has decreased possibly due to TRB price change.") - - self.stake_amount = stake_amount - # deposit stake if stakeAmount in oracle is greater than account stake or # a stake in cli is selected thats greater than account stake - if self.stake_amount > account_staked_bal or (self.stake * 1e18) > account_staked_bal: + chosen_stake_amount = (self.stake * 1e18) > staker_details.stake_balance + if chosen_stake_amount: + logger.info("Chosen stake is greater than account stake balance") + if self.stake_info.stake_amount_gt_staker_balance or chosen_stake_amount: logger.info("Approving and depositing stake...") - # amount to deposit whichever largest difference either chosen stake or stakeAmount to keep reporting - stake_diff = max(int(self.stake_amount - account_staked_bal), int((self.stake * 1e18) - account_staked_bal)) + # current oracle stake amount vs current account stake balance + to_stake_amount_1 = self.stake_info.current_stake_amount - staker_details.stake_balance + # chosen stake via cli flag vs current account stake balance + to_stake_amount_2 = (self.stake * 1e18) - staker_details.stake_balance + amount_to_stake = max(int(to_stake_amount_1), int(to_stake_amount_2)) # check TRB wallet balance! - wallet_balance, wallet_balance_status = await self.token.read("balanceOf", account=self.acct_addr) - - if not wallet_balance_status.ok: - msg = "unable to read account TRB balance" - return False, error_status(msg, log=logger.info) - - logger.info(f"Current wallet TRB balance: {wallet_balance / 1e18!r}") - - if stake_diff > wallet_balance: - msg = "Not enough TRB in the account to cover the stake" - return False, error_status(msg, log=logger.warning) - # approve token spending - if self.transaction_type == 2: - priority_fee, max_fee = self.get_fee_info() - if priority_fee is None or max_fee is None: - return False, error_status("Unable to suggest type 2 txn fees", log=logger.error) - # Approve token spending for a transaction type 2 - receipt, approve_status = await self.token.write( - func_name="approve", - gas_limit=self.gas_limit, - max_priority_fee_per_gas=priority_fee, - max_fee_per_gas=max_fee, - spender=self.oracle.address, - amount=stake_diff, - ) - if not approve_status.ok: - msg = "Unable to approve staking" - return False, error_status(msg, log=logger.error) - logger.debug(f"Approve transaction status: {receipt.status}, block: {receipt.blockNumber}") - # deposit stake for a transaction type 2 - _, deposit_status = await self.oracle.write( - func_name="depositStake", - gas_limit=self.gas_limit, - max_priority_fee_per_gas=priority_fee, - max_fee_per_gas=max_fee, - _amount=stake_diff, + wallet_balance, wallet_balance_status = await self.get_current_balance() + if not wallet_balance or not wallet_balance_status.ok: + return False, wallet_balance_status + + if amount_to_stake > wallet_balance: + msg = ( + f"Amount to stake: {amount_to_stake/1e18:.04f} " + f"is greater than your balance: {wallet_balance/1e18:.04f} so " + "not enough TRB to cover the stake" ) + return False, error_status(msg, log=logger.warning) + + _, deposit_status = await self.deposit_stake(amount_to_stake) + if not deposit_status.ok: + return False, deposit_status - if not deposit_status.ok: - msg = "Unable to deposit stake" - return False, error_status(msg, log=logger.error) - else: - # Fetch legacy gas price if not provided by user - if self.legacy_gas_price is None: - gas_price_in_gwei = await self.fetch_gas_price() - if not gas_price_in_gwei: - note = "Unable to fetch gas price for tx type 0" - return False, error_status(note, log=logger.warning) - else: - gas_price_in_gwei = self.legacy_gas_price - # Approve token spending for a transaction type 0 and deposit stake - receipt, approve_status = await self.token.write( - func_name="approve", - gas_limit=self.gas_limit, - legacy_gas_price=gas_price_in_gwei, - spender=self.oracle.address, - amount=stake_diff, - ) - if not approve_status.ok: - msg = "Unable to approve staking" - return False, error_status(msg, log=logger.error) - # Add this to avoid nonce error from txn happening too fast - time.sleep(1) - logger.debug(f"Approve transaction status: {receipt.status}, block: {receipt.blockNumber}") - # Deposit stake to oracle contract - _, deposit_status = await self.oracle.write( - func_name="depositStake", - gas_limit=self.gas_limit, - legacy_gas_price=gas_price_in_gwei, - _amount=stake_diff, - ) - if not deposit_status.ok: - msg = ( - "Unable to stake deposit: " - + deposit_status.error - + f"Make sure {self.acct_addr} has enough of the current chain's " - + "currency and the oracle's currency (TRB)" - ) - return False, error_status(msg, log=logger.error) # add staked balance after successful stake deposit - self.staker_info.stake_balance += stake_diff + self.stake_info.update_staker_balance(amount_to_stake) return True, ResponseStatus() @@ -222,16 +329,18 @@ async def check_reporter_lock(self) -> ResponseStatus: Return: - ResponseStatus: yay or nay """ - if self.staker_info is None or self.stake_amount is None: + staker_balance = self.stake_info.current_staker_balance + current_stake_amount = self.stake_info.current_stake_amount + if staker_balance is None or current_stake_amount is None: msg = "Unable to calculate reporter lock remaining time" return error_status(msg, log=logger.info) # 12hrs in seconds is 43200 try: - reporter_lock = 43200 / math.floor(self.staker_info.stake_balance / self.stake_amount) + reporter_lock = 43200 / math.floor(staker_balance / current_stake_amount) except ZeroDivisionError: # Tellor Playground contract's stakeAmount is 0 reporter_lock = 0 - time_remaining = round(self.staker_info.last_report + reporter_lock - time.time()) + time_remaining = round(self.stake_info.last_report_time + reporter_lock - time.time()) if time_remaining > 0: hr_min_sec = str(timedelta(seconds=time_remaining)) msg = "Currently in reporter lock. Time left: " + hr_min_sec @@ -295,3 +404,324 @@ async def fetch_datafeed(self) -> Optional[DataFeed[Any]]: return self.datafeed return self.datafeed + + async def fetch_gas_price(self) -> Optional[float]: + """Fetches the current gas price from an EVM network and returns + an adjusted gas price. + + Returns: + An optional integer representing the adjusted gas price in wei, or + None if the gas price could not be retrieved. + """ + try: + price = self.web3.eth.gas_price + price_gwei = self.web3.fromWei(price, "gwei") + except Exception as e: + logger.error(f"Error fetching gas price: {e}") + return None + # increase gas price by 1.0 + gas_multiplier + multiplier = 1.0 + (self.gas_multiplier / 100.0) + gas_price = (float(price_gwei) * multiplier) if price_gwei else None + return gas_price + + def get_fee_info(self) -> Tuple[Optional[float], Optional[float]]: + """Calculate max fee and priority fee if not set + for more info: + https://web3py.readthedocs.io/en/v5/web3.eth.html?highlight=fee%20history#web3.eth.Eth.fee_history + """ + if self.max_fee is None: + try: + fee_history = self.web3.eth.fee_history( + block_count=5, newest_block="latest", reward_percentiles=[25, 50, 75] + ) + # "base fee for the next block after the newest of the returned range" + base_fee = fee_history.baseFeePerGas[-1] / 1e9 + # estimate priority fee from fee history + priority_fee_max = int(self.max_priority_fee_range * 1e9) # convert to wei + priority_fee = fee_history_priority_fee_estimate(fee_history, priority_fee_max=priority_fee_max) / 1e9 + max_fee = base_fee + priority_fee + return priority_fee, max_fee + except Exception as e: + logger.warning(f"Error in calculating gas fees: {e}") + return None, None + return self.priority_fee, self.max_fee + + async def get_num_reports_by_id(self, query_id: bytes) -> Tuple[int, ResponseStatus]: + count, read_status = await self.oracle.read(func_name="getNewValueCountbyQueryId", _queryId=query_id) + return count, read_status + + def has_native_token(self) -> bool: + """Check if account has native token funds for a network for gas fees + of at least min_native_token_balance that is set in the cli""" + return has_native_token_funds(self.acct_addr, self.web3, min_balance=self.min_native_token_balance) + + async def ensure_profitable(self) -> ResponseStatus: + + status = ResponseStatus() + if not self.check_rewards: + return status + + tip = self.autopaytip + # Fetch token prices in USD + native_token_feed = get_native_token_feed(self.chain_id) + price_feeds = [native_token_feed, trb_usd_median_feed] + _ = await asyncio.gather(*[feed.source.fetch_new_datapoint() for feed in price_feeds]) + price_native_token = native_token_feed.source.latest[0] + price_trb_usd = trb_usd_median_feed.source.latest[0] + + if price_native_token is None or price_trb_usd is None: + return error_status("Unable to fetch token price", log=logger.warning) + + if not self.gas_info: + return error_status("Gas info not set", log=logger.warning) + + gas_info = self.gas_info + + if gas_info["type"] == 0: + txn_fee = int(gas_info["gas_price"] * gas_info["gas_limit"]) + logger.info( + f""" + + Tips: {tip/1e18} + Transaction fee: {self.web3.fromWei(txn_fee, 'gwei'):.09f} {tkn_symbol(self.chain_id)} + Gas price: {gas_info["gas_price"]} gwei + Gas limit: {gas_info["gas_limit"]} + Txn type: 0 (Legacy) + """ + ) + if gas_info["type"] == 2: + txn_fee = int(gas_info["max_fee"] * gas_info["gas_limit"]) + logger.info( + f""" + + Tips: {tip/1e18} + Max transaction fee: {self.web3.fromWei(txn_fee, 'gwei'):.18f} {tkn_symbol(self.chain_id)} + Max fee per gas: {gas_info["max_fee"]} gwei + Max priority fee per gas: {gas_info["priority_fee"]} gwei + Gas limit: {gas_info["gas_limit"]} + Txn type: 2 (EIP-1559) + """ + ) + + # Calculate profit + rev_usd = tip / 1e18 * price_trb_usd + costs_usd = txn_fee / 1e9 * price_native_token # convert gwei costs to eth, then to usd + profit_usd = rev_usd - costs_usd + logger.info(f"Estimated profit: ${round(profit_usd, 2)}") + logger.info(f"tip price: {round(rev_usd, 2)}, gas costs: {costs_usd}") + + percent_profit = ((profit_usd) / costs_usd) * 100 + logger.info(f"Estimated percent profit: {round(percent_profit, 2)}%") + if (self.expected_profit != "YOLO") and ( + isinstance(self.expected_profit, float) and percent_profit < self.expected_profit + ): + status.ok = False + status.error = "Estimated profitability below threshold." + logger.info(status.error) + # reset datafeed for a new suggestion if qtag wasn't selected in cli + if self.qtag_selected is False: + self.datafeed = None + return status + # reset autopay tip to check for tips again + self.autopaytip = 0 + # reset datafeed for a new suggestion if qtag wasn't selected in cli + if self.qtag_selected is False: + self.datafeed = None + + return status + + def get_acct_nonce(self) -> Tuple[Optional[int], ResponseStatus]: + """Get transaction count for an address""" + try: + return self.web3.eth.get_transaction_count(self.acct_addr), ResponseStatus() + except ValueError as e: + return None, error_status("Account nonce request timed out", e=e, log=logger.warning) + except Exception as e: + return None, error_status("Unable to retrieve account nonce", e=e, log=logger.error) + + def submit_val_tx_gas_limit(self, submit_val_tx: ContractFunction) -> Tuple[Optional[int], ResponseStatus]: + """Estimate gas usage for submitValue transaction + Args: + submit_val_tx: The submitValue transaction object + Returns a tuple of the gas limit and a ResponseStatus object""" + if self.gas_limit is None: + try: + gas_limit: int = submit_val_tx.estimateGas({"from": self.acct_addr}) + if not gas_limit: + return None, error_status("Unable to estimate gas for submitValue transaction") + return gas_limit, ResponseStatus() + except Exception as e: + msg = "Unable to estimate gas for submitValue transaction" + return None, error_status(msg, e=e, log=logger.error) + return self.gas_limit, ResponseStatus() + + async def assemble_submission_txn(self, datafeed: DataFeed) -> Tuple[Optional[ContractFunction], ResponseStatus]: + """Assemble the submitValue transaction + Params: + datafeed: The datafeed object + + Returns a tuple of the web3 function object and a ResponseStatus object + """ + # Update datafeed value + await datafeed.source.fetch_new_datapoint() + latest_data = datafeed.source.latest + if latest_data[0] is None: + msg = "Unable to retrieve updated datafeed value." + return None, error_status(msg, log=logger.info) + # Get query info & encode value to bytes + query = datafeed.query + query_id = query.query_id + query_data = query.query_data + try: + value = query.value_type.encode(latest_data[0]) + logger.debug(f"IntervalReporter Encoded value: {value.hex()}") + except Exception as e: + msg = f"Error encoding response value {latest_data[0]}" + return None, error_status(msg, e=e, log=logger.error) + + # Get nonce + report_count, read_status = await self.get_num_reports_by_id(query_id) + + if not read_status.ok: + read_status.error = ( + "Unable to retrieve report count: " + read_status.error + ) # error won't be none # noqa: E501 + logger.error(read_status.error) + read_status.e = read_status.e + return None, read_status + + # Start transaction build + submit_val_func = self.oracle.contract.get_function_by_name("submitValue") + params: ContractFunction = submit_val_func( + _queryId=query_id, + _value=value, + _nonce=report_count, + _queryData=query_data, + ) + return params, ResponseStatus() + + def send_transaction(self, tx_signed) -> Tuple[Optional[TxReceipt], ResponseStatus]: + """Send a signed transaction to the blockchain and wait for confirmation + + Params: + tx_signed: The signed transaction object + + Returns a tuple of the transaction receipt and a ResponseStatus object + """ + try: + logger.debug("Sending submitValue transaction") + tx_hash = self.web3.eth.send_raw_transaction(tx_signed.rawTransaction) + except Exception as e: + note = "Send transaction failed" + return None, error_status(note, log=logger.error, e=e) + + try: + # Confirm transaction + tx_receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash, timeout=360) + + tx_url = f"{self.endpoint.explorer}/tx/{tx_hash.hex()}" + + if tx_receipt["status"] == 0: + msg = f"Transaction reverted. ({tx_url})" + return tx_receipt, error_status(msg, log=logger.error) + + logger.info(f"View reported data: \n{tx_url}") + return tx_receipt, ResponseStatus() + except Exception as e: + note = "Failed to confirm transaction" + return None, error_status(note, log=logger.error, e=e) + + async def report_once( + self, + ) -> Tuple[Optional[TxReceipt], ResponseStatus]: + """Report query value once + This method checks to see if a user is able to submit + values to the oracle, given their staker status + and last submission time. Also, this method does not + submit values if doing so won't make a profit.""" + # Check staker status + staked, status = await self.ensure_staked() + if not staked or not status.ok: + return None, status + + status = await self.check_reporter_lock() + if not status.ok: + return None, status + + # Get suggested datafeed if none provided + datafeed = await self.fetch_datafeed() + if not datafeed: + msg = "Unable to suggest datafeed" + return None, error_status(note=msg, log=logger.info) + + logger.info(f"Current query: {datafeed.query.descriptor}") + + submit_val_tx, status = await self.assemble_submission_txn(datafeed) + if not status.ok or submit_val_tx is None: + return None, status + + # Get account nonce + acc_nonce, nonce_status = self.get_acct_nonce() + if not nonce_status.ok: + return None, nonce_status + + gas_params, status = await self.gas_params() + if gas_params is None or not status.ok: + return None, status + + # Estimate gas usage amount + gas_limit, status = self.submit_val_tx_gas_limit(submit_val_tx=submit_val_tx) + if not status.ok or gas_limit is None: + return None, status + + self.gas_info["gas_limit"] = gas_limit + if gas_params.max_fee is not None and gas_params.priority_fee is not None: + self.gas_info["type"] = 2 + self.gas_info["max_fee"] = gas_params.max_fee + self.gas_info["priority_fee"] = gas_params.priority_fee + self.gas_info["base_fee"] = gas_params.max_fee - gas_params.priority_fee + gas_fees = { + "maxPriorityFeePerGas": self.web3.toWei(gas_params.priority_fee, "gwei"), + "maxFeePerGas": self.web3.toWei(gas_params.max_fee, "gwei"), + } + + if gas_params.gas_price_in_gwei is not None: + self.gas_info["type"] = 0 + self.gas_info["gas_price"] = gas_params.gas_price_in_gwei + gas_fees = {"gasPrice": self.web3.toWei(gas_params.gas_price_in_gwei, "gwei")} + + # Check if profitable if not YOLO + status = await self.ensure_profitable() + if not status.ok: + return None, status + + # Build transaction + built_submit_val_tx = submit_val_tx.buildTransaction( + dict(nonce=acc_nonce, gas=gas_limit, chainId=self.chain_id, **gas_fees) + ) + lazy_unlock_account(self.account) + local_account = self.account.local_account + tx_signed = local_account.sign_transaction(built_submit_val_tx) + + tx_receipt, status = self.send_transaction(tx_signed) + + return tx_receipt, status + + async def is_online(self) -> bool: + return await is_online() + + 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: + if await self.is_online(): + if self.has_native_token(): + _, _ = await self.report_once() + 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/reporters/tellor_flex.py b/src/telliot_feeds/reporters/tellor_flex.py deleted file mode 100644 index a2bb56b6..00000000 --- a/src/telliot_feeds/reporters/tellor_flex.py +++ /dev/null @@ -1,344 +0,0 @@ -"""TellorFlex compatible reporters""" -import asyncio -import time -from datetime import timedelta -from typing import Any -from typing import Optional -from typing import Tuple -from typing import Union - -from chained_accounts import ChainedAccount -from eth_abi.exceptions import EncodingTypeError -from eth_utils import to_checksum_address -from telliot_core.contract.contract import Contract -from telliot_core.model.endpoints import RPCEndpoint -from telliot_core.utils.response import error_status -from telliot_core.utils.response import ResponseStatus - -from telliot_feeds.datafeed import DataFeed -from telliot_feeds.feeds import CATALOG_FEEDS -from telliot_feeds.feeds.trb_usd_feed import trb_usd_median_feed -from telliot_feeds.reporters.interval import IntervalReporter -from telliot_feeds.reporters.reporter_autopay_utils import autopay_suggested_report -from telliot_feeds.reporters.reporter_autopay_utils import CATALOG_QUERY_IDS -from telliot_feeds.reporters.reporter_autopay_utils import get_feed_tip -from telliot_feeds.utils.log import get_logger -from telliot_feeds.utils.reporter_utils import get_native_token_feed -from telliot_feeds.utils.reporter_utils import tellor_suggested_report -from telliot_feeds.utils.reporter_utils import tkn_symbol - - -logger = get_logger(__name__) - - -class TellorFlexReporter(IntervalReporter): - """Reports values from given datafeeds to a TellorFlex.""" - - def __init__( - self, - endpoint: RPCEndpoint, - account: ChainedAccount, - chain_id: int, - oracle: Contract, - token: Contract, - autopay: Contract, - stake: float = 10.0, - datafeed: Optional[DataFeed[Any]] = None, - expected_profit: Union[str, float] = "YOLO", - transaction_type: int = 2, - gas_limit: Optional[int] = None, - max_fee: int = 0, - priority_fee: float = 0.0, - legacy_gas_price: Optional[int] = None, - gas_multiplier: int = 1, # 1 percent - max_priority_fee_range: int = 80, # 80 gwei - wait_period: int = 7, - min_native_token_balance: int = 10**18, - check_rewards: bool = True, - ignore_tbr: bool = False, # relevant only for eth-mainnet and eth-testnets - ) -> None: - - self.endpoint = endpoint - self.oracle = oracle - self.token = token - self.autopay = autopay - self.stake = stake - self.datafeed = datafeed - self.chain_id = chain_id - self.acct_addr = to_checksum_address(account.address) - self.last_submission_timestamp = 0 - self.expected_profit = expected_profit - self.transaction_type = transaction_type - self.gas_limit = gas_limit - self.max_fee = max_fee - self.wait_period = wait_period - self.priority_fee = priority_fee - self.legacy_gas_price = legacy_gas_price - self.gas_multiplier = gas_multiplier - self.max_priority_fee_range = max_priority_fee_range - self.autopaytip = 0 - self.staked_amount: Optional[float] = None - self.qtag_selected = False if self.datafeed is None else True - self.min_native_token_balance = min_native_token_balance - self.check_rewards: bool = check_rewards - self.web3 = self.endpoint.web3 - self.ignore_tbr = ignore_tbr - - self.gas_info: dict[str, Union[float, int]] = {} - logger.info(f"Reporting with account: {self.acct_addr}") - - self.account: ChainedAccount = account - assert self.acct_addr == to_checksum_address(self.account.address) - - async def in_dispute(self, new_stake_amount: Any) -> bool: - """Check if staker balance decreased""" - if self.staked_amount is not None and self.staked_amount > new_stake_amount: - return True - return False - - async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: - """Make sure the current user is staked. - - Returns a bool signifying whether the current address is - staked. If the address is not initially, it attempts to deposit - the given stake amount.""" - staker_info, read_status = await self.oracle.read(func_name="getStakerInfo", _staker=self.acct_addr) - - if (not read_status.ok) or (staker_info is None): - msg = "Unable to read reporters staker info" - return False, error_status(msg, log=logger.info) - - ( - staker_startdate, - staker_balance, - locked_balance, - last_report, - num_reports, - ) = staker_info - - logger.info( - f""" - - STAKER INFO - start date: {staker_startdate} - desired stake: {self.stake} - amount staked: {staker_balance / 1e18} - locked balance: {locked_balance / 1e18} - last report: {last_report} - total reports: {num_reports} - """ - ) - - self.last_submission_timestamp = last_report - # check if staker balance has decreased after initial assignment - if await self.in_dispute(staker_balance): - msg = "Staked balance has decreased, account might be in dispute; restart telliot to keep reporting" - return False, error_status(msg) - # Attempt to stake - if staker_balance / 1e18 < self.stake: - logger.info("Current stake too low. Approving & depositing stake.") - - gas_price_gwei = await self.fetch_gas_price() - if gas_price_gwei is None: - return False, error_status("Unable to fetch gas price for staking", log=logger.info) - amount = int(self.stake * 1e18) - staker_balance - - _, write_status = await self.token.write( - func_name="approve", - gas_limit=100000, - legacy_gas_price=gas_price_gwei, - spender=self.oracle.address, - amount=amount, - ) - if not write_status.ok: - msg = "Unable to approve staking" - return False, error_status(msg, log=logger.error) - - _, write_status = await self.oracle.write( - func_name="depositStake", - gas_limit=300000, - legacy_gas_price=gas_price_gwei, - _amount=amount, - ) - if not write_status.ok: - msg = ( - "Unable to stake deposit: " - + write_status.error - + f"Make sure {self.acct_addr} has enough of the current chain's " - + "currency and the oracle's currency (TRB)" - ) # error won't be none # noqa: E501 - return False, error_status(msg, log=logger.error) - - logger.info(f"Staked {amount / 1e18} TRB") - self.staked_amount = self.stake - elif self.staked_amount is None: - self.staked_amount = staker_balance - - return True, ResponseStatus() - - async def check_reporter_lock(self) -> ResponseStatus: - """Ensure enough time has passed since last report. - - One stake is 10 TRB. Reporter lock is depends on the - total staked: - - reporter_lock = 12hrs / # stakes - - Returns bool signifying whether a given address is in a - reporter lock or not.""" - staker_info, read_status = await self.oracle.read(func_name="getStakerInfo", _staker=self.acct_addr) - - if (not read_status.ok) or (staker_info is None): - msg = "Unable to read reporters staker info" - return error_status(msg, log=logger.error) - - _, staker_balance, _, last_report, _ = staker_info - - if staker_balance < 10 * 1e18: - return error_status("Staker balance too low.", log=logger.info) - - self.last_submission_timestamp = last_report - logger.info(f"Last submission timestamp: {self.last_submission_timestamp}") - - trb = staker_balance / 1e18 - num_stakes = (trb - (trb % 10)) / 10 - reporter_lock = (12 / num_stakes) * 3600 - - time_remaining = round(self.last_submission_timestamp + reporter_lock - time.time()) - if time_remaining > 0: - hr_min_sec = str(timedelta(seconds=time_remaining)) - msg = "Currently in reporter lock. Time left: " + hr_min_sec - return error_status(msg, log=logger.info) - - return ResponseStatus() - - async def rewards(self) -> int: - tip = 0 - datafeed: DataFeed[Any] = self.datafeed # type: ignore - # Skip fetching datafeed & checking profitability - if self.expected_profit == "YOLO": - return tip - - single_tip, status = await self.autopay.get_current_tip(datafeed.query.query_id) - if not status.ok: - logger.warning("Unable to fetch single tip") - else: - tip += single_tip - - feed_tip = await get_feed_tip(datafeed.query.query_id, self.autopay) - if feed_tip is None: - logger.warning("Unable to fetch feed tip") - else: - tip += feed_tip - - return tip - - async def fetch_datafeed(self) -> Optional[DataFeed[Any]]: - """Fetches datafeed suggestion plus the reward amount from autopay if query tag isn't selected - if query tag is selected fetches the rewards, if any, for that query tag""" - if self.datafeed: - # add query id to catalog to fetch tip for legacy autopay - try: - qid = self.datafeed.query.query_id - except EncodingTypeError: - logger.warning(f"Unable to generate data/id for query: {self.datafeed.query}") - return None - if qid not in CATALOG_QUERY_IDS: - CATALOG_QUERY_IDS[qid] = self.datafeed.query.descriptor - self.autopaytip = await self.rewards() - return self.datafeed - - suggested_qtag, autopay_tip = await autopay_suggested_report(self.autopay) - if suggested_qtag: - self.autopaytip = autopay_tip - self.datafeed = CATALOG_FEEDS[suggested_qtag] - return self.datafeed - - if suggested_qtag is None: - suggested_qtag = await tellor_suggested_report(self.oracle) - if suggested_qtag is None: - logger.warning("Could not suggest query tag") - return None - elif suggested_qtag not in CATALOG_FEEDS: - logger.warning(f"Suggested query tag not in catalog: {suggested_qtag}") - return None - else: - self.datafeed = CATALOG_FEEDS[suggested_qtag] - self.autopaytip = await self.rewards() - return self.datafeed - return None - - async def ensure_profitable(self, datafeed: DataFeed[Any]) -> ResponseStatus: - - status = ResponseStatus() - if not self.check_rewards: - return status - - tip = self.autopaytip - # Fetch token prices in USD - native_token_feed = get_native_token_feed(self.chain_id) - price_feeds = [native_token_feed, trb_usd_median_feed] - _ = await asyncio.gather(*[feed.source.fetch_new_datapoint() for feed in price_feeds]) - price_native_token = native_token_feed.source.latest[0] - price_trb_usd = trb_usd_median_feed.source.latest[0] - - if price_native_token is None or price_trb_usd is None: - return error_status("Unable to fetch token price", log=logger.warning) - - if not self.gas_info: - return error_status("Gas info not set", log=logger.warning) - - gas_info = self.gas_info - - if gas_info["type"] == 0: - txn_fee = gas_info["gas_price"] * gas_info["gas_limit"] - logger.info( - f""" - - Tips: {tip/1e18} - Transaction fee: {self.web3.fromWei(txn_fee, 'gwei'):.09f} {tkn_symbol(self.chain_id)} - Gas price: {gas_info["gas_price"]} gwei - Gas limit: {gas_info["gas_limit"]} - Txn type: 0 (Legacy) - """ - ) - if gas_info["type"] == 2: - txn_fee = gas_info["max_fee"] * gas_info["gas_limit"] - logger.info( - f""" - - Tips: {tip/1e18} - Max transaction fee: {self.web3.fromWei(txn_fee, 'gwei'):.18f} {tkn_symbol(self.chain_id)} - Max fee per gas: {gas_info["max_fee"]} gwei - Max priority fee per gas: {gas_info["priority_fee"]} gwei - Gas limit: {gas_info["gas_limit"]} - Txn type: 2 (EIP-1559) - """ - ) - - # Calculate profit - rev_usd = tip / 1e18 * price_trb_usd - costs_usd = txn_fee / 1e9 * price_native_token # convert gwei costs to eth, then to usd - profit_usd = rev_usd - costs_usd - logger.info(f"Estimated profit: ${round(profit_usd, 2)}") - logger.info(f"tip price: {round(rev_usd, 2)}, gas costs: {costs_usd}") - - percent_profit = ((profit_usd) / costs_usd) * 100 - logger.info(f"Estimated percent profit: {round(percent_profit, 2)}%") - if (self.expected_profit != "YOLO") and ( - isinstance(self.expected_profit, float) and percent_profit < self.expected_profit - ): - status.ok = False - status.error = "Estimated profitability below threshold." - logger.info(status.error) - # reset datafeed for a new suggestion if qtag wasn't selected in cli - if self.qtag_selected is False: - self.datafeed = None - return status - # reset autopay tip to check for tips again - self.autopaytip = 0 - # reset datafeed for a new suggestion if qtag wasn't selected in cli - if self.qtag_selected is False: - self.datafeed = None - - return status diff --git a/src/telliot_feeds/utils/stake_info.py b/src/telliot_feeds/utils/stake_info.py new file mode 100644 index 00000000..4c770914 --- /dev/null +++ b/src/telliot_feeds/utils/stake_info.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass, field +from typing import Deque, Optional +from collections import deque +from telliot_feeds.utils.log import get_logger + +logger = get_logger(__name__) + + +@dataclass +class StakeInfo: + """Check if a datafeed is in dispute + by tracking staker balance flucutations + and also tracking current oracle stake amount + keep a deque going for both + """ + + max_data: int = 2 + last_report: int = 0 + + stake_amount_history: Deque[int] = field(default_factory=deque, init=False, repr=False) + staker_balance_history: Deque[int] = field(default_factory=deque, init=False, repr=False) + + def __post_init__(self) -> None: + self.stake_amount_history = deque(maxlen=self.max_data) + self.staker_balance_history = deque(maxlen=self.max_data) + + def store_stake_amount(self, stake_amount: int) -> None: + """Add stake amount to deque and maintain a history of 2""" + self.stake_amount_history.append(stake_amount) + + def store_staker_balance(self, stake_balance: int) -> None: + """Add staker info to deque and maintain a history of 2""" + self.staker_balance_history.append(stake_balance) + + def update_last_report_time(self, last_report: int) -> None: + """Update report count""" + self.last_report = last_report + + @property + def last_report_time(self) -> int: + """Return report count""" + return self.last_report + + def is_in_dispute(self) -> bool: + """Check if staker has been disputed""" + if len(self.staker_balance_history) == self.max_data: + if self.staker_balance_history[-1] < self.staker_balance_history[-2]: + logger.warning("Your staked balance has decreased, account might be in dispute") + return True + return False + + def stake_amount_change(self) -> bool: + """Check oracle stake amount change""" + logger.info(f"Current Oracle stakeAmount: {self.stake_amount_history[-1] / 1e18!r}") + if len(self.stake_amount_history) == self.max_data: + if self.stake_amount_history[-1] < self.stake_amount_history[-2]: + logger.info("Oracle stake amount has decreased") + if self.stake_amount_history[-1] > self.stake_amount_history[-2]: + logger.info("Oracle stake amount has increased") + return True + return False + + @property + def stake_amount_gt_staker_balance(self) -> bool: + """Compare staker balance and oracle stake amount""" + if not self.stake_amount_history or not self.staker_balance_history: + logger.debug("Not enough data to compare stake amount and staker balance") + return False + if self.stake_amount_history[-1] > self.staker_balance_history[-1]: + logger.info("Staker balance is less than oracle stake amount") + return True + return False + + + @property + def current_stake_amount(self) -> int: + """Return the current stake amount""" + if self.stake_amount_history: + return self.stake_amount_history[-1] + else: + return 0 + + @property + def current_staker_balance(self) -> Optional[int]: + """Return the current staker balance""" + if self.staker_balance_history: + return self.staker_balance_history[-1] + else: + return None + + def update_staker_balance(self, amount: int) -> None: + """Update staker balance""" + balance = self.current_staker_balance + if balance is not None: + new_amount = balance + amount + self.store_staker_balance(new_amount) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 55663ccd..0a2c2865 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,8 +12,6 @@ from brownie import QueryDataStorage from brownie import TellorFlex from brownie import TellorPlayground -from brownie import TellorXMasterMock -from brownie import TellorXOracleMock from chained_accounts import ChainedAccount from chained_accounts import find_accounts from hexbytes import HexBytes @@ -27,7 +25,7 @@ from telliot_feeds.datasource import DataSource from telliot_feeds.dtypes.datapoint import datetime_now_utc from telliot_feeds.dtypes.datapoint import OptionalDataPoint -from telliot_feeds.reporters.tellor_flex import TellorFlexReporter +from telliot_feeds.reporters.tellor_360 import Tellor360Reporter @pytest.fixture(scope="module", autouse=True) @@ -222,16 +220,6 @@ def query_data_storage_contract(): return accounts[0].deploy(QueryDataStorage) -@pytest.fixture -def tellorx_oracle_mock_contract(): - return accounts[0].deploy(TellorXOracleMock) - - -@pytest.fixture -def tellorx_master_mock_contract(): - return accounts[0].deploy(TellorXMasterMock) - - @pytest.fixture(autouse=True) def multicall_contract(): # deploy multicall contract to brownie chain and add chain id to multicall module @@ -276,7 +264,7 @@ async def tellor_flex_reporter(mumbai_test_cfg, mock_flex_contract, mock_autopay account = core.get_account() - flex = core.get_tellorflex_contracts() + flex = core.get_tellor360_contracts() flex.oracle.address = mock_flex_contract.address flex.autopay.address = mock_autopay_contract.address flex.token.address = mock_token_contract.address @@ -284,9 +272,9 @@ async def tellor_flex_reporter(mumbai_test_cfg, mock_flex_contract, mock_autopay flex.oracle.connect() flex.token.connect() flex.autopay.connect() - flex = core.get_tellorflex_contracts() + flex = core.get_tellor360_contracts() - r = TellorFlexReporter( + r = Tellor360Reporter( oracle=flex.oracle, token=flex.token, autopay=flex.autopay, diff --git a/tests/reporters/test_360_reporter.py b/tests/reporters/test_360_reporter.py index a99318f3..dc7da1d0 100644 --- a/tests/reporters/test_360_reporter.py +++ b/tests/reporters/test_360_reporter.py @@ -43,9 +43,9 @@ async def test_report(tellor_360, caplog, guaranteed_price_source, mock_flex_con ) await r.report_once() - assert r.staker_info.stake_balance == int(10e18) + assert r.stake_info.current_staker_balance == int(10e18) # report count before first submission - assert r.staker_info.reports_count == 0 + assert "reports count: 0" in caplog.text # update stakeamount increase causes reporter to deposit more to keep reporting mock_token_contract.faucet(accounts[0].address) @@ -66,10 +66,10 @@ async def test_report(tellor_360, caplog, guaranteed_price_source, mock_flex_con await r.report_once() # staker balance increased due to updateStakeAmount call - assert r.staker_info.stake_balance == stake_amount + assert r.stake_info.current_stake_amount == stake_amount assert "Currently in reporter lock. Time left: 11:59" in caplog.text # 12hr # report count before second report - assert r.staker_info.reports_count == 1 + assert "reports count: 1" in caplog.text # decrease stakeAmount should increase reporting frequency mock_token_contract.approve(mock_flex_contract.address, mock_flex_contract.stakeAmount()) mock_flex_contract.depositStake(mock_flex_contract.stakeAmount()) @@ -86,7 +86,7 @@ async def test_report(tellor_360, caplog, guaranteed_price_source, mock_flex_con assert status.ok assert stake_amount == int(10e18) - assert r.staker_info.stake_balance == int(20e18) + assert r.stake_info.current_staker_balance == int(20e18) await r.report_once() assert "Currently in reporter lock. Time left: 5:59" in caplog.text # 6hr @@ -200,14 +200,14 @@ async def test_adding_stake(tellor_360, guaranteed_price_source): # first should deposits default stake _, status = await reporter.report_once() assert status.ok - assert reporter.staker_info.stake_balance == int(10e18), "Staker balance should be 10e18" + assert reporter.stake_info.current_staker_balance == int(10e18), "Staker balance should be 10e18" # stake more by by changing stake from default similar to how a stake amount chosen in cli # high stake to bypass reporter lock reporter = Tellor360Reporter(**reporter_kwargs, stake=900000) _, status = await reporter.report_once() assert status.ok - assert reporter.staker_info.stake_balance == pytest.approx(900000e18), "Staker balance should be 90000e18" + assert reporter.stake_info.current_staker_balance == pytest.approx(900000e18), "Staker balance should be 90000e18" @pytest.mark.asyncio @@ -274,8 +274,10 @@ async def mock_get_feed_and_tip(*args, **kwargs): # set datafeed to None so fetch_datafeed will call get_feed_and_tip reporter.datafeed = None await reporter.report(report_count=1) - reporter_lock = 43200 / math.floor(reporter.staker_info.stake_balance / reporter.stake_amount) - time_remaining = round(reporter.staker_info.last_report + reporter_lock - time.time()) + reporter_lock = 43200 / math.floor( + reporter.stake_info.current_staker_balance / reporter.stake_info.current_stake_amount + ) + time_remaining = round(reporter.stake_info.last_report_time + reporter_lock - time.time()) if time_remaining > 0: hr_min_sec = str(datetime.timedelta(seconds=time_remaining)) assert f"Currently in reporter lock. Time left: {hr_min_sec}" in caplog.text diff --git a/tests/reporters/test_autopay_multicalls.py b/tests/reporters/test_autopay_multicalls.py deleted file mode 100644 index 27715f5f..00000000 --- a/tests/reporters/test_autopay_multicalls.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Test multicall success outcomes requirement True and False""" -from unittest.mock import patch - -import pytest -from multicall import Call -from multicall import Multicall -from telliot_core.apps.core import TelliotCore -from web3.exceptions import ContractLogicError - -from telliot_feeds.reporters import reporter_autopay_utils -from telliot_feeds.reporters.reporter_autopay_utils import autopay_suggested_report -from telliot_feeds.reporters.reporter_autopay_utils import AutopayCalls -from telliot_feeds.reporters.reporter_autopay_utils import safe_multicall - - -def fake_call(calls: AutopayCalls): - """helper function returning consistent fake multicall""" - return [ - Call( - calls.autopay.address, - ["fakeFunction(bytes32)(uint256)", b""], - [["fake_key", None]], - ) - ] - - -@pytest.fixture(scope="function") -async def setup_autopay_call(mumbai_test_cfg, mock_autopay_contract) -> AutopayCalls: - async with TelliotCore(config=mumbai_test_cfg) as core: - - flex = core.get_tellorflex_contracts() - flex.autopay.address = mock_autopay_contract.address - flex.autopay.connect() - calls = AutopayCalls(flex.autopay) - return calls - - -@pytest.mark.asyncio -async def test_get_current_tips(setup_autopay_call): - """Test Multicall by calling getCurrentTip in autopay""" - calls: AutopayCalls = await setup_autopay_call - tips = await calls.get_current_tip(require_success=False) - assert tips["eth-usd-spot"] == 0 - - -@pytest.mark.asyncio -async def test_get_current_feeds(caplog, setup_autopay_call): - """Test getCurrentFeeds call in autopay using multicall""" - calls: AutopayCalls = await setup_autopay_call - # test proper function for success outcomes - boolean = [True, False] - for i in boolean: - tips = await calls.get_current_feeds(require_success=i) - assert tips["eth-usd-spot"] == () - assert tips[("eth-usd-spot", "current_time")] == 0 - assert tips[("eth-usd-spot", "three_mos_ago")] == 0 - - async def fake_function(require_success=True): - """fake function signature call that doesn't exist in autopay - should revert as a ContractLogicError""" - - return await safe_multicall(calls=fake_call(calls), endpoint=calls.w3, require_success=require_success) - - calls.get_current_feeds = fake_function - - tips = await calls.get_current_feeds(require_success=False) - assert tips["fake_key"] is None - - true_result = await calls.get_current_feeds(require_success=True) - assert true_result is None - assert "Contract reversion in multicall request" in caplog.text - - -@pytest.mark.asyncio -async def test_safe_multicall(caplog, setup_autopay_call): - """Test safe multicall with error handling""" - calls: AutopayCalls = await setup_autopay_call - - def raise_unsupported_block_num(): - raise ValueError({"code": -32000, "message": "unsupported block number 22071635"}) - - def raise_unexpected_rpc_error(): - raise ValueError({"code": -32000, "message": "unexpected rpc error nooooo"}) - - def raise_contract_reversion(): - raise ContractLogicError - - with patch.object(Multicall, "coroutine", side_effect=raise_unsupported_block_num): - - res = await safe_multicall(fake_call(calls), endpoint=calls.w3, require_success=True) - - assert res is None - assert "ValueError" in caplog.text - - with patch.object(Multicall, "coroutine", side_effect=raise_unexpected_rpc_error): - - res = await safe_multicall(fake_call(calls), endpoint=calls.w3, require_success=True) - - assert res is None - assert "ValueError" in caplog.text - - with patch.object(Multicall, "coroutine", side_effect=raise_contract_reversion): - - res = await safe_multicall(fake_call(calls), endpoint=calls.w3, require_success=True) - - assert res is None - assert "Contract reversion in multicall request" in caplog.text - - -@pytest.mark.asyncio -async def test_index_error(setup_autopay_call, caplog): - bad_response = { - "ric-usd-spot": (), - "disregard_boolean": None, - ("ric-usd-spot", "current_time"): None, - ("ric-usd-spot", "three_mos_ago"): None, - } - call: AutopayCalls = await setup_autopay_call - - async def fake_func(): - return bad_response - - call.get_current_feeds = fake_func - res = await call.reward_claim_status() - assert res == (None, None, None) - assert "No feeds balance to check" - - -@pytest.mark.asyncio -async def test_multicall_return_none(setup_autopay_call): - calls: AutopayCalls = await setup_autopay_call - - async def none(x, y, z): - return None - - reporter_autopay_utils.safe_multicall = none - resp = await calls.get_current_feeds() - assert resp is None - resp = await autopay_suggested_report(calls.autopay) - assert resp == (None, None) diff --git a/tests/reporters/test_flex_reporter.py b/tests/reporters/test_flex_reporter.py index 1c1af1b5..877e72a6 100644 --- a/tests/reporters/test_flex_reporter.py +++ b/tests/reporters/test_flex_reporter.py @@ -1,22 +1,24 @@ -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from brownie import chain from telliot_core.utils.response import ResponseStatus -from telliot_feeds.datafeed import DataFeed from telliot_feeds.feeds import CATALOG_FEEDS from telliot_feeds.feeds.matic_usd_feed import matic_usd_median_feed -from telliot_feeds.reporters.tellor_flex import TellorFlexReporter +from telliot_feeds.reporters.tellor_360 import Tellor360Reporter +# from telliot_feeds.datafeed import DataFeed -@pytest.mark.asyncio -async def test_YOLO_feed_suggestion(tellor_flex_reporter): - tellor_flex_reporter.expected_profit = "YOLO" - feed = await tellor_flex_reporter.fetch_datafeed() - assert feed is not None - assert isinstance(feed, DataFeed) +# @pytest.mark.asyncio +# async def test_YOLO_feed_suggestion(tellor_flex_reporter): +# # tellor_flex_reporter.expected_profit = "YOLO" +# tellor_flex_reporter.use_random_feeds = True +# feed = await tellor_flex_reporter.fetch_datafeed() + +# assert feed is not None +# assert isinstance(feed, DataFeed) @pytest.mark.asyncio @@ -24,15 +26,15 @@ async def test_ensure_profitable(tellor_flex_reporter): r = tellor_flex_reporter r.expected_profit = "YOLO" r.gas_info = {"type": 0, "gas_price": 1e9, "gas_limit": 300000} - unused_feed = matic_usd_median_feed - status = await r.ensure_profitable(unused_feed) + + status = await r.ensure_profitable() assert isinstance(status, ResponseStatus) assert status.ok r.chain_id = 1 r.expected_profit = 100.0 - status = await r.ensure_profitable(unused_feed) + status = await r.ensure_profitable() assert not status.ok @@ -83,10 +85,9 @@ async def test_fetch_gas_price_error(tellor_flex_reporter, caplog): # Test invalid gas price speed r = tellor_flex_reporter - with patch("telliot_feeds.reporters.tellor_flex.TellorFlexReporter.fetch_gas_price") as func: + with patch("telliot_feeds.reporters.tellor_360.Tellor360Reporter.fetch_gas_price") as func: func.return_value = None - r.stake = 1e100 staked, status = await r.ensure_staked() assert not staked assert not status.ok @@ -114,17 +115,16 @@ async def offline(): @pytest.mark.asyncio -async def test_dispute(tellor_flex_reporter: TellorFlexReporter): +async def test_dispute(tellor_flex_reporter, caplog): # Test when reporter in dispute r = tellor_flex_reporter + r.datafeed = matic_usd_median_feed + # initial balance higher than current balance, current balance is 0 since first time staking + r.stake_info.store_staker_balance(1) - async def in_dispute(_): - return True - - r.in_dispute = in_dispute - _, status = await r.report_once() + _ = await r.report_once() assert ( - "Staked balance has decreased, account might be in dispute; restart telliot to keep reporting" in status.error + "Your staked balance has decreased, account might be in dispute" in caplog.text ) @@ -132,9 +132,9 @@ async def in_dispute(_): async def test_reset_datafeed(tellor_flex_reporter): # Test when reporter selects qtag vs not # datafeed should persist if qtag selected - r: TellorFlexReporter = tellor_flex_reporter + r = tellor_flex_reporter - reporter1 = TellorFlexReporter( + reporter1 = Tellor360Reporter( oracle=r.oracle, token=r.token, autopay=r.autopay, @@ -145,7 +145,7 @@ async def test_reset_datafeed(tellor_flex_reporter): datafeed=CATALOG_FEEDS["trb-usd-spot"], min_native_token_balance=0, ) - reporter2 = TellorFlexReporter( + reporter2 = Tellor360Reporter( oracle=r.oracle, token=r.token, autopay=r.autopay, diff --git a/tests/reporters/test_interval_reporter.py b/tests/reporters/test_interval_reporter.py index cdb5a744..1799e66a 100644 --- a/tests/reporters/test_interval_reporter.py +++ b/tests/reporters/test_interval_reporter.py @@ -72,12 +72,12 @@ async def test_ensure_profitable(tellor_flex_reporter): assert r.expected_profit == "YOLO" - status = await r.ensure_profitable(r.datafeed) + status = await r.ensure_profitable() assert status.ok r.expected_profit = 1e10 - status = await r.ensure_profitable(r.datafeed) + status = await r.ensure_profitable() assert not status.ok assert status.error == "Estimated profitability below threshold." diff --git a/tests/reporters/test_stake_info.py b/tests/reporters/test_stake_info.py new file mode 100644 index 00000000..59e20a57 --- /dev/null +++ b/tests/reporters/test_stake_info.py @@ -0,0 +1,15 @@ +from telliot_feeds.utils.stake_info import StakeInfo + +stake_info = StakeInfo() + +def test_storage_length(): + assert len(stake_info.stake_amount_history) == 0 + # Add some stake amounts + stake_info.store_stake_amount(100) + assert len(stake_info.stake_amount_history) == 1 + stake_info.store_stake_amount(200) + assert len(stake_info.stake_amount_history) == 2 + stake_info.store_stake_amount(300) # This should cause 100 to be removed + assert len(stake_info.stake_amount_history) == 2 + assert stake_info.stake_amount_history[0] == 200 + assert stake_info.stake_amount_history[1] == 300 \ No newline at end of file diff --git a/tests/test_autopay.py b/tests/test_autopay.py deleted file mode 100644 index 4b58daa3..00000000 --- a/tests/test_autopay.py +++ /dev/null @@ -1,238 +0,0 @@ -import pytest -from brownie import accounts -from brownie import chain -from brownie.network.account import Account -from eth_abi import encode_single -from telliot_core.apps.core import TelliotCore -from telliot_core.utils.response import ResponseStatus -from telliot_core.utils.timestamp import TimeStamp -from web3 import Web3 - -from telliot_feeds.queries.query_catalog import query_catalog -from telliot_feeds.reporters.reporter_autopay_utils import autopay_suggested_report -from telliot_feeds.reporters.reporter_autopay_utils import get_feed_tip - - -@pytest.mark.asyncio -async def test_main( - mumbai_test_cfg, - mock_flex_contract, - mock_autopay_contract, - mock_token_contract, - multicall_contract, - mock_gov_contract, -): - async with TelliotCore(config=mumbai_test_cfg) as core: - - # get PubKey and PrivKey from config files - account = core.get_account() - - flex = core.get_tellorflex_contracts() - flex.oracle.address = mock_flex_contract.address - flex.autopay.address = mock_autopay_contract.address - flex.autopay.abi = mock_autopay_contract.abi - flex.token.address = mock_token_contract.address - - flex.oracle.connect() - flex.token.connect() - flex.autopay.connect() - - # mint token and send to reporter address - mock_token_contract.faucet(account.address) - - # send eth from brownie address to reporter address for txn fees - accounts[0].transfer(account.address, "1 ether") - assert Account(account.address).balance() == 1e18 - - # check governance address is brownie address - governance_address = await flex.oracle.get_governance_address() - assert governance_address == mock_gov_contract.address - - # check stake amount is ten - stake_amount = await flex.oracle.get_stake_amount() - assert stake_amount == 10 - - # approve token to be spent by oracle - mock_token_contract.approve(mock_flex_contract.address, 50e18, {"from": account.address}) - - # staking to oracle transaction - timestamp = TimeStamp.now().ts - _, status = await flex.oracle.write("depositStake", gas_limit=350000, legacy_gas_price=1, _amount=10 * 10**18) - # check txn is successful - assert status.ok - - # check staker information - staker_info, status = await flex.oracle.get_staker_info(Web3.toChecksumAddress(account.address)) - assert isinstance(status, ResponseStatus) - assert status.ok - assert staker_info == [pytest.approx(timestamp, 200), 10e18, 0, 0, 0] - - # get suggestion from telliot on query with highest tip - suggested_qtag, tip = await autopay_suggested_report(flex.autopay) - assert suggested_qtag is None - assert tip is None - - # mkr query id and query data - mkr_query_id = query_catalog._entries["mkr-usd-spot"].query.query_id - mkr_query_data = "0x" + query_catalog._entries["mkr-usd-spot"].query.query_data.hex() - # approve token to be spent by autopay contract - mock_token_contract.approve(mock_autopay_contract.address, 500e18, {"from": account.address}) - _, status = await flex.autopay.write( - "tip", - gas_limit=3500000, - legacy_gas_price=1, - _queryId="0x" + mkr_query_id.hex(), - _amount=int(10e18), - _queryData=mkr_query_data, - ) - # check txn is successful - assert status.ok - - # submit a tip in autopay for reporter to report mkr/usd price - current_tip, status = await flex.autopay.get_current_tip(mkr_query_id) - # check success of txn - assert status.ok - # check tip amount - assert current_tip == 10e18 - - # get suggestion from telliot on query with highest tip - suggested_qtag, tip = await autopay_suggested_report(flex.autopay) - assert suggested_qtag == "mkr-usd-spot" - assert tip == 10e18 - - # query id and query data for ric - ric_query_id = query_catalog._entries["ric-usd-spot"].query_id - ric_query_data = "0x" + query_catalog._entries["ric-usd-spot"].query.query_data.hex() - - _, status = await flex.autopay.write( - "tip", - gas_limit=3500000, - legacy_gas_price=1, - _queryId=ric_query_id, - _amount=int(20e18), - _queryData=ric_query_data, - ) - - assert status.ok - - current_tip, status = await flex.autopay.get_current_tip(ric_query_id) - assert status.ok - assert current_tip == 20e18 - - # get suggestion from telliot on query with highest tip - suggested_qtag, tip = await autopay_suggested_report(flex.autopay) - assert suggested_qtag == "ric-usd-spot" - assert tip == 20e18 - - # variables for feed setup and to get feedId - trb_query_id = query_catalog._entries["trb-usd-spot"].query_id - reward = 30 * 10**18 - interval = 100 - window = 99 - price_threshold = 0 - trb_query_data = "0x" + query_catalog._entries["trb-usd-spot"].query.query_data.hex() - - # setup a feed on autopay - response, status = await flex.autopay.write( - "setupDataFeed", - gas_limit=3500000, - legacy_gas_price=1, - _queryId=trb_query_id, - _reward=reward, - _startTime=timestamp, - _interval=interval, - _window=window, - _priceThreshold=price_threshold, - _rewardIncreasePerSecond=0, - _queryData=trb_query_data, - _amount=50 * 10**18, - ) - assert status.ok - - feed_id = response.logs[1].topics[2].hex() - - # get suggestion from telliot on query with highest tip - suggested_qtag, tip = await autopay_suggested_report(flex.autopay) - assert suggested_qtag == "trb-usd-spot" - assert tip == 30e18 - - tips = await get_feed_tip(query_catalog._entries["trb-usd-spot"].query.query_id, flex.autopay) - assert tips == 30e18 - - # submit report to oracle to get tip - _, status = await flex.oracle.write( - "submitValue", - gas_limit=350000, - legacy_gas_price=1, - _queryId=trb_query_id, - _value="0x" + encode_single("(uint256)", [3000]).hex(), - _nonce=0, - _queryData=trb_query_data, - ) - chain.snapshot() - - # get suggestion from telliot on query with highest tip - suggested_qtag, tip = await autopay_suggested_report(flex.autopay) - assert suggested_qtag == "ric-usd-spot" - assert tip == 20e18 - - # fast forward to avoid claiming tips buffer 12hr - chain.sleep(43201) - - # get timestamp trb's reported value - read_timestamp, status = await flex.autopay.read("getCurrentValue", _queryId=trb_query_id) - assert status.ok - - _, status = await flex.autopay.write( - "claimTip", - gas_limit=350000, - legacy_gas_price=1, - _feedId=feed_id, - _queryId=trb_query_id, - _timestamps=[read_timestamp[2]], - ) - assert status.ok - - # get suggestion from telliot on query with highest tip - suggested_qtag, tip = await autopay_suggested_report(flex.autopay) - assert suggested_qtag == "ric-usd-spot" - assert tip == 20e18 - - # submit report for onetime tip to oracle - # should reserve tip for first reporter - _, status = await flex.oracle.write( - "submitValue", - gas_limit=350000, - legacy_gas_price=1, - _queryId=ric_query_id, - _value="0x" + encode_single("(uint256)", [1000]).hex(), - _nonce=0, - _queryData=ric_query_data, - ) - assert status.ok - - # get suggestion from telliot on query with highest tip - suggested_qtag, tip = await autopay_suggested_report(flex.autopay) - assert suggested_qtag == "mkr-usd-spot" - assert tip == 10e18 - - # fast forward to avoid reporter time lock - chain.sleep(43201) - - # submit report for onetime tip to oracle - # should reserve tip for first reporter - _, status = await flex.oracle.write( - "submitValue", - gas_limit=350000, - legacy_gas_price=1, - _queryId=mkr_query_id, - _value="0x" + encode_single("(uint256)", [1000]).hex(), - _nonce=0, - _queryData=mkr_query_data, - ) - assert status.ok - - # get suggestion from telliot on query with highest tip - suggested_qtag, tip = await autopay_suggested_report(flex.autopay) - assert suggested_qtag is None - assert tip is None diff --git a/tests/test_bct_usd.py b/tests/test_bct_usd.py index 9dc81d0c..8fe94dcc 100644 --- a/tests/test_bct_usd.py +++ b/tests/test_bct_usd.py @@ -4,7 +4,7 @@ from web3.datastructures import AttributeDict from telliot_feeds.feeds.bct_usd_feed import bct_usd_median_feed -from telliot_feeds.reporters.tellor_flex import TellorFlexReporter +from telliot_feeds.reporters.tellor_360 import Tellor360Reporter @pytest.mark.asyncio @@ -16,7 +16,7 @@ async def test_bct_usd_reporter_submit_once( # get PubKey and PrivKey from config files account = core.get_account() - flex = core.get_tellorflex_contracts() + flex = core.get_tellor360_contracts() flex.oracle.address = mock_flex_contract.address flex.autopay.address = mock_autopay_contract.address flex.token.address = mock_token_contract.address @@ -31,7 +31,7 @@ async def test_bct_usd_reporter_submit_once( # send eth from brownie address to reporter address for txn fees accounts[1].transfer(account.address, "1 ether") - r = TellorFlexReporter( + r = Tellor360Reporter( endpoint=core.endpoint, account=account, chain_id=80001, diff --git a/tests/test_dai_usd.py b/tests/test_dai_usd.py index 9b23d2f9..4b275291 100644 --- a/tests/test_dai_usd.py +++ b/tests/test_dai_usd.py @@ -4,7 +4,7 @@ from web3.datastructures import AttributeDict from telliot_feeds.feeds.dai_usd_feed import dai_usd_median_feed -from telliot_feeds.reporters.tellor_flex import TellorFlexReporter +from telliot_feeds.reporters.tellor_360 import Tellor360Reporter @pytest.mark.asyncio @@ -16,7 +16,7 @@ async def test_dai_usd_reporter_submit_once( # get PubKey and PrivKey from config files account = core.get_account() - flex = core.get_tellorflex_contracts() + flex = core.get_tellor360_contracts() flex.oracle.address = mock_flex_contract.address flex.autopay.address = mock_autopay_contract.address flex.token.address = mock_token_contract.address @@ -31,7 +31,7 @@ async def test_dai_usd_reporter_submit_once( # send eth from brownie address to reporter address for txn fees accounts[2].transfer(account.address, "1 ether") - r = TellorFlexReporter( + r = Tellor360Reporter( endpoint=core.endpoint, account=account, chain_id=80001, diff --git a/tests/test_numeric_api_response_feed.py b/tests/test_numeric_api_response_feed.py index 9b1088ca..82975370 100644 --- a/tests/test_numeric_api_response_feed.py +++ b/tests/test_numeric_api_response_feed.py @@ -5,7 +5,7 @@ from telliot_feeds.datafeed import DataFeed from telliot_feeds.queries.numeric_api_response_query import NumericApiResponse -from telliot_feeds.reporters.tellor_flex import TellorFlexReporter +from telliot_feeds.reporters.tellor_360 import Tellor360Reporter from telliot_feeds.sources.numeric_api_response import NumericApiResponseSource url = "https://taylorswiftapi.herokuapp.com/get" @@ -25,7 +25,7 @@ async def test_api_reporter_submit_once( # get PubKey and PrivKey from config files account = core.get_account() - flex = core.get_tellorflex_contracts() + flex = core.get_tellor360_contracts() flex.oracle.address = mock_flex_contract.address flex.autopay.address = mock_autopay_contract.address flex.token.address = mock_token_contract.address @@ -40,7 +40,7 @@ async def test_api_reporter_submit_once( # send eth from brownie address to reporter address for txn fees accounts[1].transfer(account.address, "1 ether") - r = TellorFlexReporter( + r = Tellor360Reporter( endpoint=core.endpoint, account=account, chain_id=80001, From c006101ce1f72fc2e584a5ac4d9fcdccff6d547e Mon Sep 17 00:00:00 2001 From: akrem Date: Sun, 11 Jun 2023 12:56:17 -0400 Subject: [PATCH 02/28] tox --- src/telliot_feeds/cli/commands/stake.py | 20 +++++++++++--------- src/telliot_feeds/reporters/flashbot.py | 3 ++- src/telliot_feeds/reporters/tellor_360.py | 10 ++++++---- src/telliot_feeds/utils/stake_info.py | 10 ++++++---- tests/reporters/test_flex_reporter.py | 6 ++---- tests/reporters/test_stake_info.py | 3 ++- 6 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/telliot_feeds/cli/commands/stake.py b/src/telliot_feeds/cli/commands/stake.py index 3e16cfec..ae48b9c3 100644 --- a/src/telliot_feeds/cli/commands/stake.py +++ b/src/telliot_feeds/cli/commands/stake.py @@ -1,19 +1,17 @@ +from typing import Optional + import click from click.core import Context - from eth_utils import to_checksum_address - -from typing import Optional - from telliot_core.cli.utils import async_run -from telliot_feeds.utils.cfg import check_endpoint -from telliot_feeds.utils.cfg import setup_config from telliot_feeds.cli.utils import get_accounts_from_name -from telliot_feeds.cli.utils import valid_transaction_type from telliot_feeds.cli.utils import reporter_cli_core -from telliot_feeds.utils.reporter_utils import has_native_token_funds +from telliot_feeds.cli.utils import valid_transaction_type from telliot_feeds.reporters.tellor_360 import Tellor360Reporter +from telliot_feeds.utils.cfg import check_endpoint +from telliot_feeds.utils.cfg import setup_config +from telliot_feeds.utils.reporter_utils import has_native_token_funds @click.group() @@ -171,5 +169,9 @@ async def stake( "autopay": contracts.autopay, "token": contracts.token, } - if has_native_token_funds(to_checksum_address(account.address), core.endpoint.web3, min_balance=int(min_native_token_balance * 10**18)): + if has_native_token_funds( + to_checksum_address(account.address), + core.endpoint.web3, + min_balance=int(min_native_token_balance * 10**18), + ): _ = await Tellor360Reporter(**common_reporter_kwargs).deposit_stake(int(amount * 1e18)) diff --git a/src/telliot_feeds/reporters/flashbot.py b/src/telliot_feeds/reporters/flashbot.py index 6d15ecc2..77731515 100644 --- a/src/telliot_feeds/reporters/flashbot.py +++ b/src/telliot_feeds/reporters/flashbot.py @@ -3,6 +3,7 @@ Example of a subclassed Reporter. """ from typing import Any +from typing import Optional from typing import Tuple from chained_accounts import ChainedAccount @@ -41,7 +42,7 @@ def __init__(self, signature_account: ChainedAccount, *args: Any, **kwargs: Any) logger.info(f"Flashbots provider endpoint: {flashbots_uri}") flashbot(self.endpoint._web3, self.signature_account, flashbots_uri) - def send_transaction(self, tx_signed) -> Tuple[TxReceipt, ResponseStatus]: + def send_transaction(self, tx_signed: Any) -> Tuple[Optional[TxReceipt], ResponseStatus]: status = ResponseStatus() # Create bundle of one pre-signed, EIP-1559 (type 2) transaction bundle = [ diff --git a/src/telliot_feeds/reporters/tellor_360.py b/src/telliot_feeds/reporters/tellor_360.py index 94407be0..9e4d25d0 100644 --- a/src/telliot_feeds/reporters/tellor_360.py +++ b/src/telliot_feeds/reporters/tellor_360.py @@ -26,7 +26,6 @@ from telliot_feeds.reporters.rewards.time_based_rewards import get_time_based_rewards from telliot_feeds.reporters.tips.suggest_datafeed import get_feed_and_tip from telliot_feeds.reporters.tips.tip_amount import fetch_feed_tip -from telliot_feeds.utils.stake_info import StakeInfo from telliot_feeds.utils.log import get_logger from telliot_feeds.utils.reporter_utils import fee_history_priority_fee_estimate from telliot_feeds.utils.reporter_utils import get_native_token_feed @@ -34,6 +33,7 @@ from telliot_feeds.utils.reporter_utils import is_online from telliot_feeds.utils.reporter_utils import suggest_random_feed from telliot_feeds.utils.reporter_utils import tkn_symbol +from telliot_feeds.utils.stake_info import StakeInfo logger = get_logger(__name__) @@ -555,7 +555,9 @@ def submit_val_tx_gas_limit(self, submit_val_tx: ContractFunction) -> Tuple[Opti return None, error_status(msg, e=e, log=logger.error) return self.gas_limit, ResponseStatus() - async def assemble_submission_txn(self, datafeed: DataFeed) -> Tuple[Optional[ContractFunction], ResponseStatus]: + async def assemble_submission_txn( + self, datafeed: DataFeed[Any] + ) -> Tuple[Optional[ContractFunction], ResponseStatus]: """Assemble the submitValue transaction Params: datafeed: The datafeed object @@ -600,7 +602,7 @@ async def assemble_submission_txn(self, datafeed: DataFeed) -> Tuple[Optional[Co ) return params, ResponseStatus() - def send_transaction(self, tx_signed) -> Tuple[Optional[TxReceipt], ResponseStatus]: + def send_transaction(self, tx_signed: Any) -> Tuple[Optional[TxReceipt], ResponseStatus]: """Send a signed transaction to the blockchain and wait for confirmation Params: @@ -697,7 +699,7 @@ async def report_once( # Build transaction built_submit_val_tx = submit_val_tx.buildTransaction( - dict(nonce=acc_nonce, gas=gas_limit, chainId=self.chain_id, **gas_fees) + dict(nonce=acc_nonce, gas=gas_limit, chainId=self.chain_id, **gas_fees) # type: ignore ) lazy_unlock_account(self.account) local_account = self.account.local_account diff --git a/src/telliot_feeds/utils/stake_info.py b/src/telliot_feeds/utils/stake_info.py index 4c770914..5014c309 100644 --- a/src/telliot_feeds/utils/stake_info.py +++ b/src/telliot_feeds/utils/stake_info.py @@ -1,6 +1,9 @@ -from dataclasses import dataclass, field -from typing import Deque, Optional from collections import deque +from dataclasses import dataclass +from dataclasses import field +from typing import Deque +from typing import Optional + from telliot_feeds.utils.log import get_logger logger = get_logger(__name__) @@ -71,7 +74,6 @@ def stake_amount_gt_staker_balance(self) -> bool: return True return False - @property def current_stake_amount(self) -> int: """Return the current stake amount""" @@ -93,4 +95,4 @@ def update_staker_balance(self, amount: int) -> None: balance = self.current_staker_balance if balance is not None: new_amount = balance + amount - self.store_staker_balance(new_amount) \ No newline at end of file + self.store_staker_balance(new_amount) diff --git a/tests/reporters/test_flex_reporter.py b/tests/reporters/test_flex_reporter.py index 877e72a6..7aad2f3e 100644 --- a/tests/reporters/test_flex_reporter.py +++ b/tests/reporters/test_flex_reporter.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest from brownie import chain @@ -123,9 +123,7 @@ async def test_dispute(tellor_flex_reporter, caplog): r.stake_info.store_staker_balance(1) _ = await r.report_once() - assert ( - "Your staked balance has decreased, account might be in dispute" in caplog.text - ) + assert "Your staked balance has decreased, account might be in dispute" in caplog.text @pytest.mark.asyncio diff --git a/tests/reporters/test_stake_info.py b/tests/reporters/test_stake_info.py index 59e20a57..92f4dc4d 100644 --- a/tests/reporters/test_stake_info.py +++ b/tests/reporters/test_stake_info.py @@ -2,6 +2,7 @@ stake_info = StakeInfo() + def test_storage_length(): assert len(stake_info.stake_amount_history) == 0 # Add some stake amounts @@ -12,4 +13,4 @@ def test_storage_length(): stake_info.store_stake_amount(300) # This should cause 100 to be removed assert len(stake_info.stake_amount_history) == 2 assert stake_info.stake_amount_history[0] == 200 - assert stake_info.stake_amount_history[1] == 300 \ No newline at end of file + assert stake_info.stake_amount_history[1] == 300 From 193f864dd0ded2dde5baff59b985dca86847b32d Mon Sep 17 00:00:00 2001 From: akrem Date: Mon, 12 Jun 2023 20:20:59 -0400 Subject: [PATCH 03/28] update/merge tests --- .../SampleFlexReporter.sol | 136 +++++++++++++++--- .../SampleXReporter.sol | 63 -------- src/telliot_feeds/reporters/tellor_360.py | 18 +-- src/telliot_feeds/utils/stake_info.py | 7 +- tests/reporters/test_360_reporter.py | 126 ++++++++++++++++ tests/reporters/test_custom_360_reporter.py | 9 +- 6 files changed, 261 insertions(+), 98 deletions(-) delete mode 100644 contracts/SampleReporterContract/SampleXReporter.sol diff --git a/contracts/SampleReporterContract/SampleFlexReporter.sol b/contracts/SampleReporterContract/SampleFlexReporter.sol index ece3e4b1..82da0994 100644 --- a/contracts/SampleReporterContract/SampleFlexReporter.sol +++ b/contracts/SampleReporterContract/SampleFlexReporter.sol @@ -3,17 +3,62 @@ pragma solidity ^0.8.15; interface IFlex { function balanceOf(address account) external view returns (uint256); + + function allowance( + address _user, + address _spender + ) external view returns (uint256); + function approve(address _spender, uint256 _amount) external returns (bool); - function transfer(address recipient, uint256 amount) external returns (bool); - function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); + + function transfer( + address recipient, + uint256 amount + ) external returns (bool); + + function transferFrom( + address sender, + address recipient, + uint256 amount + ) external returns (bool); + function depositStake(uint256 _amount) external; + function requestStakingWithdraw(uint256 _amount) external; + function getCurrentTip(bytes32 _queryId) external view returns (uint256); - function submitValue(bytes32 _queryId, bytes calldata _value, uint256 _nonce, bytes memory _queryData) external; + + function submitValue( + bytes32 _queryId, + bytes calldata _value, + uint256 _nonce, + bytes memory _queryData + ) external; + function withdrawStake() external; + function stakeAmount() external view returns (uint256); - function getStakerInfo(address _staker) external view returns (uint256, uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256); + function getStakerInfo( + address _staker + ) + external + view + returns ( + uint256, + uint256, + uint256, + uint256, + uint256, + uint256, + uint256, + uint256, + uint256 + ); + + function getNewValueCountbyQueryId( + bytes32 _queryId + ) external view returns (uint256); } contract SampleFlexReporter { @@ -21,9 +66,14 @@ contract SampleFlexReporter { IFlex public autopay; IFlex public token; address public owner; - uint256 public profitThreshold;//inTRB - - constructor(address _oracle, address _autopay, address _token, uint256 _profitThreshold){ + uint256 public profitThreshold; //inTRB + + constructor( + address _oracle, + address _autopay, + address _token, + uint256 _profitThreshold + ) { oracle = IFlex(_oracle); autopay = IFlex(_autopay); token = IFlex(_token); @@ -31,7 +81,7 @@ contract SampleFlexReporter { profitThreshold = _profitThreshold; } - modifier onlyOwner { + modifier onlyOwner() { require(msg.sender == owner, "Only owner can call this function."); _; } @@ -40,7 +90,7 @@ contract SampleFlexReporter { owner = _newOwner; } - function depositStake(uint256 _amount) onlyOwner external{ + function depositStake(uint256 _amount) external onlyOwner { oracle.depositStake(_amount); } @@ -48,35 +98,79 @@ contract SampleFlexReporter { oracle.requestStakingWithdraw(_amount); } - function submitValue(bytes32 _queryId, bytes memory _value, uint256 _nonce, bytes memory _queryData) onlyOwner external{ + function submitValue( + bytes32 _queryId, + bytes memory _value, + uint256 _nonce, + bytes memory _queryData + ) external onlyOwner { uint256 _reward; _reward = autopay.getCurrentTip(_queryId); require(_reward > profitThreshold, "profit threshold not met"); - oracle.submitValue(_queryId,_value,_nonce,_queryData); + oracle.submitValue(_queryId, _value, _nonce, _queryData); + } + + function submitValueBypass( + bytes32 _queryId, + bytes memory _value, + uint256 _nonce, + bytes memory _queryData + ) external onlyOwner { + oracle.submitValue(_queryId, _value, _nonce, _queryData); } - function submitValueBypass(bytes32 _queryId, bytes memory _value, uint256 _nonce, bytes memory _queryData) onlyOwner external{ - oracle.submitValue(_queryId,_value,_nonce,_queryData); + function transfer(address _to, uint256 _amount) external onlyOwner { + token.transfer(_to, _amount); } - function transfer(address _to, uint256 _amount) external onlyOwner{ - token.transfer(_to,_amount); + function balanceOf(address account) external view returns (uint256) { + return token.balanceOf(address(this)); } - function approve(uint256 _amount) external onlyOwner{ - token.approve(address(oracle), _amount); + function allowance( + address owner, + address spender + ) external view returns (uint256) { + return token.allowance(address(this), address(oracle)); } - function withdrawStake() onlyOwner external{ + function approve(address spender, uint256 amount) external onlyOwner { + token.approve(address(oracle), amount); + } + + function withdrawStake() external onlyOwner { oracle.withdrawStake(); } - function getStakeAmount() external view returns (uint256){ + function getStakeAmount() external view returns (uint256) { return oracle.stakeAmount(); } - function getStakerInfo(address _stakerAddress) external view returns (uint256, uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256){ - return oracle.getStakerInfo(_stakerAddress); + function getStakerInfo( + address _stakerAddress + ) + external + view + returns ( + uint256, + uint256, + uint256, + uint256, + uint256, + uint256, + uint256, + uint256, + uint256 + ) + { + return oracle.getStakerInfo(address(this)); + } + + function getNewValueCountbyQueryId( + bytes32 _queryId + ) external view returns (uint256) { + return oracle.getNewValueCountbyQueryId(_queryId); } + receive() external payable {} } diff --git a/contracts/SampleReporterContract/SampleXReporter.sol b/contracts/SampleReporterContract/SampleXReporter.sol deleted file mode 100644 index 0541420b..00000000 --- a/contracts/SampleReporterContract/SampleXReporter.sol +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.15; - - -interface ITellor{ - function submitValue(bytes32 _queryId, bytes calldata _value, uint256 _nonce, bytes memory _queryData) external; - function getAddressVars(bytes32 _data) external view returns (address); - function depositStake() external; - function requestStakingWithdraw() external; - function getCurrentReward(bytes32 _queryId) external view returns(uint256, uint256); - function transfer(address _to, uint256 _amount)external returns (bool success); - function withdrawStake() external; -} - -contract Reporter { - ITellor public tellor; - ITellor public oracle; - address public owner; - uint256 public profitThreshold;//inTRB - - constructor(address _tellorAddress, address _oracleAddress, uint256 _profitThreshold){ - tellor = ITellor(_tellorAddress); - oracle = ITellor(_oracleAddress);//keccak256(_ORACLE_CONTRACT) - owner = msg.sender; - profitThreshold = _profitThreshold; - } - - modifier onlyOwner { - require(msg.sender == owner, "Only owner can call this function."); - _; - } - - function changeOwner(address _newOwner) external onlyOwner { - owner = _newOwner; - } - - function depositStake() onlyOwner external{ - tellor.depositStake(); - } - - function requestStakingWithdraw() external onlyOwner { - tellor.requestStakingWithdraw(); - } - - function submitValue(bytes32 _queryId, bytes memory _value, uint256 _nonce, bytes memory _queryData) onlyOwner external{ - uint256 _reward; - (,_reward) = oracle.getCurrentReward(_queryId); - require(_reward > profitThreshold, "profit threshold not met"); - oracle.submitValue(_queryId,_value,_nonce,_queryData); - } - - function submitValueBypass(bytes32 _queryId, bytes memory _value, uint256 _nonce, bytes memory _queryData) onlyOwner external{ - oracle.submitValue(_queryId,_value,_nonce,_queryData); - } - - function transfer(address _to, uint256 _amount) external onlyOwner{ - tellor.transfer(_to,_amount); - } - - function withdrawStake() onlyOwner external{ - tellor.withdrawStake(); - } -} diff --git a/src/telliot_feeds/reporters/tellor_360.py b/src/telliot_feeds/reporters/tellor_360.py index 9e4d25d0..ca801f33 100644 --- a/src/telliot_feeds/reporters/tellor_360.py +++ b/src/telliot_feeds/reporters/tellor_360.py @@ -145,7 +145,7 @@ async def get_stake_amount(self) -> Tuple[Optional[int], ResponseStatus]: """ response, status = await self.oracle.read("getStakeAmount") if not status.ok: - msg = "Unable to read current stake amount" + msg = f"Unable to read current stake amount: {status.e}" return None, error_status(msg, log=logger.error) stake_amount: int = response return stake_amount, status @@ -158,7 +158,7 @@ async def get_staker_details(self) -> Tuple[Optional[StakerInfo], ResponseStatus """ response, status = await self.oracle.read("getStakerInfo", _stakerAddress=self.acct_addr) if not status.ok: - msg = "Unable to read account staker info" + msg = f"Unable to read account staker info {status.e}" return None, error_status(msg, log=logger.error) staker_details = StakerInfo(*response) return staker_details, status @@ -167,7 +167,7 @@ async def get_current_balance(self) -> Tuple[Optional[int], ResponseStatus]: """Reads the current balance of the account""" response, status = await self.token.read("balanceOf", account=self.acct_addr) if not status.ok: - msg = "Unable to read account balance" + msg = f"Unable to read account balance: {status.e}" return None, error_status(msg, log=logger.error) wallet_balance: int = response logger.info(f"Current wallet TRB balance: {wallet_balance / 1e18!r}") @@ -205,7 +205,7 @@ async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: "allowance", owner=self.acct_addr, spender=self.oracle.address ) if not allowance_status.ok: - msg = "Unable to check allowance" + msg = f"Unable to check allowance: {allowance_status.e}" return False, error_status(msg, log=logger.error) logger.debug(f"Current allowance: {allowance / 1e18!r}") gas_params, status = await self.gas_params() @@ -225,7 +225,7 @@ async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: amount=amount, ) if not approve_status.ok: - msg = "Unable to approve staking" + msg = f"Unable to approve staking: {approve_status.e}" return False, error_status(msg, log=logger.error) logger.debug(f"Approve transaction status: {approve_receipt.status}, block: {approve_receipt.blockNumber}") # Add this to avoid nonce error from txn happening too fast @@ -242,7 +242,7 @@ async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: _amount=amount, ) if not deposit_status.ok: - msg = "Unable to deposit stake" + msg = f"Unable to deposit stake: {deposit_status.e}" return False, error_status(msg, log=logger.error) logger.debug(f"Deposit transaction status: {deposit_receipt.status}, block: {deposit_receipt.blockNumber}") return True, deposit_status @@ -269,8 +269,10 @@ async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: return False, status # store staker balance self.stake_info.store_staker_balance(staker_details.stake_balance) - # update report count + # update time of last report self.stake_info.update_last_report_time(staker_details.last_report) + # update reports count + self.stake_info.update_reports_count(staker_details.reports_count) # check if staker balance changed which means a value they submitted has been disputed # (logs when it does) self.stake_info.is_in_dispute() @@ -424,7 +426,7 @@ async def fetch_gas_price(self) -> Optional[float]: gas_price = (float(price_gwei) * multiplier) if price_gwei else None return gas_price - def get_fee_info(self) -> Tuple[Optional[float], Optional[float]]: + def get_fee_info(self) -> Tuple[Optional[float], Optional[int]]: """Calculate max fee and priority fee if not set for more info: https://web3py.readthedocs.io/en/v5/web3.eth.html?highlight=fee%20history#web3.eth.Eth.fee_history diff --git a/src/telliot_feeds/utils/stake_info.py b/src/telliot_feeds/utils/stake_info.py index 5014c309..344c5bbb 100644 --- a/src/telliot_feeds/utils/stake_info.py +++ b/src/telliot_feeds/utils/stake_info.py @@ -19,6 +19,7 @@ class StakeInfo: max_data: int = 2 last_report: int = 0 + reports_count: int = 0 stake_amount_history: Deque[int] = field(default_factory=deque, init=False, repr=False) staker_balance_history: Deque[int] = field(default_factory=deque, init=False, repr=False) @@ -36,9 +37,13 @@ def store_staker_balance(self, stake_balance: int) -> None: self.staker_balance_history.append(stake_balance) def update_last_report_time(self, last_report: int) -> None: - """Update report count""" + """Update last report time""" self.last_report = last_report + def update_reports_count(self, reports_count: int) -> None: + """Update report count""" + self.reports_count = reports_count + @property def last_report_time(self) -> int: """Return report count""" diff --git a/tests/reporters/test_360_reporter.py b/tests/reporters/test_360_reporter.py index dc7da1d0..41f303eb 100644 --- a/tests/reporters/test_360_reporter.py +++ b/tests/reporters/test_360_reporter.py @@ -8,14 +8,20 @@ import pytest from brownie import accounts from brownie import chain +from telliot_core.utils.response import ResponseStatus +from telliot_feeds.datafeed import DataFeed +from telliot_feeds.feeds.btc_usd_feed import btc_usd_median_feed from telliot_feeds.feeds.eth_usd_feed import eth_usd_median_feed from telliot_feeds.feeds.matic_usd_feed import matic_usd_median_feed from telliot_feeds.feeds.snapshot_feed import snapshot_manual_feed from telliot_feeds.feeds.trb_usd_feed import trb_usd_median_feed from telliot_feeds.reporters.rewards.time_based_rewards import get_time_based_rewards from telliot_feeds.reporters.tellor_360 import Tellor360Reporter +from telliot_feeds.utils.log import get_logger +from tests.utils.utils import passing_bool_w_status +logger = get_logger(__name__) txn_kwargs = {"gas_limit": 3500000, "legacy_gas_price": 1} CHAIN_ID = 80001 @@ -372,3 +378,123 @@ def mock_tbr(): # tip amount should not increase assert "Tips: 2.0" not in caplog.text assert "Tips: 3.0" not in caplog.text + + +@pytest.mark.asyncio +async def test_fetch_datafeed(tellor_flex_reporter): + r = tellor_flex_reporter + r.use_random_feeds = True + feed = await r.fetch_datafeed() + assert isinstance(feed, DataFeed) + + r.datafeed = None + assert r.datafeed is None + feed = await r.fetch_datafeed() + assert isinstance(feed, DataFeed) + + +@pytest.mark.skip(reason="EIP-1559 not supported by ganache") +@pytest.mark.asyncio +def test_get_fee_info(tellor_flex_reporter): + """Test fee info for type 2 transactions.""" + priority, max_fee = tellor_flex_reporter.get_fee_info() + + assert isinstance(priority, float) + assert isinstance(max_fee, (float, int)) + + +@pytest.mark.asyncio +async def test_get_num_reports_by_id(tellor_flex_reporter): + r = tellor_flex_reporter + num, status = await r.get_num_reports_by_id(matic_usd_median_feed.query.query_id) + + assert status.ok + assert isinstance(num, int) + + +@pytest.mark.asyncio +async def test_ensure_staked(tellor_flex_reporter): + """Test staking status of reporter.""" + staked, status = await tellor_flex_reporter.ensure_staked() + + assert staked + assert status.ok + + +@pytest.mark.asyncio +async def test_ensure_profitable(tellor_flex_reporter): + """Test profitability check.""" + r = tellor_flex_reporter + r.gas_info = {"type": 0, "gas_price": 1e9, "gas_limit": 300000} + + assert r.expected_profit == "YOLO" + + status = await r.ensure_profitable() + + assert status.ok + + r.expected_profit = 1e10 + status = await r.ensure_profitable() + + assert not status.ok + assert status.error == "Estimated profitability below threshold." + + +@pytest.mark.asyncio +async def test_ethgasstation_error(tellor_flex_reporter): + with mock.patch("telliot_feeds.reporters.tellor_360.Tellor360Reporter.fetch_gas_price") as func: + func.return_value = None + r = tellor_flex_reporter + r.stake = 1000000 * 10**18 + + staked, status = await r.ensure_staked() + assert not staked + assert not status.ok + + +@pytest.mark.asyncio +async def test_no_updated_value(tellor_flex_reporter, bad_datasource): + """Test handling for no updated value returned from datasource.""" + r = tellor_flex_reporter + r.datafeed = btc_usd_median_feed + + # Clear latest datapoint + r.datafeed.source._history.clear() + + # Replace PriceAggregator's sources with test source that + # returns no updated DataPoint + r.datafeed.source.sources = [bad_datasource] + + tx_receipt, status = await r.report_once() + + assert not tx_receipt + assert not status.ok + assert status.error == "Unable to retrieve updated datafeed value." + + +@pytest.mark.asyncio +async def test_ensure_reporter_lock_check_after_submitval_attempt( + tellor_flex_reporter, guaranteed_price_source, caplog +): + r = tellor_flex_reporter + + async def check_reporter_lock(*args, **kwargs): + logger.debug(f"Checking reporter lock: {time.time()}") + return ResponseStatus() + + r.ensure_staked = passing_bool_w_status + r.check_reporter_lock = check_reporter_lock + r.datafeed = matic_usd_median_feed + r.gas_limit = 350000 + + # Simulate fetching latest value + r.datafeed.source.sources = [guaranteed_price_source] + + def send_failure(*args, **kwargs): + raise Exception("bingo") + + with mock.patch("web3.eth.Eth.send_raw_transaction", side_effect=send_failure): + r.wait_period = 0 + await r.report(2) + assert "Send transaction failed: Exception('bingo')" in caplog.text + assert caplog.text.count("Checking reporter lock") == 2 diff --git a/tests/reporters/test_custom_360_reporter.py b/tests/reporters/test_custom_360_reporter.py index 6852d6ca..3619dbe9 100644 --- a/tests/reporters/test_custom_360_reporter.py +++ b/tests/reporters/test_custom_360_reporter.py @@ -22,8 +22,9 @@ logger = get_logger(__name__) - +# adding account to brownie accounts account_fake = accounts.add("023861e2ceee1ea600e43cbd203e9e01ea2ed059ee3326155453a1ed3b1113a9") +# adding account to telliot accounts try: account = find_accounts(name="fake_flex_custom_reporter_address", chain_id=80001)[0] except IndexError: @@ -86,7 +87,7 @@ def mock_confirm(*args, **kwargs): r = Tellor360Reporter( transaction_type=0, oracle=custom_contract, - token=contracts.token, + token=custom_contract, autopay=contracts.autopay, endpoint=core.endpoint, account=account, @@ -107,9 +108,7 @@ def mock_confirm(*args, **kwargs): ) # send eth from brownie address to reporter address for txn fees accounts[1].transfer(account.address, "10 ether") - - mock_token_contract.approve(mock_flex_contract.address, 10e18, {"from": account_fake}) - mock_flex_contract.depositStake(10e18, {"from": account_fake}) + accounts[1].transfer(mock_reporter_contract.address, "10 ether") return r From 0ba3cd1ac361e2d08304c48d239397fa001c3af4 Mon Sep 17 00:00:00 2001 From: akrem Date: Tue, 13 Jun 2023 09:23:27 -0400 Subject: [PATCH 04/28] consolidate more --- src/telliot_feeds/cli/commands/liquity.py | 2 + src/telliot_feeds/cli/commands/report.py | 2 + src/telliot_feeds/cli/commands/stake.py | 87 +---------------------- src/telliot_feeds/cli/utils.py | 76 +++++++++++--------- 4 files changed, 50 insertions(+), 117 deletions(-) diff --git a/src/telliot_feeds/cli/commands/liquity.py b/src/telliot_feeds/cli/commands/liquity.py index a32c663a..6875db1d 100644 --- a/src/telliot_feeds/cli/commands/liquity.py +++ b/src/telliot_feeds/cli/commands/liquity.py @@ -6,6 +6,7 @@ from web3 import Web3 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 @@ -26,6 +27,7 @@ def liquity_reporter() -> None: @liquity_reporter.command() @common_options +@common_reporter_options @click.option( "-clf", "--chainlink-feed", diff --git a/src/telliot_feeds/cli/commands/report.py b/src/telliot_feeds/cli/commands/report.py index 3127a9f4..5add4e1e 100644 --- a/src/telliot_feeds/cli/commands/report.py +++ b/src/telliot_feeds/cli/commands/report.py @@ -11,6 +11,7 @@ from telliot_feeds.cli.utils import build_feed_from_input 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 print_reporter_settings from telliot_feeds.cli.utils import reporter_cli_core @@ -52,6 +53,7 @@ def reporter() -> None: ) @reporter.command() @common_options +@common_reporter_options @click.option( "--build-feed", "-b", diff --git a/src/telliot_feeds/cli/commands/stake.py b/src/telliot_feeds/cli/commands/stake.py index ae48b9c3..fd4f3eb3 100644 --- a/src/telliot_feeds/cli/commands/stake.py +++ b/src/telliot_feeds/cli/commands/stake.py @@ -5,9 +5,9 @@ from eth_utils import to_checksum_address from telliot_core.cli.utils import async_run +from telliot_feeds.cli.utils import common_options from telliot_feeds.cli.utils import get_accounts_from_name from telliot_feeds.cli.utils import reporter_cli_core -from telliot_feeds.cli.utils import valid_transaction_type from telliot_feeds.reporters.tellor_360 import Tellor360Reporter from telliot_feeds.utils.cfg import check_endpoint from telliot_feeds.utils.cfg import setup_config @@ -20,90 +20,9 @@ def deposit_stake() -> None: pass -@click.option( - "--account", - "-a", - "account_str", - help="Name of account used for reporting, staking, etc. More info: run `telliot account --help`", - required=True, - nargs=1, - type=str, -) -@click.option("--amount", "-amt", "amount", help="Amount of tokens to stake", nargs=1, type=float, required=True) -@click.option( - "--gas-limit", - "-gl", - "gas_limit", - help="use custom gas limit", - nargs=1, - type=int, -) -@click.option( - "--max-fee", - "-mf", - "max_fee", - help="use custom maxFeePerGas (gwei)", - nargs=1, - type=float, - required=False, -) -@click.option( - "--priority-fee", - "-pf", - "priority_fee", - help="use custom maxPriorityFeePerGas (gwei)", - nargs=1, - type=float, - required=False, -) -@click.option( - "--gas-price", - "-gp", - "legacy_gas_price", - help="use custom legacy gasPrice (gwei)", - nargs=1, - type=int, - required=False, -) -@click.option( - "--tx-type", - "-tx", - "tx_type", - help="choose transaction type (0 for legacy txs, 2 for EIP-1559)", - type=click.UNPROCESSED, - required=False, - callback=valid_transaction_type, - default=2, -) -@click.option( - "--min-native-token-balance", - "-mnb", - "min_native_token_balance", - help="Minimum native token balance required to report. Denominated in ether.", - nargs=1, - type=float, - default=0.25, -) -@click.option( - "--gas-multiplier", - "-gm", - "gas_multiplier", - help="increase gas price by this percentage (default 1%) ie 5 = 5%", - nargs=1, - type=int, - default=1, # 1% above the gas price by web3 -) -@click.option( - "--max-priority-fee-range", - "-mpfr", - "max_priority_fee_range", - help="the maximum range of priority fees to use in gwei (default 80 gwei)", - nargs=1, - type=int, - default=80, # 80 gwei -) -@click.option("-pwd", "--password", type=str) @deposit_stake.command() +@common_options +@click.option("--amount", "-amt", "amount", help="Amount of tokens to stake", nargs=1, type=float, required=True) @click.pass_context @async_run async def stake( diff --git a/src/telliot_feeds/cli/utils.py b/src/telliot_feeds/cli/utils.py index 3a2f7f3b..68201a0e 100644 --- a/src/telliot_feeds/cli/utils.py +++ b/src/telliot_feeds/cli/utils.py @@ -290,6 +290,49 @@ def get_accounts_from_name(name: Optional[str]) -> list[ChainedAccount]: return accounts +def common_reporter_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Decorator for common options between reporter commands""" + + @click.option( + "--query-tag", + "-qt", + "query_tag", + help="select datafeed using query tag", + required=False, + nargs=1, + type=click.Choice([q.tag for q in query_catalog.find()]), + ) + @click.option( + "-wp", "--wait-period", help="wait period between feed suggestion calls", nargs=1, type=int, default=7 + ) + @click.option("--submit-once/--submit-continuous", default=False) + @click.option("--stake", "-s", "stake", help=STAKE_MESSAGE, nargs=1, type=float, default=10.0) + @click.option( + "--check-rewards/--no-check-rewards", + "-cr/-ncr", + "check_rewards", + default=True, + help=REWARDS_CHECK_MESSAGE, + ) + @click.option( + "--profit", + "-p", + "expected_profit", + help="lower threshold (inclusive) for expected percent profit", + nargs=1, + # User can omit profitability checks by specifying "YOLO" + type=click.UNPROCESSED, + required=False, + callback=parse_profit_input, + default="100.0", + ) + @functools.wraps(f) + def wrapper(*args: Any, **kwargs: Any) -> Any: + return f(*args, **kwargs) + + return wrapper + + def common_options(f: Callable[..., Any]) -> Callable[..., Any]: """Decorator for common options between commands""" @@ -302,15 +345,6 @@ def common_options(f: Callable[..., Any]) -> Callable[..., Any]: nargs=1, type=str, ) - @click.option( - "--query-tag", - "-qt", - "query_tag", - help="select datafeed using query tag", - required=False, - nargs=1, - type=click.Choice([q.tag for q in query_catalog.find()]), - ) @click.option("--gas-limit", "-gl", "gas_limit", help="use custom gas limit", nargs=1, type=int) @click.option( "--max-fee", "-mf", "max_fee", help="use custom maxFeePerGas (gwei)", nargs=1, type=float, required=False @@ -333,22 +367,6 @@ def common_options(f: Callable[..., Any]) -> Callable[..., Any]: type=int, required=False, ) - @click.option( - "-wp", "--wait-period", help="wait period between feed suggestion calls", nargs=1, type=int, default=7 - ) - @click.option( - "--profit", - "-p", - "expected_profit", - help="lower threshold (inclusive) for expected percent profit", - nargs=1, - # User can omit profitability checks by specifying "YOLO" - type=click.UNPROCESSED, - required=False, - callback=parse_profit_input, - default="100.0", - ) - @click.option("--submit-once/--submit-continuous", default=False) @click.option("-pwd", "--password", type=str) @click.option( "--tx-type", @@ -360,7 +378,6 @@ def common_options(f: Callable[..., Any]) -> Callable[..., Any]: callback=valid_transaction_type, default=2, ) - @click.option("--stake", "-s", "stake", help=STAKE_MESSAGE, nargs=1, type=float, default=10.0) @click.option( "--min-native-token-balance", "-mnb", @@ -370,13 +387,6 @@ def common_options(f: Callable[..., Any]) -> Callable[..., Any]: type=float, default=0.25, ) - @click.option( - "--check-rewards/--no-check-rewards", - "-cr/-ncr", - "check_rewards", - default=True, - help=REWARDS_CHECK_MESSAGE, - ) @click.option( "--gas-multiplier", "-gm", From 21b927f48e7c1a3bc97821aa38ecea508c0e76bd Mon Sep 17 00:00:00 2001 From: akrem Date: Wed, 21 Jun 2023 09:57:45 -0400 Subject: [PATCH 05/28] create GasFees object that sets gas fee params --- src/telliot_feeds/cli/commands/report.py | 18 +- .../cli/commands/request_withdraw_stake.py | 99 ++++ src/telliot_feeds/cli/commands/stake.py | 42 +- src/telliot_feeds/cli/main.py | 2 + src/telliot_feeds/cli/utils.py | 28 +- src/telliot_feeds/reporters/gas.py | 313 +++++++++++++ src/telliot_feeds/reporters/stake.py | 82 ++++ src/telliot_feeds/reporters/tellor_360.py | 434 ++++++------------ src/telliot_feeds/reporters/types.py | 34 ++ src/telliot_feeds/utils/reporter_utils.py | 4 +- 10 files changed, 726 insertions(+), 330 deletions(-) create mode 100644 src/telliot_feeds/cli/commands/request_withdraw_stake.py create mode 100644 src/telliot_feeds/reporters/gas.py create mode 100644 src/telliot_feeds/reporters/stake.py create mode 100644 src/telliot_feeds/reporters/types.py diff --git a/src/telliot_feeds/cli/commands/report.py b/src/telliot_feeds/cli/commands/report.py index 5add4e1e..760c8484 100644 --- a/src/telliot_feeds/cli/commands/report.py +++ b/src/telliot_feeds/cli/commands/report.py @@ -153,8 +153,9 @@ async def report( build_feed: bool, tx_type: int, gas_limit: int, - max_fee: Optional[float], - priority_fee: Optional[float], + base_fee_per_gas: Optional[float], + priority_fee_per_gas: Optional[float], + max_fee_per_gas: Optional[float], legacy_gas_price: Optional[int], expected_profit: str, submit_once: bool, @@ -188,9 +189,6 @@ async def report( return ctx.obj["CHAIN_ID"] = accounts[0].chains[0] # used in reporter_cli_core - # if max_fee flag is set, then priority_fee must also be set - if (max_fee is not None and priority_fee is None) or (max_fee is None and priority_fee is not None): - raise click.UsageError("Must specify both max fee and priority fee") # Initialize telliot core app using CLI context async with reporter_cli_core(ctx) as core: @@ -246,8 +244,9 @@ async def report( query_tag=query_tag, transaction_type=tx_type, gas_limit=gas_limit, - max_fee=max_fee, - priority_fee=priority_fee, + max_fee=base_fee_per_gas, + priority_fee=priority_fee_per_gas, + base_fee=base_fee_per_gas, legacy_gas_price=legacy_gas_price, expected_profit=expected_profit, chain_id=core.config.main.chain_id, @@ -301,8 +300,9 @@ async def report( "account": account, "datafeed": chosen_feed, "gas_limit": gas_limit, - "max_fee": max_fee, - "priority_fee": priority_fee, + "base_fee_per_gas": base_fee_per_gas, + "priority_fee_per_gas": priority_fee_per_gas, + "max_fee_per_gas": max_fee_per_gas, "legacy_gas_price": legacy_gas_price, "chain_id": core.config.main.chain_id, "wait_period": wait_period, diff --git a/src/telliot_feeds/cli/commands/request_withdraw_stake.py b/src/telliot_feeds/cli/commands/request_withdraw_stake.py new file mode 100644 index 00000000..f10af0a5 --- /dev/null +++ b/src/telliot_feeds/cli/commands/request_withdraw_stake.py @@ -0,0 +1,99 @@ +from typing import Optional + +import click +from click.core import Context +from telliot_core.cli.utils import async_run +from eth_utils import to_checksum_address +from telliot_feeds.cli.utils import common_options +from telliot_feeds.cli.utils import get_accounts_from_name +from telliot_feeds.cli.utils import reporter_cli_core +from telliot_feeds.reporters.gas import GasFees +from telliot_feeds.utils.cfg import check_endpoint +from telliot_feeds.utils.cfg import setup_config +from telliot_feeds.utils.reporter_utils import has_native_token_funds + + +@click.group() +def request_withdraw_stake() -> None: + """request to withdraw tokens from the Tellor oracle which locks them for 7 days.""" + pass + + +@request_withdraw_stake.command() +@common_options +@click.option( + "--amount", "-amt", "amount", help="Amount of tokens to request withdraw", nargs=1, type=float, required=True +) +@click.pass_context +@async_run +async def request_withdraw( + ctx: Context, + account_str: str, + amount: float, + tx_type: int, + gas_limit: int, + base_fee_per_gas: Optional[float], + priority_fee_per_gas: Optional[float], + max_fee_per_gas: Optional[float], + legacy_gas_price: Optional[int], + password: str, + min_native_token_balance: float, + gas_multiplier: int, + max_priority_fee_range: int, +) -> None: + """Request withdraw of tokens from oracle""" + ctx.obj["ACCOUNT_NAME"] = account_str + + accounts = get_accounts_from_name(account_str) + if not accounts: + return + + ctx.obj["CHAIN_ID"] = accounts[0].chains[0] # 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) + + 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) + + contracts = core.get_tellor360_contracts() + # set private key for oracle interaction calls + contracts.oracle._private_key = account.local_account.privateKey + + class_kwargs = { + "endpoint": core.endpoint, + "account": account, + "gas_limit": gas_limit, + "base_fee_per_gas": base_fee_per_gas, + "priority_fee_per_gas": priority_fee_per_gas, + "max_fee_per_gas": max_fee_per_gas, + "legacy_gas_price": legacy_gas_price, + "transaction_type": tx_type, + "gas_multiplier": gas_multiplier, + "max_priority_fee_range": max_priority_fee_range, + } + if has_native_token_funds( + to_checksum_address(account.address), + core.endpoint.web3, + min_balance=int(min_native_token_balance * 10**18), + ): + gas = GasFees(**class_kwargs) + gas.update_gas_fees() + gas_info = gas.get_gas_info_core() + _ = await contracts.oracle.write( + "requestStakingWithdraw", + _amount=int(amount * 1e18), + gas_limit=gas_limit, + **gas_info, + ) diff --git a/src/telliot_feeds/cli/commands/stake.py b/src/telliot_feeds/cli/commands/stake.py index fd4f3eb3..154899d3 100644 --- a/src/telliot_feeds/cli/commands/stake.py +++ b/src/telliot_feeds/cli/commands/stake.py @@ -8,7 +8,7 @@ from telliot_feeds.cli.utils import common_options from telliot_feeds.cli.utils import get_accounts_from_name from telliot_feeds.cli.utils import reporter_cli_core -from telliot_feeds.reporters.tellor_360 import Tellor360Reporter +from telliot_feeds.reporters.stake import Stake from telliot_feeds.utils.cfg import check_endpoint from telliot_feeds.utils.cfg import setup_config from telliot_feeds.utils.reporter_utils import has_native_token_funds @@ -31,8 +31,9 @@ async def stake( amount: float, tx_type: int, gas_limit: int, - max_fee: Optional[float], - priority_fee: Optional[float], + base_fee_per_gas: Optional[float], + priority_fee_per_gas: Optional[float], + max_fee_per_gas: Optional[float], legacy_gas_price: Optional[int], password: str, min_native_token_balance: float, @@ -47,9 +48,6 @@ async def stake( return ctx.obj["CHAIN_ID"] = accounts[0].chains[0] # used in reporter_cli_core - # if max_fee flag is set, then priority_fee must also be set - if (max_fee is not None and priority_fee is None) or (max_fee is None and priority_fee is not None): - raise click.UsageError("Must specify both max fee and priority fee") # Initialize telliot core app using CLI context async with reporter_cli_core(ctx) as core: @@ -73,24 +71,24 @@ async def stake( # set private key for oracle stake deposit txn contracts.oracle._private_key = account.local_account.privateKey - common_reporter_kwargs = { - "endpoint": core.endpoint, - "account": account, - "gas_limit": gas_limit, - "max_fee": max_fee, - "priority_fee": priority_fee, - "legacy_gas_price": legacy_gas_price, - "chain_id": core.config.main.chain_id, - "transaction_type": tx_type, - "gas_multiplier": gas_multiplier, - "max_priority_fee_range": max_priority_fee_range, - "oracle": contracts.oracle, - "autopay": contracts.autopay, - "token": contracts.token, - } if has_native_token_funds( to_checksum_address(account.address), core.endpoint.web3, min_balance=int(min_native_token_balance * 10**18), ): - _ = await Tellor360Reporter(**common_reporter_kwargs).deposit_stake(int(amount * 1e18)) + s = Stake( + endpoint=core.endpoint, + account=account, + transaction_type=tx_type, + gas_limit=gas_limit, + legacy_gas_price=legacy_gas_price, + gas_multiplier=gas_multiplier, + max_priority_fee_range=max_priority_fee_range, + priority_fee_per_gas=priority_fee_per_gas, + base_fee_per_gas=base_fee_per_gas, + max_fee_per_gas=max_fee_per_gas, + oracle=contracts.oracle, + token=contracts.token, + min_native_token_balance=min_native_token_balance, + ) + _ = await s.deposit_stake(int(amount * 1e18)) diff --git a/src/telliot_feeds/cli/main.py b/src/telliot_feeds/cli/main.py index 645c842f..e0f3888b 100644 --- a/src/telliot_feeds/cli/main.py +++ b/src/telliot_feeds/cli/main.py @@ -14,6 +14,7 @@ from telliot_feeds.cli.commands.liquity import liquity from telliot_feeds.cli.commands.query import query from telliot_feeds.cli.commands.report import report +from telliot_feeds.cli.commands.request_withdraw_stake import request_withdraw from telliot_feeds.cli.commands.settle import settle from telliot_feeds.cli.commands.stake import stake from telliot_feeds.utils.log import get_logger @@ -47,6 +48,7 @@ def main( main.add_command(account) main.add_command(stake) main.add_command(liquity) +main.add_command(request_withdraw) if __name__ == "__main__": main() diff --git a/src/telliot_feeds/cli/utils.py b/src/telliot_feeds/cli/utils.py index 68201a0e..20603ebc 100644 --- a/src/telliot_feeds/cli/utils.py +++ b/src/telliot_feeds/cli/utils.py @@ -32,10 +32,11 @@ def print_reporter_settings( signature_address: str, query_tag: str, gas_limit: int, + base_fee: Optional[float], priority_fee: Optional[float], + max_fee: Optional[float], expected_profit: str, chain_id: int, - max_fee: Optional[float], transaction_type: int, legacy_gas_price: Optional[int], reporting_diva_protocol: bool, @@ -347,17 +348,32 @@ def common_options(f: Callable[..., Any]) -> Callable[..., Any]: ) @click.option("--gas-limit", "-gl", "gas_limit", help="use custom gas limit", nargs=1, type=int) @click.option( - "--max-fee", "-mf", "max_fee", help="use custom maxFeePerGas (gwei)", nargs=1, type=float, required=False + "--max-fee", + "-mf", + "max_fee_per_gas", + help="use custom maxFeePerGas (gwei)", + nargs=1, + type=float, + required=False, ) @click.option( "--priority-fee", "-pf", - "priority_fee", + "priority_fee_per_gas", help="use custom maxPriorityFeePerGas (gwei)", nargs=1, type=float, required=False, ) + @click.option( + "--base-fee", + "-bf", + "base_fee_per_gas", + help="use custom baseFeePerGas (gwei)", + nargs=1, + type=float, + required=False, + ) @click.option( "--gas-price", "-gp", @@ -391,7 +407,7 @@ def common_options(f: Callable[..., Any]) -> Callable[..., Any]: "--gas-multiplier", "-gm", "gas_multiplier", - help="increase gas price by this percentage (default 1%) ie 5 = 5%", + help="increase gas price for legacy transaction by this percentage (default 1%) ie 5 = 5%", nargs=1, type=int, default=1, # 1% above the gas price by web3 @@ -400,10 +416,10 @@ def common_options(f: Callable[..., Any]) -> Callable[..., Any]: "--max-priority-fee-range", "-mpfr", "max_priority_fee_range", - help="the maximum range of priority fees to use in gwei (default 80 gwei)", + help="the maximum range of priority fees to use in gwei (default 3 gwei)", nargs=1, type=int, - default=80, # 80 gwei + default=3, # 3 gwei ) @functools.wraps(f) def wrapper(*args: Any, **kwargs: Any) -> Any: diff --git a/src/telliot_feeds/reporters/gas.py b/src/telliot_feeds/reporters/gas.py new file mode 100644 index 00000000..64bab4c4 --- /dev/null +++ b/src/telliot_feeds/reporters/gas.py @@ -0,0 +1,313 @@ +from typing import Any, Dict, List, Literal, Tuple +from typing import Optional +from typing import Union +from decimal import Decimal +from chained_accounts import ChainedAccount + +from web3 import Web3 +from web3.types import Wei + +from web3.types import FeeHistory + +from web3.contract import ContractFunction +from eth_utils import to_checksum_address + +from telliot_feeds.utils.log import get_logger +from telliot_core.apps.core import RPCEndpoint + +from telliot_core.utils.response import ResponseStatus, error_status + +from telliot_feeds.reporters.types import GasParams +from telliot_feeds.utils.reporter_utils import fee_history_priority_fee_estimate + + +logger = get_logger(__name__) + +FEE = Literal["maxPriorityFeePerGas", "maxFeePerGas", "gasPrice", "gas"] +FEES = Dict[FEE, Optional[Union[Wei, int]]] + + +class GasFees: + """Set gas fees for a transaction. + + call update_gas_prices() to update/set gas prices + then call get_gas_prices() to get the gas prices for a transaction assembled manually + or call get_gas_params_core() to get the gas prices for a transaction assembled by telliot_core + returns gas_info for the transaction type + """ + gas_info: GasParams = { + "maxPriorityFeePerGas": None, + "maxFeePerGas": None, + "gasPrice": None, + "gas": None, + } + + def __init__( + self, + endpoint: RPCEndpoint, + account: ChainedAccount, + transaction_type: int, + gas_limit: Optional[int] = None, # Amount of a transaction will need to be executed + legacy_gas_price: Optional[int] = None, # Type 0 transaction pre London fork in Gwei + gas_multiplier: int = 1, # 1 percent + # Max priority fee range that shouldn't be exceeded when auto calculating gas price in Gwei + max_priority_fee_range: int = 5, + priority_fee_per_gas: Optional[int] = None, + base_fee_per_gas: Optional[int] = None, + max_fee_per_gas: Optional[int] = None, + reward_percentile: Optional[List[float]] = None, + block_count: int = 10, # Number of blocks to use for gas price calculation + min_native_token_balance: int = 0, # Minimum native token balance to be considered for gas price calculation + ): + self.endpoint = endpoint + self.account = account + self.transaction_type = transaction_type + self.gas_limit = gas_limit + self.legacy_gas_price = legacy_gas_price + self.gas_multiplier = gas_multiplier + self.max_fee_per_gas = max_fee_per_gas + self.priority_fee_per_gas = priority_fee_per_gas + self.base_fee_per_gas = base_fee_per_gas + self.max_priority_fee_range = max_priority_fee_range + self.reward_percentile = reward_percentile or [25.0, 50.0, 75.0] + self.block_count = block_count + self.min_native_token_balance = min_native_token_balance + + self.acct_address = to_checksum_address(account.address) + self.web3: Web3 = endpoint._web3 + assert self.web3 is not None, f"Web3 is not initialized, check endpoint {endpoint}" + + def set_gas_info(self, fees: FEES) -> None: + """Set gas info""" + for fee in fees: + logger.debug(f"Setting gas info {fee} to {fees[fee]}") + self.gas_info[fee] = fees[fee] + + def _reset_gas_info(self) -> None: + """Reset gas info whenever gas price fails to update""" + self.gas_info = { + "maxPriorityFeePerGas": None, + "maxFeePerGas": None, + "gasPrice": None, + "gas": None, + } + + def get_gas_info(self) -> Dict[str, Any]: + """Get gas info and remove None values""" + return {k: v for k, v in self.gas_info.items() if v is not None} + + def get_gas_info_core(self) -> Dict[str, Optional[Union[int, float]]]: + """Convert gas info to gwei and update keys to follow telliot core naming convention""" + gas = self.gas_info + return { + "max_fee_per_gas": self.from_wei(gas["maxFeePerGas"]), + "max_priority_fee_per_gas": self.from_wei(gas["maxPriorityFeePerGas"]), + "legacy_gas_price": self.from_wei(gas["gasPrice"]), + } + + @staticmethod + def from_wei(value: Optional[Wei]) -> Optional[Union[int, float]]: + if value is None: + return None + converted_value = Web3.fromWei(value, "gwei") + if isinstance(converted_value, Decimal): + return float(converted_value) + return converted_value + + @staticmethod + def to_wei(value: Union[int, float, Decimal]) -> Wei: + return Web3.toWei(value, "gwei") + + def estimate_gas_amount(self, pre_built_transaction: ContractFunction) -> Tuple[Optional[int], ResponseStatus]: + """Estimate the gas amount for a given transaction + should also take in to account the possiblity of a wrong estimation + 'out of gas' error + """ + if self.gas_limit is not None: + self.set_gas_info({"gas": self.gas_limit}) + return self.gas_limit, ResponseStatus() + try: + gas = pre_built_transaction.estimateGas({'from': self.acct_address}) + self.set_gas_info({"gas": gas}) + return gas, ResponseStatus() + except Exception as e: + return None, error_status("Error estimating gas amount:", e, logger.error) + + def get_legacy_gas_price(self) -> Tuple[Optional[FEES], ResponseStatus]: + """Fetch the legacy gas price for a type 0 (legacy) transaction from the node + + Returns: + - gas_price in gwei int + """ + if self.legacy_gas_price is not None: + gas_price = self.to_wei(self.legacy_gas_price) + return {"gasPrice": gas_price}, ResponseStatus() + + try: + gas_price = self.web3.eth.gas_price + if gas_price is None: + return None, error_status("Error fetching legacy gas price, rpc returned None", log=logger.error) + multiplier = 1.0 + (self.gas_multiplier / 100.0) # 1 percent default extra + legacy_gas_price = int(gas_price * multiplier) + return {"gasPrice": Wei(legacy_gas_price)}, ResponseStatus() + except Exception as e: + return None, error_status("Error fetching legacy gas price", e=e, log=logger.error) + + def fee_history(self) -> Tuple[Optional[FeeHistory], ResponseStatus]: + """Fetch the fee history for a type 2 (EIP1559) transaction from the node + This function uses the `fee_history` method of `web3.eth` to get the history of + transaction fees from the EVM network. The number of blocks to retrieve and the + reward percentiles to compute are hardcoded to 5 blocks and [25, 50, 75] percentiles + meaning for each block get the 25th, 50th and 75th percentile of priority fee per gas + + Returns: + - fee_history: FeeHistory + """ + try: + fee_history = self.web3.eth.fee_history( + block_count=self.block_count, newest_block="latest", reward_percentiles=self.reward_percentile + ) + if fee_history is None: + return None, error_status("unable to fetch fee history from node") + # "base fee for the next block after the newest of the returned range" + return fee_history, ResponseStatus() + except Exception as e: + return None, error_status("Error fetching fee history", e=e) + + def get_max_fee(self, base_fee: Wei) -> Wei: + """Calculate the max fee for a type 2 (EIP1559) transaction""" + if self.max_fee_per_gas is not None: + return self.to_wei(self.max_fee_per_gas) + # if a block is 100% full, the base fee per gas is set to increase by 12.5% for the next block + percentage = 12.5 + # adding 12.5% to base_fee arg to ensure inclusion to at least the next block + # if not included in current block + return Wei(int(base_fee * (1 + (percentage / 100)))) + + def get_max_priority_fee(self, fee_history: Optional[FeeHistory] = None) -> Tuple[Optional[Wei], ResponseStatus]: + """Return the max priority fee for a type 2 (EIP1559) transaction + if priority fee is provided then return the provided priority fee + else return the max priority fee based on the fee history + + Args: + - fee_history: Optional[FeeHistory] + """ + priority_fee = self.priority_fee_per_gas + max_range = self.to_wei(self.max_priority_fee_range) + if priority_fee is not None: + return self.to_wei(priority_fee), ResponseStatus() + else: + try: + max_priority_fee = self.web3.eth._max_priority_fee() + return max_priority_fee if max_priority_fee < max_range else max_range, ResponseStatus() + except ValueError: + logger.warning( + "unable to fetch max priority fee from node using eth._max_priority_fee_per_gas method." + ) + if fee_history is not None: + return fee_history_priority_fee_estimate(fee_history, max_range), ResponseStatus() + else: + fee_history, status = self.fee_history() + if fee_history is None: + msg = "unable to fetch history to calculate max priority fee" + return None, error_status(msg, e=status.error, log=logger.error) + else: + return fee_history_priority_fee_estimate(fee_history, max_range), ResponseStatus() + + def get_base_fee(self) -> Tuple[Optional[Union[Wei, FeeHistory]], ResponseStatus]: + """Return the base fee for a type 2 (EIP1559) transaction. + if base fee is provided then return the provided base fee + else return the base fee based on the fee history + """ + base_fee = self.base_fee_per_gas + if base_fee is not None: + return self.to_wei(base_fee), ResponseStatus() + else: + fee_history, status = self.fee_history() + if fee_history is None: + msg = "unable to fetch history to set base fee" + return None, error_status(msg, e=status.error, log=logger.error) + else: + return fee_history, ResponseStatus() + + def get_eip1559_gas_price(self) -> Tuple[Optional[FEES], ResponseStatus]: + """Get the gas price for a type 2 (EIP1559) transaction + if at least two user args for gas aren't provided then fetch fee history and assemble the gas params + for args that aren't provided, else use the provided args to assemble the gas params + + Returns: + - Dict[str, Wei]; {priority_fee_per_gas: Wei, max_fee_per_gas: Wei} + - ResponseStatus + """ + fee_args = [self.base_fee_per_gas, self.priority_fee_per_gas, self.max_fee_per_gas] + provided_fee_args = [arg for arg in fee_args if arg is not None] + if len(provided_fee_args) < 2: + # Get base fee + _base_fee, status = self.get_base_fee() # returns FeeHistory if base fee is not provided + if _base_fee is None: + msg = "no base fee set" + return None, error_status(msg, e=status.error, log=logger.error) + else: + if not isinstance(_base_fee, int): + base_fee = _base_fee['baseFeePerGas'][-1] + fee_history = _base_fee + else: + fee_history = None + base_fee = _base_fee + # Get priority fee + priority_fee, status = self.get_max_priority_fee(fee_history) + if priority_fee is None: + msg = "no priority fee set" + return None, error_status(msg, e=status.error, log=logger.error) + # Get max fee + max_fee = self.get_max_fee(base_fee) + else: + # if two args are given then we can calculate the third + if self.base_fee_per_gas is not None and self.priority_fee_per_gas is not None: + # calculate max fee + max_fee = self.get_max_fee(self.to_wei(self.base_fee_per_gas)) + priority_fee = self.to_wei(self.priority_fee_per_gas) + + elif self.base_fee_per_gas is not None and self.max_fee_per_gas is not None: + # calculate priority fee + priority_fee = Wei(self.to_wei(self.max_fee_per_gas) - self.to_wei(self.base_fee_per_gas)) + max_fee = self.to_wei(self.max_fee_per_gas) + elif self.priority_fee_per_gas is not None and self.max_fee_per_gas is not None: + priority_fee = self.to_wei(self.priority_fee_per_gas) + max_fee = self.to_wei(self.max_fee_per_gas) + else: + # this should never happen? + return None, error_status("Error calculating EIP1559 gas price no args provided", logger.error) + + return { + "maxPriorityFeePerGas": priority_fee, + "maxFeePerGas": max_fee if max_fee > priority_fee else priority_fee + }, ResponseStatus() + + def update_gas_fees(self) -> ResponseStatus: + """Return gas parameters for a transaction""" + if self.transaction_type == 0: + legacy_gas_fees, status = self.get_legacy_gas_price() + if legacy_gas_fees is None: + self._reset_gas_info() + return error_status( + "Failed to update gas fees for legacy type transaction", e=status.error, log=logger.debug + ) + self.set_gas_info(legacy_gas_fees) + logger.debug(f"Gas price: {legacy_gas_fees} status: {status}") + return status + elif self.transaction_type == 2: + eip1559_gas_fees, status = self.get_eip1559_gas_price() + if eip1559_gas_fees is None: + self._reset_gas_info() + return error_status( + "Failed to update gas fees for EIP1559 type transaction", e=status.error, log=logger.debug + ) + self.set_gas_info(eip1559_gas_fees) + logger.debug(f"Gas fees: {eip1559_gas_fees} status: {status}") + return status + else: + self._reset_gas_info() + msg = "Failed to update gas fees" + e = f"Invalid transaction type: {self.transaction_type}" + return error_status(msg, e=e, log=logger.error) diff --git a/src/telliot_feeds/reporters/stake.py b/src/telliot_feeds/reporters/stake.py new file mode 100644 index 00000000..118dc11f --- /dev/null +++ b/src/telliot_feeds/reporters/stake.py @@ -0,0 +1,82 @@ +import time +from typing import Any +from typing import Tuple + +from telliot_feeds.reporters.gas import GasFees +from telliot_feeds.utils.log import get_logger + +from telliot_core.contract.contract import Contract +from telliot_core.utils.response import ResponseStatus, error_status + +logger = get_logger(__name__) + + +class Stake(GasFees): + """Stake tokens to tellor oracle""" + def __init__( + self, + oracle: Contract, + token: Contract, + *args: Any, + **kwargs: Any, + ): + super().__init__(*args, **kwargs) + self.oracle = oracle + self.token = token + + async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: + """Deposits stake into the oracle contract""" + # check allowance to avoid unnecessary approval transactions + allowance, allowance_status = await self.token.read( + "allowance", owner=self.acct_address, spender=self.oracle.address + ) + if not allowance_status.ok: + msg = "Unable to check allowance:" + return False, error_status(msg, allowance_status.e, log=logger.error) + + logger.debug(f"Current allowance: {allowance / 1e18!r}") + # calculate and set gas params + status = self.update_gas_fees() + if not status.ok: + return False, error_status("unable to calculate fees for approve txn", status.e, log=logger.error) + + fees = self.get_gas_info_core() + # if allowance is less than amount_to_stake then approve + if allowance < amount: + # Approve token spending + logger.info(f"Approving {self.oracle.address} token spending: {amount}...") + approve_receipt, approve_status = await self.token.write( + func_name="approve", + gas_limit=self.gas_limit, + # have to convert to gwei because of telliot_core where numbers are converted to wei + # consider changing this in telliot_core + spender=self.oracle.address, + amount=amount, + **fees, + ) + if not approve_status.ok: + msg = "Unable to approve staking: " + return False, error_status(msg, approve_status.e, log=logger.error) + logger.debug(f"Approve transaction status: {approve_receipt.status}, block: {approve_receipt.blockNumber}") + # Add this to avoid nonce error from txn happening too fast + time.sleep(1) + + # deposit stake + logger.info(f"Now depositing stake: {amount}...") + # calculate and set gas params + status = self.update_gas_fees() + if not status.ok: + return False, error_status("unable to calculate fees for deposit txn", status.e, log=logger.error) + + fees = self.get_gas_info_core() + deposit_receipt, deposit_status = await self.oracle.write( + func_name="depositStake", + gas_limit=self.gas_limit, + _amount=amount, + **fees, + ) + if not deposit_status.ok: + msg = "Unable to deposit stake!" + return False, error_status(msg, deposit_status.e, log=logger.error) + logger.debug(f"Deposit transaction status: {deposit_receipt.status}, block: {deposit_receipt.blockNumber}") + return True, deposit_status diff --git a/src/telliot_feeds/reporters/tellor_360.py b/src/telliot_feeds/reporters/tellor_360.py index ca801f33..24ebdff3 100644 --- a/src/telliot_feeds/reporters/tellor_360.py +++ b/src/telliot_feeds/reporters/tellor_360.py @@ -1,33 +1,33 @@ import asyncio import math import time -from dataclasses import dataclass from datetime import timedelta from typing import Any +from typing import Dict from typing import Optional from typing import Tuple from typing import Union -from chained_accounts import ChainedAccount from eth_abi.exceptions import EncodingTypeError from eth_utils import to_checksum_address from telliot_core.contract.contract import Contract -from telliot_core.model.endpoints import RPCEndpoint from telliot_core.utils.key_helpers import lazy_unlock_account from telliot_core.utils.response import error_status from telliot_core.utils.response import ResponseStatus -from web3 import Web3 from web3.contract import ContractFunction +from web3.types import TxParams from web3.types import TxReceipt from telliot_feeds.constants import CHAINS_WITH_TBR from telliot_feeds.feeds import DataFeed from telliot_feeds.feeds.trb_usd_feed import trb_usd_median_feed +from telliot_feeds.reporters.gas import GasFees from telliot_feeds.reporters.rewards.time_based_rewards import get_time_based_rewards from telliot_feeds.reporters.tips.suggest_datafeed import get_feed_and_tip from telliot_feeds.reporters.tips.tip_amount import fetch_feed_tip +from telliot_feeds.reporters.types import GasParams +from telliot_feeds.reporters.types import StakerInfo from telliot_feeds.utils.log import get_logger -from telliot_feeds.utils.reporter_utils import fee_history_priority_fee_estimate from telliot_feeds.utils.reporter_utils import get_native_token_feed from telliot_feeds.utils.reporter_utils import has_native_token_funds from telliot_feeds.utils.reporter_utils import is_online @@ -38,104 +38,43 @@ logger = get_logger(__name__) -@dataclass -class StakerInfo: - """Data types for staker info - start_date: TimeStamp - stake_balance: int - locked_balance: int - reward_debt: int - last_report: TimeStamp - reports_count: int - gov_vote_count: int - vote_count: int - in_total_stakers: bool - """ - - start_date: int - stake_balance: int - locked_balance: int - reward_debt: int - last_report: int - reports_count: int - gov_vote_count: int - vote_count: int - in_total_stakers: bool - - -@dataclass -class GasParams: - priority_fee: Optional[float] = None - max_fee: Optional[float] = None - gas_price_in_gwei: Union[float, int, None] = None - - -class Tellor360Reporter: +class Tellor360Reporter(GasFees): """Reports values from given datafeeds to a TellorFlex.""" def __init__( self, - endpoint: RPCEndpoint, - account: ChainedAccount, - chain_id: int, oracle: Contract, token: Contract, + min_native_token_balance: int, autopay: Contract, + chain_id: int, datafeed: Optional[DataFeed[Any]] = None, expected_profit: Union[str, float] = "YOLO", - transaction_type: int = 2, - gas_limit: Optional[int] = None, - max_fee: int = 0, - priority_fee: float = 0.0, - legacy_gas_price: Optional[int] = None, - gas_multiplier: int = 1, # 1 percent - max_priority_fee_range: int = 80, # 80 gwei wait_period: int = 7, - min_native_token_balance: int = 10**18, check_rewards: bool = True, ignore_tbr: bool = False, # relevant only for eth-mainnet and eth-testnets stake: float = 0, use_random_feeds: bool = False, + **kwargs: Any, ) -> None: - self.endpoint = endpoint - self.account = account - self.chain_id = chain_id + super().__init__(**kwargs) self.oracle = oracle self.token = token + self.min_native_token_balance = min_native_token_balance self.autopay = autopay - # datafeed stuff self.datafeed = datafeed self.use_random_feeds: bool = use_random_feeds self.qtag_selected = False if self.datafeed is None else True - # profitibility stuff self.expected_profit = expected_profit - # stake amount stuff self.stake: float = stake self.stake_info = StakeInfo() - - self.min_native_token_balance = min_native_token_balance - # check rewards bool flag self.check_rewards: bool = check_rewards self.autopaytip = 0 - self.web3: Web3 = self.endpoint.web3 - # ignore tbr bool flag to optionally disregard time based rewards self.ignore_tbr = ignore_tbr - self.last_submission_timestamp = 0 - # gas stuff - self.transaction_type = transaction_type - self.gas_limit = gas_limit - self.max_fee = max_fee self.wait_period = wait_period - self.priority_fee = priority_fee - self.legacy_gas_price = legacy_gas_price - self.gas_multiplier = gas_multiplier - self.max_priority_fee_range = max_priority_fee_range - self.gas_info: dict[str, Union[float, int]] = {} - - self.acct_addr = to_checksum_address(account.address) + self.chain_id = chain_id + self.acct_addr = to_checksum_address(self.account.address) logger.info(f"Reporting with account: {self.acct_addr}") - # TODO: why is this here? - # assert self.acct_addr == to_checksum_address(self.account.address) async def get_stake_amount(self) -> Tuple[Optional[int], ResponseStatus]: """Reads the current stake amount from the oracle contract @@ -145,8 +84,8 @@ async def get_stake_amount(self) -> Tuple[Optional[int], ResponseStatus]: """ response, status = await self.oracle.read("getStakeAmount") if not status.ok: - msg = f"Unable to read current stake amount: {status.e}" - return None, error_status(msg, log=logger.error) + msg = "Unable to read current stake amount" + return None, error_status(msg, status.e, log=logger.error) stake_amount: int = response return stake_amount, status @@ -158,46 +97,21 @@ async def get_staker_details(self) -> Tuple[Optional[StakerInfo], ResponseStatus """ response, status = await self.oracle.read("getStakerInfo", _stakerAddress=self.acct_addr) if not status.ok: - msg = f"Unable to read account staker info {status.e}" - return None, error_status(msg, log=logger.error) + msg = "Unable to read account staker info:" + return None, error_status(msg, status.e, log=logger.error) staker_details = StakerInfo(*response) return staker_details, status - async def get_current_balance(self) -> Tuple[Optional[int], ResponseStatus]: + async def get_current_token_balance(self) -> Tuple[Optional[int], ResponseStatus]: """Reads the current balance of the account""" response, status = await self.token.read("balanceOf", account=self.acct_addr) if not status.ok: - msg = f"Unable to read account balance: {status.e}" - return None, error_status(msg, log=logger.error) + msg = "Unable to read account balance:" + return None, error_status(msg, status.e, log=logger.error) wallet_balance: int = response logger.info(f"Current wallet TRB balance: {wallet_balance / 1e18!r}") return wallet_balance, status - async def gas_params(self) -> Tuple[Optional[GasParams], ResponseStatus]: - """Returns the gas params for the transaction - - Returns: - - priority_fee: float, the priority fee in gwei - - max_fee: int, the max fee in wei - - gas_price_in_gwei: float, the gas price in gwei - """ - if self.transaction_type == 2: - priority_fee, max_fee = self.get_fee_info() - if priority_fee is None or max_fee is None: - return None, error_status("Unable to suggest type 2 txn fees", log=logger.error) - return GasParams(priority_fee=priority_fee, max_fee=max_fee), ResponseStatus() - - else: - # Fetch legacy gas price if not provided by user - if self.legacy_gas_price is None: - gas_price_in_gwei = await self.fetch_gas_price() - if not gas_price_in_gwei: - note = "Unable to fetch gas price for staking tx type 0" - return None, error_status(note, log=logger.warning) - else: - gas_price_in_gwei = self.legacy_gas_price - return GasParams(gas_price_in_gwei=gas_price_in_gwei), ResponseStatus() - async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: """Deposits stake into the oracle contract""" # check allowance to avoid unnecessary approval transactions @@ -205,45 +119,48 @@ async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: "allowance", owner=self.acct_addr, spender=self.oracle.address ) if not allowance_status.ok: - msg = f"Unable to check allowance: {allowance_status.e}" - return False, error_status(msg, log=logger.error) + msg = "Unable to check allowance:" + return False, error_status(msg, allowance_status.e, log=logger.error) + logger.debug(f"Current allowance: {allowance / 1e18!r}") - gas_params, status = await self.gas_params() - if not status.ok or not gas_params: - return False, status + # calculate and set gas params + status = self.update_gas_fees() + if not status.ok: + return False, error_status("unable to calculate fees for approve/deposit txn", status.e, log=logger.error) + + fees = self.get_gas_info_core() + logger.debug(f"Gas fees: {fees}") # if allowance is less than amount_to_stake then approve if allowance < amount: # Approve token spending - logger.info("Approving token spending") + logger.info(f"Approving {self.oracle.address} token spending: {amount}...") approve_receipt, approve_status = await self.token.write( func_name="approve", gas_limit=self.gas_limit, - max_priority_fee_per_gas=gas_params.priority_fee, - max_fee_per_gas=gas_params.max_fee, - legacy_gas_price=gas_params.gas_price_in_gwei, + # have to convert to gwei because of telliot_core where numbers are converted to wei + # consider changing this in telliot_core spender=self.oracle.address, amount=amount, + **fees, ) if not approve_status.ok: - msg = f"Unable to approve staking: {approve_status.e}" - return False, error_status(msg, log=logger.error) + msg = "Unable to approve staking: " + return False, error_status(msg, approve_status.e, log=logger.error) logger.debug(f"Approve transaction status: {approve_receipt.status}, block: {approve_receipt.blockNumber}") # Add this to avoid nonce error from txn happening too fast time.sleep(1) # deposit stake - logger.info("Depositing stake") + logger.info(f"Now depositing stake: {amount}...") deposit_receipt, deposit_status = await self.oracle.write( func_name="depositStake", gas_limit=self.gas_limit, - max_priority_fee_per_gas=gas_params.priority_fee, - max_fee_per_gas=gas_params.max_fee, - legacy_gas_price=gas_params.gas_price_in_gwei, _amount=amount, + **fees, ) if not deposit_status.ok: - msg = f"Unable to deposit stake: {deposit_status.e}" - return False, error_status(msg, log=logger.error) + msg = "Unable to deposit stake!" + return False, error_status(msg, deposit_status.e, log=logger.error) logger.debug(f"Deposit transaction status: {deposit_receipt.status}, block: {deposit_receipt.blockNumber}") return True, deposit_status @@ -304,7 +221,7 @@ async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: amount_to_stake = max(int(to_stake_amount_1), int(to_stake_amount_2)) # check TRB wallet balance! - wallet_balance, wallet_balance_status = await self.get_current_balance() + wallet_balance, wallet_balance_status = await self.get_current_token_balance() if not wallet_balance or not wallet_balance_status.ok: return False, wallet_balance_status @@ -407,56 +324,6 @@ async def fetch_datafeed(self) -> Optional[DataFeed[Any]]: return self.datafeed - async def fetch_gas_price(self) -> Optional[float]: - """Fetches the current gas price from an EVM network and returns - an adjusted gas price. - - Returns: - An optional integer representing the adjusted gas price in wei, or - None if the gas price could not be retrieved. - """ - try: - price = self.web3.eth.gas_price - price_gwei = self.web3.fromWei(price, "gwei") - except Exception as e: - logger.error(f"Error fetching gas price: {e}") - return None - # increase gas price by 1.0 + gas_multiplier - multiplier = 1.0 + (self.gas_multiplier / 100.0) - gas_price = (float(price_gwei) * multiplier) if price_gwei else None - return gas_price - - def get_fee_info(self) -> Tuple[Optional[float], Optional[int]]: - """Calculate max fee and priority fee if not set - for more info: - https://web3py.readthedocs.io/en/v5/web3.eth.html?highlight=fee%20history#web3.eth.Eth.fee_history - """ - if self.max_fee is None: - try: - fee_history = self.web3.eth.fee_history( - block_count=5, newest_block="latest", reward_percentiles=[25, 50, 75] - ) - # "base fee for the next block after the newest of the returned range" - base_fee = fee_history.baseFeePerGas[-1] / 1e9 - # estimate priority fee from fee history - priority_fee_max = int(self.max_priority_fee_range * 1e9) # convert to wei - priority_fee = fee_history_priority_fee_estimate(fee_history, priority_fee_max=priority_fee_max) / 1e9 - max_fee = base_fee + priority_fee - return priority_fee, max_fee - except Exception as e: - logger.warning(f"Error in calculating gas fees: {e}") - return None, None - return self.priority_fee, self.max_fee - - async def get_num_reports_by_id(self, query_id: bytes) -> Tuple[int, ResponseStatus]: - count, read_status = await self.oracle.read(func_name="getNewValueCountbyQueryId", _queryId=query_id) - return count, read_status - - def has_native_token(self) -> bool: - """Check if account has native token funds for a network for gas fees - of at least min_native_token_balance that is set in the cli""" - return has_native_token_funds(self.acct_addr, self.web3, min_balance=self.min_native_token_balance) - async def ensure_profitable(self) -> ResponseStatus: status = ResponseStatus() @@ -479,35 +346,27 @@ async def ensure_profitable(self) -> ResponseStatus: gas_info = self.gas_info - if gas_info["type"] == 0: - txn_fee = int(gas_info["gas_price"] * gas_info["gas_limit"]) - logger.info( - f""" - - Tips: {tip/1e18} - Transaction fee: {self.web3.fromWei(txn_fee, 'gwei'):.09f} {tkn_symbol(self.chain_id)} - Gas price: {gas_info["gas_price"]} gwei - Gas limit: {gas_info["gas_limit"]} - Txn type: 0 (Legacy) - """ - ) - if gas_info["type"] == 2: - txn_fee = int(gas_info["max_fee"] * gas_info["gas_limit"]) - logger.info( - f""" - - Tips: {tip/1e18} - Max transaction fee: {self.web3.fromWei(txn_fee, 'gwei'):.18f} {tkn_symbol(self.chain_id)} - Max fee per gas: {gas_info["max_fee"]} gwei - Max priority fee per gas: {gas_info["priority_fee"]} gwei - Gas limit: {gas_info["gas_limit"]} - Txn type: 2 (EIP-1559) - """ - ) + m = gas_info["maxFeePerGas"] if gas_info["maxFeePerGas"] else gas_info["gasPrice"] + # multiply gasPrice by gasLimit + if m is None: + return error_status("Unable to calculate profitablity, no gas fees set", log=logger.warning) + max_fee = float(self.from_wei(m)) # type: ignore + gas_ = float(self.from_wei(gas_info["gas"])) # type: ignore + txn_fee = max_fee * gas_ + logger.info( + f"""\n + Tips: {tip/1e18} + Transaction fee: {txn_fee} {tkn_symbol(self.chain_id)} + Gas limit: {gas_info["gas"]} + Gas price: {self.from_wei(gas_info["gasPrice"])} gwei + Max fee per gas: {self.from_wei(gas_info["maxFeePerGas"])} gwei + Max priority fee per gas: {self.from_wei(gas_info["maxPriorityFeePerGas"])} gwei + Txn type: {self.transaction_type}\n""" + ) # Calculate profit rev_usd = tip / 1e18 * price_trb_usd - costs_usd = txn_fee / 1e9 * price_native_token # convert gwei costs to eth, then to usd + costs_usd = txn_fee * price_native_token # convert gwei costs to eth, then to usd profit_usd = rev_usd - costs_usd logger.info(f"Estimated profit: ${round(profit_usd, 2)}") logger.info(f"tip price: {round(rev_usd, 2)}, gas costs: {costs_usd}") @@ -532,34 +391,11 @@ async def ensure_profitable(self) -> ResponseStatus: return status - def get_acct_nonce(self) -> Tuple[Optional[int], ResponseStatus]: - """Get transaction count for an address""" - try: - return self.web3.eth.get_transaction_count(self.acct_addr), ResponseStatus() - except ValueError as e: - return None, error_status("Account nonce request timed out", e=e, log=logger.warning) - except Exception as e: - return None, error_status("Unable to retrieve account nonce", e=e, log=logger.error) + async def get_num_reports_by_id(self, query_id: bytes) -> Tuple[int, ResponseStatus]: + count, read_status = await self.oracle.read(func_name="getNewValueCountbyQueryId", _queryId=query_id) + return count, read_status - def submit_val_tx_gas_limit(self, submit_val_tx: ContractFunction) -> Tuple[Optional[int], ResponseStatus]: - """Estimate gas usage for submitValue transaction - Args: - submit_val_tx: The submitValue transaction object - Returns a tuple of the gas limit and a ResponseStatus object""" - if self.gas_limit is None: - try: - gas_limit: int = submit_val_tx.estimateGas({"from": self.acct_addr}) - if not gas_limit: - return None, error_status("Unable to estimate gas for submitValue transaction") - return gas_limit, ResponseStatus() - except Exception as e: - msg = "Unable to estimate gas for submitValue transaction" - return None, error_status(msg, e=e, log=logger.error) - return self.gas_limit, ResponseStatus() - - async def assemble_submission_txn( - self, datafeed: DataFeed[Any] - ) -> Tuple[Optional[ContractFunction], ResponseStatus]: + async def submission_txn_params(self, datafeed: DataFeed[Any]) -> Tuple[Optional[Dict[str, Any]], ResponseStatus]: """Assemble the submitValue transaction Params: datafeed: The datafeed object @@ -573,38 +409,61 @@ async def assemble_submission_txn( msg = "Unable to retrieve updated datafeed value." return None, error_status(msg, log=logger.info) # Get query info & encode value to bytes - query = datafeed.query - query_id = query.query_id - query_data = query.query_data + query_id = datafeed.query.query_id + query_data = datafeed.query.query_data try: - value = query.value_type.encode(latest_data[0]) - logger.debug(f"IntervalReporter Encoded value: {value.hex()}") + value = datafeed.query.value_type.encode(latest_data[0]) + logger.debug(f"Current query: {datafeed.query.descriptor}") + logger.debug(f"Reporter Encoded value: {value.hex()}") except Exception as e: msg = f"Error encoding response value {latest_data[0]}" return None, error_status(msg, e=e, log=logger.error) # Get nonce report_count, read_status = await self.get_num_reports_by_id(query_id) - if not read_status.ok: - read_status.error = ( - "Unable to retrieve report count: " + read_status.error - ) # error won't be none # noqa: E501 - logger.error(read_status.error) - read_status.e = read_status.e - return None, read_status - - # Start transaction build - submit_val_func = self.oracle.contract.get_function_by_name("submitValue") - params: ContractFunction = submit_val_func( - _queryId=query_id, - _value=value, - _nonce=report_count, - _queryData=query_data, - ) + msg = f"Unable to retrieve report count for query id: {read_status.error}" + return None, error_status(msg, read_status.e, logger.error) + + params = {"_queryId": query_id, "_value": value, "_nonce": report_count, "_queryData": query_data} return params, ResponseStatus() - def send_transaction(self, tx_signed: Any) -> Tuple[Optional[TxReceipt], ResponseStatus]: + def assemble_function( + self, function_name: str, **transaction_params: Any + ) -> Tuple[Optional[ContractFunction], ResponseStatus]: + """Assemble a contract function""" + try: + func = self.oracle.contract.get_function_by_name(function_name)(**transaction_params) + return func, ResponseStatus() + except Exception as e: + return None, error_status("Error assembling function", e, logger.error) + + def build_transaction( + self, function_name: str, **transaction_params: Any + ) -> Tuple[Optional[TxParams], ResponseStatus]: + """Build a transaction""" + + contract_function, status = self.assemble_function(function_name=function_name, **transaction_params) + if contract_function is None: + return None, error_status("Error building function to estimate gas", status.e, logger.error) + + _, status = self.estimate_gas_amount(contract_function) + if not status: + return None, error_status(f"Error estimating gas for function: {contract_function}", status.e, logger.error) + # set gas parameters globally + status = self.update_gas_fees() + logger.debug(status) + if not status.ok: + return None, error_status("Error setting gas parameters", status.e, logger.error) + + params, status = self.tx_params(**self.get_gas_info()) + logger.debug(f"Transaction parameters: {params}") + if params is None: + return None, error_status("Error getting transaction parameters", status.e, logger.error) + + return contract_function.buildTransaction(params), ResponseStatus() # type: ignore + + def sign_n_send_transaction(self, built_tx: Any) -> Tuple[Optional[TxReceipt], ResponseStatus]: """Send a signed transaction to the blockchain and wait for confirmation Params: @@ -612,8 +471,10 @@ def send_transaction(self, tx_signed: Any) -> Tuple[Optional[TxReceipt], Respons Returns a tuple of the transaction receipt and a ResponseStatus object """ + lazy_unlock_account(self.account) + local_account = self.account.local_account + tx_signed = local_account.sign_transaction(built_tx) try: - logger.debug("Sending submitValue transaction") tx_hash = self.web3.eth.send_raw_transaction(tx_signed.rawTransaction) except Exception as e: note = "Send transaction failed" @@ -635,6 +496,31 @@ def send_transaction(self, tx_signed: Any) -> Tuple[Optional[TxReceipt], Respons note = "Failed to confirm transaction" return None, error_status(note, log=logger.error, e=e) + def get_acct_nonce(self) -> Tuple[Optional[int], ResponseStatus]: + """Get the nonce for the account""" + try: + return self.web3.eth.get_transaction_count(self.acct_address), ResponseStatus() + except ValueError as e: + return None, error_status("Account nonce request timed out", e=e, log=logger.warning) + except Exception as e: + return None, error_status("Unable to retrieve account nonce", e=e, log=logger.error) + + def tx_params(self, **gas_fees: GasParams) -> Tuple[Optional[Dict[str, Any]], ResponseStatus]: + """Return transaction parameters""" + nonce, status = self.get_acct_nonce() + if nonce is None: + return None, status + return { + "nonce": nonce, + "chainId": self.chain_id, + **gas_fees, + }, ResponseStatus() + + def has_native_token(self) -> bool: + """Check if account has native token funds for a network for gas fees + of at least min_native_token_balance that is set in the cli""" + return has_native_token_funds(self.acct_addr, self.web3, min_balance=self.min_native_token_balance) + async def report_once( self, ) -> Tuple[Optional[TxReceipt], ResponseStatus]: @@ -658,56 +544,22 @@ async def report_once( msg = "Unable to suggest datafeed" return None, error_status(note=msg, log=logger.info) - logger.info(f"Current query: {datafeed.query.descriptor}") - - submit_val_tx, status = await self.assemble_submission_txn(datafeed) - if not status.ok or submit_val_tx is None: - return None, status - - # Get account nonce - acc_nonce, nonce_status = self.get_acct_nonce() - if not nonce_status.ok: - return None, nonce_status - - gas_params, status = await self.gas_params() - if gas_params is None or not status.ok: + params, status = await self.submission_txn_params(datafeed) + if not status.ok or params is None: return None, status - # Estimate gas usage amount - gas_limit, status = self.submit_val_tx_gas_limit(submit_val_tx=submit_val_tx) - if not status.ok or gas_limit is None: + build_tx, status = self.build_transaction("submitValue", **params) + if not status.ok or build_tx is None: return None, status - self.gas_info["gas_limit"] = gas_limit - if gas_params.max_fee is not None and gas_params.priority_fee is not None: - self.gas_info["type"] = 2 - self.gas_info["max_fee"] = gas_params.max_fee - self.gas_info["priority_fee"] = gas_params.priority_fee - self.gas_info["base_fee"] = gas_params.max_fee - gas_params.priority_fee - gas_fees = { - "maxPriorityFeePerGas": self.web3.toWei(gas_params.priority_fee, "gwei"), - "maxFeePerGas": self.web3.toWei(gas_params.max_fee, "gwei"), - } - - if gas_params.gas_price_in_gwei is not None: - self.gas_info["type"] = 0 - self.gas_info["gas_price"] = gas_params.gas_price_in_gwei - gas_fees = {"gasPrice": self.web3.toWei(gas_params.gas_price_in_gwei, "gwei")} - # Check if profitable if not YOLO status = await self.ensure_profitable() + logger.debug(status) if not status.ok: return None, status - # Build transaction - built_submit_val_tx = submit_val_tx.buildTransaction( - dict(nonce=acc_nonce, gas=gas_limit, chainId=self.chain_id, **gas_fees) # type: ignore - ) - lazy_unlock_account(self.account) - local_account = self.account.local_account - tx_signed = local_account.sign_transaction(built_submit_val_tx) - - tx_receipt, status = self.send_transaction(tx_signed) + logger.debug("Sending submitValue transaction") + tx_receipt, status = self.sign_n_send_transaction(build_tx) return tx_receipt, status diff --git a/src/telliot_feeds/reporters/types.py b/src/telliot_feeds/reporters/types.py new file mode 100644 index 00000000..5707c033 --- /dev/null +++ b/src/telliot_feeds/reporters/types.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from typing import Optional, TypedDict +from web3.types import Wei + + +@dataclass +class StakerInfo: + """Data types for staker info + start_date: TimeStamp + stake_balance: int + locked_balance: int + reward_debt: int + last_report: TimeStamp + reports_count: int + gov_vote_count: int + vote_count: int + in_total_stakers: bool + """ + start_date: int + stake_balance: int + locked_balance: int + reward_debt: int + last_report: int + reports_count: int + gov_vote_count: int + vote_count: int + in_total_stakers: bool + + +class GasParams(TypedDict): + maxPriorityFeePerGas: Optional[Wei] + maxFeePerGas: Optional[Wei] + gasPrice: Optional[Wei] # Legacy gas price + gas: Optional[int] # Gas limit diff --git a/src/telliot_feeds/utils/reporter_utils.py b/src/telliot_feeds/utils/reporter_utils.py index a60e3a88..1f6816c8 100644 --- a/src/telliot_feeds/utils/reporter_utils.py +++ b/src/telliot_feeds/utils/reporter_utils.py @@ -197,7 +197,7 @@ def tkn_symbol(chain_id: int) -> str: return "Unknown native token" -def fee_history_priority_fee_estimate(fee_history: FeeHistory, priority_fee_max: int) -> int: +def fee_history_priority_fee_estimate(fee_history: FeeHistory, priority_fee_max: Wei) -> Wei: """Estimate priority fee based on a percentile of the fee history. Adapted from web3.py fee_utils.py @@ -209,7 +209,7 @@ def fee_history_priority_fee_estimate(fee_history: FeeHistory, priority_fee_max: Returns: Estimated priority fee in wei """ - priority_fee_min = 1_000_000_000 # 1 gwei + priority_fee_min = Wei(1_000_000_000) # 1 gwei # grab only non-zero fees and average against only that list non_empty_block_fees = [fee[0] for fee in fee_history["reward"] if fee[0] != 0] From bc26e3f8c7bb338cb6459393dc4ad175562bd914 Mon Sep 17 00:00:00 2001 From: akrem Date: Wed, 21 Jun 2023 10:03:23 -0400 Subject: [PATCH 06/28] tox style --- .../cli/commands/request_withdraw_stake.py | 3 +- src/telliot_feeds/reporters/gas.py | 40 +++++++++---------- src/telliot_feeds/reporters/stake.py | 8 ++-- src/telliot_feeds/reporters/types.py | 5 ++- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/telliot_feeds/cli/commands/request_withdraw_stake.py b/src/telliot_feeds/cli/commands/request_withdraw_stake.py index f10af0a5..b774b30f 100644 --- a/src/telliot_feeds/cli/commands/request_withdraw_stake.py +++ b/src/telliot_feeds/cli/commands/request_withdraw_stake.py @@ -2,8 +2,9 @@ import click from click.core import Context -from telliot_core.cli.utils import async_run from eth_utils import to_checksum_address +from telliot_core.cli.utils import async_run + from telliot_feeds.cli.utils import common_options from telliot_feeds.cli.utils import get_accounts_from_name from telliot_feeds.cli.utils import reporter_cli_core diff --git a/src/telliot_feeds/reporters/gas.py b/src/telliot_feeds/reporters/gas.py index 64bab4c4..61c01754 100644 --- a/src/telliot_feeds/reporters/gas.py +++ b/src/telliot_feeds/reporters/gas.py @@ -1,23 +1,24 @@ -from typing import Any, Dict, List, Literal, Tuple +from decimal import Decimal +from typing import Any +from typing import Dict +from typing import List +from typing import Literal from typing import Optional +from typing import Tuple from typing import Union -from decimal import Decimal -from chained_accounts import ChainedAccount - -from web3 import Web3 -from web3.types import Wei -from web3.types import FeeHistory - -from web3.contract import ContractFunction +from chained_accounts import ChainedAccount from eth_utils import to_checksum_address - -from telliot_feeds.utils.log import get_logger from telliot_core.apps.core import RPCEndpoint - -from telliot_core.utils.response import ResponseStatus, error_status +from telliot_core.utils.response import error_status +from telliot_core.utils.response import ResponseStatus +from web3 import Web3 +from web3.contract import ContractFunction +from web3.types import FeeHistory +from web3.types import Wei from telliot_feeds.reporters.types import GasParams +from telliot_feeds.utils.log import get_logger from telliot_feeds.utils.reporter_utils import fee_history_priority_fee_estimate @@ -29,12 +30,13 @@ class GasFees: """Set gas fees for a transaction. - + call update_gas_prices() to update/set gas prices then call get_gas_prices() to get the gas prices for a transaction assembled manually or call get_gas_params_core() to get the gas prices for a transaction assembled by telliot_core returns gas_info for the transaction type """ + gas_info: GasParams = { "maxPriorityFeePerGas": None, "maxFeePerGas": None, @@ -127,7 +129,7 @@ def estimate_gas_amount(self, pre_built_transaction: ContractFunction) -> Tuple[ self.set_gas_info({"gas": self.gas_limit}) return self.gas_limit, ResponseStatus() try: - gas = pre_built_transaction.estimateGas({'from': self.acct_address}) + gas = pre_built_transaction.estimateGas({"from": self.acct_address}) self.set_gas_info({"gas": gas}) return gas, ResponseStatus() except Exception as e: @@ -201,9 +203,7 @@ def get_max_priority_fee(self, fee_history: Optional[FeeHistory] = None) -> Tupl max_priority_fee = self.web3.eth._max_priority_fee() return max_priority_fee if max_priority_fee < max_range else max_range, ResponseStatus() except ValueError: - logger.warning( - "unable to fetch max priority fee from node using eth._max_priority_fee_per_gas method." - ) + logger.warning("unable to fetch max priority fee from node using eth._max_priority_fee_per_gas method.") if fee_history is not None: return fee_history_priority_fee_estimate(fee_history, max_range), ResponseStatus() else: @@ -249,7 +249,7 @@ def get_eip1559_gas_price(self) -> Tuple[Optional[FEES], ResponseStatus]: return None, error_status(msg, e=status.error, log=logger.error) else: if not isinstance(_base_fee, int): - base_fee = _base_fee['baseFeePerGas'][-1] + base_fee = _base_fee["baseFeePerGas"][-1] fee_history = _base_fee else: fee_history = None @@ -281,7 +281,7 @@ def get_eip1559_gas_price(self) -> Tuple[Optional[FEES], ResponseStatus]: return { "maxPriorityFeePerGas": priority_fee, - "maxFeePerGas": max_fee if max_fee > priority_fee else priority_fee + "maxFeePerGas": max_fee if max_fee > priority_fee else priority_fee, }, ResponseStatus() def update_gas_fees(self) -> ResponseStatus: diff --git a/src/telliot_feeds/reporters/stake.py b/src/telliot_feeds/reporters/stake.py index 118dc11f..4df830bf 100644 --- a/src/telliot_feeds/reporters/stake.py +++ b/src/telliot_feeds/reporters/stake.py @@ -2,17 +2,19 @@ from typing import Any from typing import Tuple +from telliot_core.contract.contract import Contract +from telliot_core.utils.response import error_status +from telliot_core.utils.response import ResponseStatus + from telliot_feeds.reporters.gas import GasFees from telliot_feeds.utils.log import get_logger -from telliot_core.contract.contract import Contract -from telliot_core.utils.response import ResponseStatus, error_status - logger = get_logger(__name__) class Stake(GasFees): """Stake tokens to tellor oracle""" + def __init__( self, oracle: Contract, diff --git a/src/telliot_feeds/reporters/types.py b/src/telliot_feeds/reporters/types.py index 5707c033..43d7931e 100644 --- a/src/telliot_feeds/reporters/types.py +++ b/src/telliot_feeds/reporters/types.py @@ -1,5 +1,7 @@ from dataclasses import dataclass -from typing import Optional, TypedDict +from typing import Optional +from typing import TypedDict + from web3.types import Wei @@ -16,6 +18,7 @@ class StakerInfo: vote_count: int in_total_stakers: bool """ + start_date: int stake_balance: int locked_balance: int From 7018f4e8d2bb292fff21937d1fba801128b5a293 Mon Sep 17 00:00:00 2001 From: akrem Date: Wed, 21 Jun 2023 10:06:37 -0400 Subject: [PATCH 07/28] update diva_protocol/report.py --- .../integrations/diva_protocol/report.py | 159 ++++-------------- 1 file changed, 32 insertions(+), 127 deletions(-) diff --git a/src/telliot_feeds/integrations/diva_protocol/report.py b/src/telliot_feeds/integrations/diva_protocol/report.py index fd0dfe5a..90c35701 100644 --- a/src/telliot_feeds/integrations/diva_protocol/report.py +++ b/src/telliot_feeds/integrations/diva_protocol/report.py @@ -4,13 +4,13 @@ import asyncio import time from typing import Any +from typing import Dict from typing import Optional from typing import Tuple from telliot_core.utils.key_helpers import lazy_unlock_account from telliot_core.utils.response import error_status from telliot_core.utils.response import ResponseStatus -from web3 import Web3 from web3.types import TxReceipt from telliot_feeds.datafeed import DataFeed @@ -76,7 +76,9 @@ async def filter_unreported_pools(self, pools: list[DivaPool]) -> list[DivaPool] query = DIVAProtocol( poolId=pool.pool_id, divaDiamond=self.diva_diamond_address, chainId=self.endpoint.chain_id ) - report_count, read_status = await self.get_num_reports_by_id(query.query_id) + report_count, read_status = await self.oracle.read( + func_name="getNewValueCountbyQueryId", _queryId=query.query_id + ) if not read_status.ok: logger.error(f"Unable to read from tellor oracle: {read_status.error}") @@ -140,21 +142,18 @@ async def fetch_datafeed(self) -> Optional[DataFeed[Any]]: self.datafeed = datafeed return datafeed - async def set_final_ref_value(self, pool_id: int, gas_price: int) -> ResponseStatus: - return await self.middleware_contract.set_final_reference_value(pool_id=pool_id, legacy_gas_price=gas_price) + async def set_final_ref_value(self, pool_id: int, gas_fees: Dict[str, Any]) -> ResponseStatus: + return await self.middleware_contract.set_final_reference_value(pool_id=pool_id, **gas_fees) async def settle_pool(self, pool_id: int) -> ResponseStatus: """Settle pool""" - if not self.legacy_gas_price: - gas_price = await self.fetch_gas_price() - if not gas_price: - msg = "Unable to fetch gas price for tx type 0" - return error_status(note=msg, log=logger.warning) - else: - gas_price = self.legacy_gas_price + status = self.update_gas_fees() + if not status.ok: + return error_status("unable to generate gas fees", log=logger.error) + gas_fees = self.get_gas_info_core() - gas_price = int(gas_price) if gas_price >= 1 else 1 - status = await self.set_final_ref_value(pool_id=pool_id, gas_price=gas_price) + # gas_price = int(gas_price) if gas_price >= 1 else 1 + status = await self.set_final_ref_value(pool_id=pool_id, gas_fees=gas_fees) if status is not None and status.ok: logger.info(f"Pool {pool_id} settled.") return status @@ -213,139 +212,45 @@ async def settle_pools(self) -> ResponseStatus: update_reported_pools(pools=reported_pools) return ResponseStatus() - async def report_once( - self, - ) -> Tuple[Optional[TxReceipt], ResponseStatus]: - """Report query response to a TellorFlex oracle.""" - staked, status = await self.ensure_staked() - if not staked or not status.ok: - logger.warning(status.error) - return None, status - - status = await self.check_reporter_lock() - if not status.ok: - return None, status - - datafeed = await self.fetch_datafeed() - if not datafeed: - msg = "Unable to fetch DIVA Protocol datafeed." - return None, error_status(note=msg, log=logger.info) - - logger.info(f"Current query: {datafeed.query.descriptor}") + def sign_n_send_transaction(self, built_tx: Any) -> Tuple[Optional[TxReceipt], ResponseStatus]: + """Send a signed transaction to the blockchain and wait for confirmation - status = ResponseStatus() - - # Update datafeed value - latest_data = await datafeed.source.fetch_new_datapoint() - if latest_data[0] is None: - msg = "Unable to retrieve updated datafeed value." - return None, error_status(msg, log=logger.info) - - # Get query info & encode value to bytes - query = datafeed.query - query_id = query.query_id - query_data = query.query_data - try: - value = query.value_type.encode(latest_data[0]) - except Exception as e: - msg = f"Error encoding response value {latest_data[0]}" - return None, error_status(msg, e=e, log=logger.error) - - # Get nonce - report_count, read_status = await self.get_num_reports_by_id(query_id) - - if not read_status.ok: - status.error = "Unable to retrieve report count: " + read_status.error # error won't be none # noqa: E501 - logger.error(status.error) - status.e = read_status.e - return None, status - - # Start transaction build - submit_val_func = self.oracle.contract.get_function_by_name("submitValue") - submit_val_tx = submit_val_func( - _queryId=query_id, - _value=value, - _nonce=report_count, - _queryData=query_data, - ) - # Estimate gas usage amount - gas_limit, status = self.submit_val_tx_gas_limit(submit_val_tx=submit_val_tx) - if not status.ok or gas_limit is None: - return None, status - - acc_nonce, nonce_status = self.get_acct_nonce() - if not nonce_status.ok: - return None, nonce_status - - # Add transaction type 2 (EIP-1559) data - if self.transaction_type == 2: - priority_fee, max_fee = self.get_fee_info() - if priority_fee is None or max_fee is None: - return None, error_status("Unable to suggest type 2 txn fees", log=logger.error) - - built_submit_val_tx = submit_val_tx.buildTransaction( - { - "nonce": acc_nonce, - "gas": gas_limit, - "maxFeePerGas": Web3.toWei(max_fee, "gwei"), - "maxPriorityFeePerGas": Web3.toWei(priority_fee, "gwei"), - "chainId": self.chain_id, - } - ) - # Add transaction type 0 (legacy) data - else: - # Fetch legacy gas price if not provided by user - if not self.legacy_gas_price: - gas_price = await self.fetch_gas_price() - if gas_price is None: - note = "Unable to fetch gas price for tx type 0" - return None, error_status(note, log=logger.warning) - else: - gas_price = self.legacy_gas_price - - built_submit_val_tx = submit_val_tx.buildTransaction( - { - "nonce": acc_nonce, - "gas": gas_limit, - "gasPrice": Web3.toWei(gas_price, "gwei"), - "chainId": self.chain_id, - } - ) + Params: + tx_signed: The signed transaction object + Returns a tuple of the transaction receipt and a ResponseStatus object + """ lazy_unlock_account(self.account) local_account = self.account.local_account - tx_signed = local_account.sign_transaction(built_submit_val_tx) - + tx_signed = local_account.sign_transaction(built_tx) try: - logger.debug("Sending submitValue transaction") - tx_hash = self.endpoint._web3.eth.send_raw_transaction(tx_signed.rawTransaction) + tx_hash = self.web3.eth.send_raw_transaction(tx_signed.rawTransaction) except Exception as e: note = "Send transaction failed" return None, error_status(note, log=logger.error, e=e) - # Confirm submitValue transaction try: - tx_receipt = self.endpoint._web3.eth.wait_for_transaction_receipt(tx_hash, timeout=360) + # Confirm transaction + tx_receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash, timeout=360) + tx_url = f"{self.endpoint.explorer}/tx/{tx_hash.hex()}" if tx_receipt["status"] == 0: - msg = f"Transaction reverted: {tx_url}" + msg = f"Transaction reverted. ({tx_url})" return tx_receipt, error_status(msg, log=logger.error) - except Exception as e: - note = "Failed to confirm transaction" - return None, error_status(note, log=logger.error, e=e) - if status.ok and not status.error: + logger.info(f"View reported data: \n{tx_url}") self.last_submission_timestamp = 0 # Update reported pools pools = get_reported_pools() cur_time = int(time.time()) - update_reported_pools(pools=pools, add=[[datafeed.query.poolId, [cur_time, "not settled"]]]) - logger.info(f"View reported data at timestamp {cur_time}: \n{tx_url}") - else: - logger.error(status) - - return tx_receipt, status + if self.datafeed is not None: + update_reported_pools(pools=pools, add=[[self.datafeed.query.poolId, [cur_time, "not settled"]]]) + logger.info(f"View reported data at timestamp {cur_time}: \n{tx_url}") + return tx_receipt, ResponseStatus() + except Exception as e: + note = "Failed to confirm transaction" + return None, error_status(note, log=logger.error, e=e) async def report(self, report_count: Optional[int] = None) -> None: """Report values for pool reference assets & settle pools.""" From 6a485e2c31670eeff3bf4451d36c27a488b59d1d Mon Sep 17 00:00:00 2001 From: akrem Date: Thu, 22 Jun 2023 13:53:35 -0400 Subject: [PATCH 08/28] Add tests --- src/telliot_feeds/reporters/stake.py | 10 +- src/telliot_feeds/reporters/tellor_360.py | 62 +----- tests/reporters/test_360_reporter.py | 105 +++++++++- tests/reporters/test_flex_reporter.py | 176 ---------------- tests/reporters/test_gas_fees.py | 151 ++++++++++++++ tests/reporters/test_interval_reporter.py | 232 ---------------------- tests/reporters/test_rng_reporter.py | 4 +- 7 files changed, 258 insertions(+), 482 deletions(-) delete mode 100644 tests/reporters/test_flex_reporter.py create mode 100644 tests/reporters/test_gas_fees.py delete mode 100644 tests/reporters/test_interval_reporter.py diff --git a/src/telliot_feeds/reporters/stake.py b/src/telliot_feeds/reporters/stake.py index 4df830bf..0764a8c6 100644 --- a/src/telliot_feeds/reporters/stake.py +++ b/src/telliot_feeds/reporters/stake.py @@ -34,13 +34,13 @@ async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: ) if not allowance_status.ok: msg = "Unable to check allowance:" - return False, error_status(msg, allowance_status.e, log=logger.error) + return False, error_status(msg, e=allowance_status.error, log=logger.error) logger.debug(f"Current allowance: {allowance / 1e18!r}") # calculate and set gas params status = self.update_gas_fees() if not status.ok: - return False, error_status("unable to calculate fees for approve txn", status.e, log=logger.error) + return False, error_status("unable to calculate fees for approve txn", e=status.error, log=logger.error) fees = self.get_gas_info_core() # if allowance is less than amount_to_stake then approve @@ -58,7 +58,7 @@ async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: ) if not approve_status.ok: msg = "Unable to approve staking: " - return False, error_status(msg, approve_status.e, log=logger.error) + return False, error_status(msg, e=approve_status.error, log=logger.error) logger.debug(f"Approve transaction status: {approve_receipt.status}, block: {approve_receipt.blockNumber}") # Add this to avoid nonce error from txn happening too fast time.sleep(1) @@ -68,7 +68,7 @@ async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: # calculate and set gas params status = self.update_gas_fees() if not status.ok: - return False, error_status("unable to calculate fees for deposit txn", status.e, log=logger.error) + return False, error_status("unable to calculate fees for deposit txn", e=status.error, log=logger.error) fees = self.get_gas_info_core() deposit_receipt, deposit_status = await self.oracle.write( @@ -79,6 +79,6 @@ async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: ) if not deposit_status.ok: msg = "Unable to deposit stake!" - return False, error_status(msg, deposit_status.e, log=logger.error) + return False, error_status(msg, e=deposit_status.error, log=logger.error) logger.debug(f"Deposit transaction status: {deposit_receipt.status}, block: {deposit_receipt.blockNumber}") return True, deposit_status diff --git a/src/telliot_feeds/reporters/tellor_360.py b/src/telliot_feeds/reporters/tellor_360.py index 24ebdff3..b7de4934 100644 --- a/src/telliot_feeds/reporters/tellor_360.py +++ b/src/telliot_feeds/reporters/tellor_360.py @@ -21,7 +21,7 @@ from telliot_feeds.constants import CHAINS_WITH_TBR from telliot_feeds.feeds import DataFeed from telliot_feeds.feeds.trb_usd_feed import trb_usd_median_feed -from telliot_feeds.reporters.gas import GasFees +from telliot_feeds.reporters.stake import Stake from telliot_feeds.reporters.rewards.time_based_rewards import get_time_based_rewards from telliot_feeds.reporters.tips.suggest_datafeed import get_feed_and_tip from telliot_feeds.reporters.tips.tip_amount import fetch_feed_tip @@ -38,13 +38,11 @@ logger = get_logger(__name__) -class Tellor360Reporter(GasFees): +class Tellor360Reporter(Stake): """Reports values from given datafeeds to a TellorFlex.""" def __init__( self, - oracle: Contract, - token: Contract, min_native_token_balance: int, autopay: Contract, chain_id: int, @@ -58,8 +56,6 @@ def __init__( **kwargs: Any, ) -> None: super().__init__(**kwargs) - self.oracle = oracle - self.token = token self.min_native_token_balance = min_native_token_balance self.autopay = autopay self.datafeed = datafeed @@ -112,58 +108,6 @@ async def get_current_token_balance(self) -> Tuple[Optional[int], ResponseStatus logger.info(f"Current wallet TRB balance: {wallet_balance / 1e18!r}") return wallet_balance, status - async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: - """Deposits stake into the oracle contract""" - # check allowance to avoid unnecessary approval transactions - allowance, allowance_status = await self.token.read( - "allowance", owner=self.acct_addr, spender=self.oracle.address - ) - if not allowance_status.ok: - msg = "Unable to check allowance:" - return False, error_status(msg, allowance_status.e, log=logger.error) - - logger.debug(f"Current allowance: {allowance / 1e18!r}") - # calculate and set gas params - status = self.update_gas_fees() - if not status.ok: - return False, error_status("unable to calculate fees for approve/deposit txn", status.e, log=logger.error) - - fees = self.get_gas_info_core() - logger.debug(f"Gas fees: {fees}") - # if allowance is less than amount_to_stake then approve - if allowance < amount: - # Approve token spending - logger.info(f"Approving {self.oracle.address} token spending: {amount}...") - approve_receipt, approve_status = await self.token.write( - func_name="approve", - gas_limit=self.gas_limit, - # have to convert to gwei because of telliot_core where numbers are converted to wei - # consider changing this in telliot_core - spender=self.oracle.address, - amount=amount, - **fees, - ) - if not approve_status.ok: - msg = "Unable to approve staking: " - return False, error_status(msg, approve_status.e, log=logger.error) - logger.debug(f"Approve transaction status: {approve_receipt.status}, block: {approve_receipt.blockNumber}") - # Add this to avoid nonce error from txn happening too fast - time.sleep(1) - - # deposit stake - logger.info(f"Now depositing stake: {amount}...") - deposit_receipt, deposit_status = await self.oracle.write( - func_name="depositStake", - gas_limit=self.gas_limit, - _amount=amount, - **fees, - ) - if not deposit_status.ok: - msg = "Unable to deposit stake!" - return False, error_status(msg, deposit_status.e, log=logger.error) - logger.debug(f"Deposit transaction status: {deposit_receipt.status}, block: {deposit_receipt.blockNumber}") - return True, deposit_status - async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: """Compares stakeAmount and stakerInfo every loop to monitor changes to the stakeAmount or stakerInfo and deposits stake if needed for continuous reporting @@ -554,7 +498,7 @@ async def report_once( # Check if profitable if not YOLO status = await self.ensure_profitable() - logger.debug(status) + logger.debug(f"Ensure profitibility method status: {status}") if not status.ok: return None, status diff --git a/tests/reporters/test_360_reporter.py b/tests/reporters/test_360_reporter.py index 41f303eb..eeb493dd 100644 --- a/tests/reporters/test_360_reporter.py +++ b/tests/reporters/test_360_reporter.py @@ -8,9 +8,10 @@ import pytest from brownie import accounts from brownie import chain -from telliot_core.utils.response import ResponseStatus +from telliot_core.utils.response import ResponseStatus, error_status from telliot_feeds.datafeed import DataFeed +from telliot_feeds.feeds import CATALOG_FEEDS from telliot_feeds.feeds.btc_usd_feed import btc_usd_median_feed from telliot_feeds.feeds.eth_usd_feed import eth_usd_median_feed from telliot_feeds.feeds.matic_usd_feed import matic_usd_median_feed @@ -169,9 +170,11 @@ async def test_360_reporter_rewards(tellor_360, guaranteed_price_source): min_native_token_balance=0, ignore_tbr=False, datafeed=feed, + expected_profit="YOLO", ) assert isinstance(await r.rewards(), int) + await r.report_once() @pytest.mark.asyncio @@ -397,10 +400,12 @@ async def test_fetch_datafeed(tellor_flex_reporter): @pytest.mark.asyncio def test_get_fee_info(tellor_flex_reporter): """Test fee info for type 2 transactions.""" - priority, max_fee = tellor_flex_reporter.get_fee_info() + tellor_flex_reporter.transaction_type = 2 + tellor_flex_reporter.update_gas_fees() + gas_fees = tellor_flex_reporter.get_gas_info() - assert isinstance(priority, float) - assert isinstance(max_fee, (float, int)) + assert isinstance(gas_fees["maxPriorityFeePerGas"], int) + assert isinstance(gas_fees["maxFeePerGas"], int) @pytest.mark.asyncio @@ -425,7 +430,7 @@ async def test_ensure_staked(tellor_flex_reporter): async def test_ensure_profitable(tellor_flex_reporter): """Test profitability check.""" r = tellor_flex_reporter - r.gas_info = {"type": 0, "gas_price": 1e9, "gas_limit": 300000} + r.gas_info = {"maxPriorityFeePerGas": None, "maxFeePerGas": None, "gasPrice": 1e9, "gas": 300000} assert r.expected_profit == "YOLO" @@ -442,10 +447,9 @@ async def test_ensure_profitable(tellor_flex_reporter): @pytest.mark.asyncio async def test_ethgasstation_error(tellor_flex_reporter): - with mock.patch("telliot_feeds.reporters.tellor_360.Tellor360Reporter.fetch_gas_price") as func: - func.return_value = None + with mock.patch("telliot_feeds.reporters.tellor_360.Tellor360Reporter.update_gas_fees") as func: + func.return_value = error_status("failed", log=logger.error) r = tellor_flex_reporter - r.stake = 1000000 * 10**18 staked, status = await r.ensure_staked() assert not staked @@ -498,3 +502,88 @@ def send_failure(*args, **kwargs): await r.report(2) assert "Send transaction failed: Exception('bingo')" in caplog.text assert caplog.text.count("Checking reporter lock") == 2 + + +@pytest.mark.asyncio +async def test_check_reporter_lock(tellor_flex_reporter): + status = await tellor_flex_reporter.check_reporter_lock() + + assert isinstance(status, ResponseStatus) + if not status.ok: + assert ("reporter lock" in status.error) or ("Staker balance too low" in status.error) + +@pytest.mark.asyncio +async def test_reporting_without_internet(tellor_flex_reporter, caplog): + async def offline(): + return False + + with patch("asyncio.sleep", side_effect=InterruptedError): + + r = tellor_flex_reporter + + r.is_online = lambda: offline() + + with pytest.raises(InterruptedError): + await r.report() + + assert "Unable to connect to the internet!" in caplog.text + +@pytest.mark.asyncio +async def test_dispute(tellor_flex_reporter, caplog): + # Test when reporter in dispute + r = tellor_flex_reporter + r.datafeed = matic_usd_median_feed + # initial balance higher than current balance, current balance is 0 since first time staking + r.stake_info.store_staker_balance(1) + + _ = await r.report_once() + assert "Your staked balance has decreased, account might be in dispute" in caplog.text + +@pytest.mark.asyncio +async def test_reset_datafeed(tellor_flex_reporter): + # Test when reporter selects qtag vs not + # datafeed should persist if qtag selected + r = tellor_flex_reporter + + reporter1 = Tellor360Reporter( + oracle=r.oracle, + token=r.token, + autopay=r.autopay, + endpoint=r.endpoint, + account=r.account, + chain_id=80001, + transaction_type=0, + datafeed=CATALOG_FEEDS["trb-usd-spot"], + min_native_token_balance=0, + ) + reporter2 = Tellor360Reporter( + oracle=r.oracle, + token=r.token, + autopay=r.autopay, + endpoint=r.endpoint, + account=r.account, + chain_id=80001, + transaction_type=0, + min_native_token_balance=0, + ) + + # Unlocker reporter lock checker + async def reporter_lock(): + return ResponseStatus() + + reporter1.check_reporter_lock = lambda: reporter_lock() + reporter2.check_reporter_lock = lambda: reporter_lock() + + async def reprt(): + for _ in range(3): + await reporter1.report_once() + assert reporter1.qtag_selected is True + assert reporter1.datafeed.query.asset == "trb" + chain.sleep(43201) + + for _ in range(3): + await reporter2.report_once() + assert reporter2.qtag_selected is False + chain.sleep(43201) + + _ = await reprt() diff --git a/tests/reporters/test_flex_reporter.py b/tests/reporters/test_flex_reporter.py deleted file mode 100644 index 7aad2f3e..00000000 --- a/tests/reporters/test_flex_reporter.py +++ /dev/null @@ -1,176 +0,0 @@ -from unittest.mock import patch - -import pytest -from brownie import chain -from telliot_core.utils.response import ResponseStatus - -from telliot_feeds.feeds import CATALOG_FEEDS -from telliot_feeds.feeds.matic_usd_feed import matic_usd_median_feed -from telliot_feeds.reporters.tellor_360 import Tellor360Reporter - -# from telliot_feeds.datafeed import DataFeed - - -# @pytest.mark.asyncio -# async def test_YOLO_feed_suggestion(tellor_flex_reporter): -# # tellor_flex_reporter.expected_profit = "YOLO" -# tellor_flex_reporter.use_random_feeds = True -# feed = await tellor_flex_reporter.fetch_datafeed() - -# assert feed is not None -# assert isinstance(feed, DataFeed) - - -@pytest.mark.asyncio -async def test_ensure_profitable(tellor_flex_reporter): - r = tellor_flex_reporter - r.expected_profit = "YOLO" - r.gas_info = {"type": 0, "gas_price": 1e9, "gas_limit": 300000} - - status = await r.ensure_profitable() - - assert isinstance(status, ResponseStatus) - assert status.ok - - r.chain_id = 1 - r.expected_profit = 100.0 - status = await r.ensure_profitable() - - assert not status.ok - - -@pytest.mark.asyncio -async def test_fetch_gas_price(tellor_flex_reporter): - price = await tellor_flex_reporter.fetch_gas_price() - - assert isinstance(price, float) - assert price > 0 - - -@pytest.mark.asyncio -async def test_ensure_staked(tellor_flex_reporter): - staked, status = await tellor_flex_reporter.ensure_staked() - - assert isinstance(status, ResponseStatus) - assert isinstance(staked, bool) - if status.ok: - assert staked - else: - assert "Unable to approve staking" in status.error - - -@pytest.mark.asyncio -async def test_check_reporter_lock(tellor_flex_reporter): - status = await tellor_flex_reporter.check_reporter_lock() - - assert isinstance(status, ResponseStatus) - if not status.ok: - assert ("reporter lock" in status.error) or ("Staker balance too low" in status.error) - - -@pytest.mark.asyncio -async def test_get_num_reports_by_id(tellor_flex_reporter): - qid = matic_usd_median_feed.query.query_id - count, status = await tellor_flex_reporter.get_num_reports_by_id(qid) - - assert isinstance(status, ResponseStatus) - if status.ok: - assert isinstance(count, int) - else: - assert count is None - - -@pytest.mark.asyncio -async def test_fetch_gas_price_error(tellor_flex_reporter, caplog): - # Test invalid gas price speed - r = tellor_flex_reporter - - with patch("telliot_feeds.reporters.tellor_360.Tellor360Reporter.fetch_gas_price") as func: - func.return_value = None - - staked, status = await r.ensure_staked() - assert not staked - assert not status.ok - assert "Unable to fetch gas price for staking" in status.error - _, status = await r.report_once() - assert not status.ok - assert "Unable to fetch gas price" in status.error - - -@pytest.mark.asyncio -async def test_reporting_without_internet(tellor_flex_reporter, caplog): - async def offline(): - return False - - with patch("asyncio.sleep", side_effect=InterruptedError): - - r = tellor_flex_reporter - - r.is_online = lambda: offline() - - with pytest.raises(InterruptedError): - await r.report() - - assert "Unable to connect to the internet!" in caplog.text - - -@pytest.mark.asyncio -async def test_dispute(tellor_flex_reporter, caplog): - # Test when reporter in dispute - r = tellor_flex_reporter - r.datafeed = matic_usd_median_feed - # initial balance higher than current balance, current balance is 0 since first time staking - r.stake_info.store_staker_balance(1) - - _ = await r.report_once() - assert "Your staked balance has decreased, account might be in dispute" in caplog.text - - -@pytest.mark.asyncio -async def test_reset_datafeed(tellor_flex_reporter): - # Test when reporter selects qtag vs not - # datafeed should persist if qtag selected - r = tellor_flex_reporter - - reporter1 = Tellor360Reporter( - oracle=r.oracle, - token=r.token, - autopay=r.autopay, - endpoint=r.endpoint, - account=r.account, - chain_id=80001, - transaction_type=0, - datafeed=CATALOG_FEEDS["trb-usd-spot"], - min_native_token_balance=0, - ) - reporter2 = Tellor360Reporter( - oracle=r.oracle, - token=r.token, - autopay=r.autopay, - endpoint=r.endpoint, - account=r.account, - chain_id=80001, - transaction_type=0, - min_native_token_balance=0, - ) - - # Unlocker reporter lock checker - async def reporter_lock(): - return ResponseStatus() - - reporter1.check_reporter_lock = lambda: reporter_lock() - reporter2.check_reporter_lock = lambda: reporter_lock() - - async def reprt(): - for _ in range(3): - await reporter1.report_once() - assert reporter1.qtag_selected is True - assert reporter1.datafeed.query.asset == "trb" - chain.sleep(43201) - - for _ in range(3): - await reporter2.report_once() - assert reporter2.qtag_selected is False - chain.sleep(43201) - - _ = await reprt() diff --git a/tests/reporters/test_gas_fees.py b/tests/reporters/test_gas_fees.py new file mode 100644 index 00000000..0bcd4a5f --- /dev/null +++ b/tests/reporters/test_gas_fees.py @@ -0,0 +1,151 @@ +import pytest +from telliot_core.apps.core import TelliotCore +from telliot_feeds.reporters.gas import GasFees +from web3.datastructures import AttributeDict +from unittest.mock import Mock, PropertyMock + +@pytest.fixture(scope="function") +async def gas_fees_object(mumbai_test_cfg): + """Fixture that build GasFees object.""" + async with TelliotCore(config=mumbai_test_cfg) as core: + # get PubKey and PrivKey from config files + account = core.get_account() + + gas = GasFees( + endpoint=core.endpoint, + account=account, + transaction_type=0, + legacy_gas_price=None, + gas_multiplier=1, + priority_fee_per_gas=None, + base_fee_per_gas=None, + max_fee_per_gas=None, + gas_limit=None, + ) + return gas + + +@pytest.mark.asyncio +async def test_get_legacy_gas_price(gas_fees_object): + gas: GasFees = await gas_fees_object + + legacy_gas_price, status = gas.get_legacy_gas_price() + assert status.ok + assert 'gasPrice' in legacy_gas_price + assert legacy_gas_price['gasPrice'] is not None, "get_legacy_gas_price returned None" + assert legacy_gas_price['gasPrice'] > 0, "legacy gas price not fetched properly" + gas.web3 = Mock() + type(gas.web3.eth).gas_price = PropertyMock(return_value=None) + legacy_gas_price, status = gas.get_legacy_gas_price() + assert not status.ok + assert legacy_gas_price is None + assert status.error == "Error fetching legacy gas price, rpc returned None" + + type(gas.web3.eth).gas_price = PropertyMock(side_effect=ValueError("Mock Error")) + legacy_gas_price, status = gas.get_legacy_gas_price() + assert not status.ok + assert legacy_gas_price is None + assert status.error == "Error fetching legacy gas price: ValueError('Mock Error')" + + +@pytest.mark.asyncio +async def test_get_eip1559_gas_price(gas_fees_object): + gas: GasFees = await gas_fees_object + mock_fee_history = AttributeDict( + {'baseFeePerGas': [13676801331, 14273862890, 13972887310, 14813623596, 14046654284, 13615655875], + 'reward': [[100000000, 100000000, 1500000000], [100000000, 100000000, 1500000000], [100000000, 1000000000, 3000000000], [100000000, 528000000, 2000000000], [100000000, 103445451, 1650000000]] + }) + + type(gas.web3.eth).fee_history = Mock(return_value=mock_fee_history) + eip1559_gas_price, status = gas.get_eip1559_gas_price() + assert status.ok + eip1559_gas_price["maxPriorityFeePerGas"] == 1000000000 + base_fee = mock_fee_history['baseFeePerGas'][-1] + eip1559_gas_price["maxFeePerGas"] == base_fee + eip1559_gas_price["maxPriorityFeePerGas"] + # fee history is None + type(gas.web3.eth).fee_history = Mock(return_value=None) + eip1559_gas_price, status = gas.get_eip1559_gas_price() + assert not status.ok + assert eip1559_gas_price is None + assert "no base fee set" in status.error + # exception when fetching fee history + type(gas.web3.eth).fee_history = Mock(side_effect=ValueError("Mock Error")) + eip1559_gas_price, status = gas.get_eip1559_gas_price() + assert not status.ok + assert eip1559_gas_price is None + assert "Error fetching fee history" in status.error + # assert status.error == 'unable to calculate EIP1559 gas price: "Error fetching fee history: ValueError(\'Mock Error\')' + # when 2 out 3 fee args are user provided fee history from node isn't used + # instead maxFeePerGas is calculated from user provided values + type(gas.web3.eth).fee_history = Mock(return_value=mock_fee_history) + gas.priority_fee_per_gas = 1 # 1 Gwei. users provide gwei value + gas.base_fee_per_gas = 136 + eip1559_gas_price, status = gas.get_eip1559_gas_price() + assert status.ok + assert eip1559_gas_price["maxPriorityFeePerGas"] == 1000000000 # 1 Gwei + assert eip1559_gas_price["maxFeePerGas"] == gas.get_max_fee(gas.to_wei(136)) + + # when 1 out 3 fee args are user provided fee history from node used + # and maxFeePerGas and priority fee are calculated from fee history + type(gas.web3.eth).fee_history = Mock(return_value=mock_fee_history) + gas.priority_fee_per_gas = None + gas.base_fee_per_gas = 150 + eip1559_gas_price, status = gas.get_eip1559_gas_price() + assert status.ok + assert eip1559_gas_price["maxPriorityFeePerGas"] == 1000000000 # 1 Gwei + assert eip1559_gas_price["maxFeePerGas"] == gas.get_max_fee(gas.to_wei(150)) + + +@pytest.mark.asyncio +async def test_update_gas_fees(gas_fees_object): + gas: GasFees = await gas_fees_object + status = gas.update_gas_fees() + assert status.ok, "update_gas_fees returned not ok status" + assert gas.gas_info['gasPrice'] is not None, "gas_info['gasPrice'] returned None" + assert gas.gas_info['gasPrice'] > 0, "gas_info['gasPrice'] is not fetched properly" + assert gas.gas_info['gas'] is None + assert gas.gas_info['maxFeePerGas'] is None + assert gas.gas_info['maxPriorityFeePerGas'] is None + gas_info_core = {'max_fee_per_gas': None, 'max_priority_fee_per_gas': None, 'legacy_gas_price': 20.2} + assert gas.get_gas_info_core() == gas_info_core + + gas.web3 = Mock() + type(gas.web3.eth)._max_priorit_fee = Mock(return_value=gas.to_wei(1)) + type(gas.web3.eth).gas_price = PropertyMock(return_value=None) + status = gas.update_gas_fees() + assert not status.ok + assert "Failed to update gas fees for legacy type transaction:" in status.error + assert gas.gas_info['gasPrice'] is None, "gas_info['gasPrice'] should be None since gas update failed" + # set gas price to None and check if gas info is updated properly + gas_info_core["legacy_gas_price"] = None + assert gas.get_gas_info_core() == gas_info_core + + # change to type 2 transactions + mock_fee_history = AttributeDict( + {'baseFeePerGas': [13676801331, 14273862890, 13972887310, 14813623596, 14046654284, 13615655875], + 'reward': [[100000000, 100000000, 1500000000], [100000000, 100000000, 1500000000], [100000000, 1000000000, 3000000000], [100000000, 528000000, 2000000000], [100000000, 103445451, 1650000000]] + }) + gas.transaction_type = 2 + type(gas.web3.eth).fee_history = Mock(return_value=mock_fee_history) + status = gas.update_gas_fees() + assert status.ok + assert gas.gas_info['gasPrice'] is None + assert gas.gas_info['gas'] is None + assert gas.gas_info['maxFeePerGas'] is not None + average_base_fee = round(sum(mock_fee_history['baseFeePerGas']) // len(mock_fee_history['baseFeePerGas'])) + assert gas.gas_info['maxFeePerGas'] == int(average_base_fee) + gas.gas_info["maxPriorityFeePerGas"] + assert gas.gas_info['maxPriorityFeePerGas'] == 1000000000 + + type(gas.web3.eth).fee_history = Mock(side_effect=ValueError("Mock Error")) + status = gas.update_gas_fees() + assert gas.gas_info['maxFeePerGas'] is None + assert gas.gas_info['maxPriorityFeePerGas'] is None + assert 'Failed to update gas fees for EIP1559 type transaction: \'unable to calculate EIP1559 gas price: "Error fetching fee history: ValueError(\\\'Mock Error\\\')"\'' in status.error + + gas.transaction_type = 5 + status = gas.update_gas_fees() + assert gas.gas_info['maxFeePerGas'] is None + assert gas.gas_info['maxPriorityFeePerGas'] is None + assert "Failed to update gas fees: 'Invalid transaction type: 5'" in status.error + + diff --git a/tests/reporters/test_interval_reporter.py b/tests/reporters/test_interval_reporter.py deleted file mode 100644 index 1799e66a..00000000 --- a/tests/reporters/test_interval_reporter.py +++ /dev/null @@ -1,232 +0,0 @@ -""" -Tests covering the IntervalReporter class from -telliot's reporters subpackage. -""" -import asyncio -from datetime import datetime -from unittest import mock - -import pytest -from telliot_core.utils.response import ResponseStatus -from web3.datastructures import AttributeDict - -from telliot_feeds.datafeed import DataFeed -from telliot_feeds.feeds.matic_usd_feed import matic_usd_median_feed -from telliot_feeds.sources.etherscan_gas import EtherscanGasPrice -from tests.utils.utils import gas_price -from tests.utils.utils import passing_bool_w_status -from tests.utils.utils import passing_status - - -@pytest.mark.asyncio -async def test_fetch_datafeed(tellor_flex_reporter): - r = tellor_flex_reporter - feed = await r.fetch_datafeed() - assert isinstance(feed, DataFeed) - - r.datafeed = None - assert r.datafeed is None - feed = await r.fetch_datafeed() - assert isinstance(feed, DataFeed) - - -@pytest.mark.skip(reason="EIP-1559 not supported by ganache") -@pytest.mark.asyncio -def test_get_fee_info(tellor_flex_reporter): - info, time = tellor_flex_reporter.get_fee_info() - - assert isinstance(time, datetime) - assert isinstance(info, EtherscanGasPrice) - assert isinstance(info.LastBlock, int) - assert info.LastBlock > 0 - assert isinstance(info.gasUsedRatio, list) - - -@pytest.mark.asyncio -async def test_get_num_reports_by_id(tellor_flex_reporter): - r = tellor_flex_reporter - num, status = await r.get_num_reports_by_id(matic_usd_median_feed.query.query_id) - - assert isinstance(status, ResponseStatus) - - if status.ok: - assert isinstance(num, int) - else: - assert num is None - - -@pytest.mark.asyncio -async def test_ensure_staked(tellor_flex_reporter): - """Test staking status of reporter.""" - staked, status = await tellor_flex_reporter.ensure_staked() - - assert staked - assert status.ok - - -@pytest.mark.asyncio -async def test_ensure_profitable(tellor_flex_reporter): - """Test profitability check.""" - r = tellor_flex_reporter - r.gas_info = {"type": 0, "gas_price": 1e9, "gas_limit": 300000} - - assert r.expected_profit == "YOLO" - - status = await r.ensure_profitable() - - assert status.ok - - r.expected_profit = 1e10 - status = await r.ensure_profitable() - - assert not status.ok - assert status.error == "Estimated profitability below threshold." - - -@pytest.mark.asyncio -async def test_ethgasstation_error(tellor_flex_reporter): - with mock.patch("telliot_feeds.reporters.interval.IntervalReporter.fetch_gas_price") as func: - func.return_value = None - r = tellor_flex_reporter - r.stake = 1000000 * 10**18 - - staked, status = await r.ensure_staked() - assert not staked - assert not status.ok - - -@pytest.mark.asyncio -async def test_interval_reporter_submit_once(tellor_flex_reporter): - """Test reporting once to the TellorX playground on Rinkeby - with three retries.""" - r = tellor_flex_reporter - - # Sync reporter - r.datafeed = None - - EXPECTED_ERRORS = { - "Current addess disputed. Switch address to continue reporting.", - "Current address is locked in dispute or for withdrawal.", - "Current address is in reporter lock.", - "Estimated profitability below threshold.", - "Estimated gas price is above maximum gas price.", - "Unable to retrieve updated datafeed value.", - } - - ORACLE_ADDRESSES = {r.oracle.address} - - tx_receipt, status = await r.report_once() - - # Reporter submitted - if tx_receipt is not None and status.ok: - assert isinstance(tx_receipt, AttributeDict) - assert tx_receipt.to in ORACLE_ADDRESSES - # Reporter did not submit - else: - assert not tx_receipt - assert not status.ok - assert status.error in EXPECTED_ERRORS - - -@pytest.mark.asyncio -async def test_no_updated_value(tellor_flex_reporter, bad_datasource): - """Test handling for no updated value returned from datasource.""" - r = tellor_flex_reporter - r.datafeed = matic_usd_median_feed - - # Clear latest datapoint - r.datafeed.source._history.clear() - - # Replace PriceAggregator's sources with test source that - # returns no updated DataPoint - r.datafeed.source.sources = [bad_datasource] - - r.fetch_gas_price = gas_price - r.check_reporter_lock = passing_status - r.ensure_profitable = passing_status - - tx_receipt, status = await r.report_once() - - assert not tx_receipt - assert not status.ok - print("status.error:", status.error) - assert status.error == "Unable to retrieve updated datafeed value." - - -@pytest.mark.skip("ensure_profitable is overritten in TelloFlexReporter") -@pytest.mark.asyncio -async def test_no_token_prices_for_profit_calc(tellor_flex_reporter, bad_datasource, guaranteed_price_source): - """Test handling for no token prices for profit calculation.""" - r = tellor_flex_reporter - - r.fetch_gas_price = gas_price - r.check_reporter_lock = passing_status - - # Simulate TRB/USD price retrieval failure - r.trb_usd_median_feed.source._history.clear() - r.eth_usd_median_feed.source.sources = [guaranteed_price_source] - r.trb_usd_median_feed.source.sources = [bad_datasource] - tx_receipt, status = await r.report_once() - - assert tx_receipt is None - assert not status.ok - assert status.error == "Unable to fetch TRB/USD price for profit calculation" - - # Simulate ETH/USD price retrieval failure - r.eth_usd_median_feed.source._history.clear() - r.eth_usd_median_feed.source.sources = [bad_datasource] - tx_receipt, status = await r.report_once() - - assert tx_receipt is None - assert not status.ok - assert status.error == "Unable to fetch ETH/USD price for profit calculation" - - -@pytest.mark.skip("ensure_staked is overritten in TelloFlexReporter") -@pytest.mark.asyncio -async def test_handle_contract_master_read_timeout(tellor_flex_reporter): - """Test handling for contract master read timeout.""" - - def conn_timeout(url, *args, **kwargs): - raise asyncio.exceptions.TimeoutError() - - with mock.patch("web3.contract.ContractFunction.call", side_effect=conn_timeout): - r = tellor_flex_reporter - r.fetch_gas_price = gas_price - staked, status = await r.ensure_staked() - - assert not staked - assert not status.ok - assert "Unable to read reporters staker status" in status.error - - -@pytest.mark.asyncio -async def test_ensure_reporter_lock_check_after_submitval_attempt(tellor_flex_reporter, guaranteed_price_source): - r = tellor_flex_reporter - r.last_submission_timestamp = 1234 - r.fetch_gas_price = gas_price - r.ensure_staked = passing_bool_w_status - r.ensure_profitable = passing_status - r.check_reporter_lock = passing_status - r.datafeed = matic_usd_median_feed - r.gas_limit = 350000 - - # Simulate fetching latest value - r.datafeed.source.sources = [guaranteed_price_source] - - async def num_reports(*args, **kwargs): - return 1, ResponseStatus() - - r.get_num_reports_by_id = num_reports - - assert r.last_submission_timestamp == 1234 - - def send_failure(*args, **kwargs): - raise Exception("bingo") - - with mock.patch("web3.eth.Eth.send_raw_transaction", side_effect=send_failure): - tx_receipt, status = await r.report_once() - assert tx_receipt is None - assert not status.ok - assert "bingo" in status.error - assert r.last_submission_timestamp == 0 diff --git a/tests/reporters/test_rng_reporter.py b/tests/reporters/test_rng_reporter.py index f427354b..9ee1a25a 100644 --- a/tests/reporters/test_rng_reporter.py +++ b/tests/reporters/test_rng_reporter.py @@ -183,7 +183,7 @@ async def assemble_feed(timestamp): await rng_reporter.report(2) # encoded value for timestamp 1678311000 count = caplog.text.count( - "IntervalReporter Encoded value: 236eabcc1c1dc5c01bd6357576b17e490eaf7aaa37b360485cddcd6877a395c3" + "Reporter Encoded value: 236eabcc1c1dc5c01bd6357576b17e490eaf7aaa37b360485cddcd6877a395c3" ) # count is 2 because second report uses same value assert count == 2 @@ -203,6 +203,6 @@ async def test_unique_rng_report_value(rng_reporter: RNGReporter, monkeypatch, c ) await rng_reporter.report(2) count = caplog.text.count( - "IntervalReporter Encoded value: 236eabcc1c1dc5c01bd6357576b17e490eaf7aaa37b360485cddcd6877a395c3" + "Reporter Encoded value: 236eabcc1c1dc5c01bd6357576b17e490eaf7aaa37b360485cddcd6877a395c3" ) assert count == 1 From 0df75fc12dd93ba2f5beb222bc24b58d64f01b34 Mon Sep 17 00:00:00 2001 From: akrem Date: Thu, 22 Jun 2023 13:55:52 -0400 Subject: [PATCH 09/28] tox style --- src/telliot_feeds/reporters/tellor_360.py | 2 +- tests/reporters/test_360_reporter.py | 8 +- tests/reporters/test_gas_fees.py | 97 ++++++++++++++--------- 3 files changed, 67 insertions(+), 40 deletions(-) diff --git a/src/telliot_feeds/reporters/tellor_360.py b/src/telliot_feeds/reporters/tellor_360.py index b7de4934..e40d3838 100644 --- a/src/telliot_feeds/reporters/tellor_360.py +++ b/src/telliot_feeds/reporters/tellor_360.py @@ -21,8 +21,8 @@ from telliot_feeds.constants import CHAINS_WITH_TBR from telliot_feeds.feeds import DataFeed from telliot_feeds.feeds.trb_usd_feed import trb_usd_median_feed -from telliot_feeds.reporters.stake import Stake from telliot_feeds.reporters.rewards.time_based_rewards import get_time_based_rewards +from telliot_feeds.reporters.stake import Stake from telliot_feeds.reporters.tips.suggest_datafeed import get_feed_and_tip from telliot_feeds.reporters.tips.tip_amount import fetch_feed_tip from telliot_feeds.reporters.types import GasParams diff --git a/tests/reporters/test_360_reporter.py b/tests/reporters/test_360_reporter.py index eeb493dd..1d0b299d 100644 --- a/tests/reporters/test_360_reporter.py +++ b/tests/reporters/test_360_reporter.py @@ -8,7 +8,8 @@ import pytest from brownie import accounts from brownie import chain -from telliot_core.utils.response import ResponseStatus, error_status +from telliot_core.utils.response import error_status +from telliot_core.utils.response import ResponseStatus from telliot_feeds.datafeed import DataFeed from telliot_feeds.feeds import CATALOG_FEEDS @@ -512,6 +513,7 @@ async def test_check_reporter_lock(tellor_flex_reporter): if not status.ok: assert ("reporter lock" in status.error) or ("Staker balance too low" in status.error) + @pytest.mark.asyncio async def test_reporting_without_internet(tellor_flex_reporter, caplog): async def offline(): @@ -527,7 +529,8 @@ async def offline(): await r.report() assert "Unable to connect to the internet!" in caplog.text - + + @pytest.mark.asyncio async def test_dispute(tellor_flex_reporter, caplog): # Test when reporter in dispute @@ -539,6 +542,7 @@ async def test_dispute(tellor_flex_reporter, caplog): _ = await r.report_once() assert "Your staked balance has decreased, account might be in dispute" in caplog.text + @pytest.mark.asyncio async def test_reset_datafeed(tellor_flex_reporter): # Test when reporter selects qtag vs not diff --git a/tests/reporters/test_gas_fees.py b/tests/reporters/test_gas_fees.py index 0bcd4a5f..907e4860 100644 --- a/tests/reporters/test_gas_fees.py +++ b/tests/reporters/test_gas_fees.py @@ -1,8 +1,12 @@ +from unittest.mock import Mock +from unittest.mock import PropertyMock + import pytest from telliot_core.apps.core import TelliotCore -from telliot_feeds.reporters.gas import GasFees from web3.datastructures import AttributeDict -from unittest.mock import Mock, PropertyMock + +from telliot_feeds.reporters.gas import GasFees + @pytest.fixture(scope="function") async def gas_fees_object(mumbai_test_cfg): @@ -31,9 +35,9 @@ async def test_get_legacy_gas_price(gas_fees_object): legacy_gas_price, status = gas.get_legacy_gas_price() assert status.ok - assert 'gasPrice' in legacy_gas_price - assert legacy_gas_price['gasPrice'] is not None, "get_legacy_gas_price returned None" - assert legacy_gas_price['gasPrice'] > 0, "legacy gas price not fetched properly" + assert "gasPrice" in legacy_gas_price + assert legacy_gas_price["gasPrice"] is not None, "get_legacy_gas_price returned None" + assert legacy_gas_price["gasPrice"] > 0, "legacy gas price not fetched properly" gas.web3 = Mock() type(gas.web3.eth).gas_price = PropertyMock(return_value=None) legacy_gas_price, status = gas.get_legacy_gas_price() @@ -52,16 +56,24 @@ async def test_get_legacy_gas_price(gas_fees_object): async def test_get_eip1559_gas_price(gas_fees_object): gas: GasFees = await gas_fees_object mock_fee_history = AttributeDict( - {'baseFeePerGas': [13676801331, 14273862890, 13972887310, 14813623596, 14046654284, 13615655875], - 'reward': [[100000000, 100000000, 1500000000], [100000000, 100000000, 1500000000], [100000000, 1000000000, 3000000000], [100000000, 528000000, 2000000000], [100000000, 103445451, 1650000000]] - }) - + { + "baseFeePerGas": [13676801331, 14273862890, 13972887310, 14813623596, 14046654284, 13615655875], + "reward": [ + [100000000, 100000000, 1500000000], + [100000000, 100000000, 1500000000], + [100000000, 1000000000, 3000000000], + [100000000, 528000000, 2000000000], + [100000000, 103445451, 1650000000], + ], + } + ) + type(gas.web3.eth).fee_history = Mock(return_value=mock_fee_history) eip1559_gas_price, status = gas.get_eip1559_gas_price() assert status.ok - eip1559_gas_price["maxPriorityFeePerGas"] == 1000000000 - base_fee = mock_fee_history['baseFeePerGas'][-1] - eip1559_gas_price["maxFeePerGas"] == base_fee + eip1559_gas_price["maxPriorityFeePerGas"] + assert eip1559_gas_price["maxPriorityFeePerGas"] == 1000000000 + base_fee = mock_fee_history["baseFeePerGas"][-1] + assert eip1559_gas_price["maxFeePerGas"] == base_fee + eip1559_gas_price["maxPriorityFeePerGas"] # fee history is None type(gas.web3.eth).fee_history = Mock(return_value=None) eip1559_gas_price, status = gas.get_eip1559_gas_price() @@ -74,7 +86,8 @@ async def test_get_eip1559_gas_price(gas_fees_object): assert not status.ok assert eip1559_gas_price is None assert "Error fetching fee history" in status.error - # assert status.error == 'unable to calculate EIP1559 gas price: "Error fetching fee history: ValueError(\'Mock Error\')' + # assert status.error == ( + # 'unable to calculate EIP1559 gas price: "Error fetching fee history: ValueError(\'Mock Error\')') # when 2 out 3 fee args are user provided fee history from node isn't used # instead maxFeePerGas is calculated from user provided values type(gas.web3.eth).fee_history = Mock(return_value=mock_fee_history) @@ -101,12 +114,12 @@ async def test_update_gas_fees(gas_fees_object): gas: GasFees = await gas_fees_object status = gas.update_gas_fees() assert status.ok, "update_gas_fees returned not ok status" - assert gas.gas_info['gasPrice'] is not None, "gas_info['gasPrice'] returned None" - assert gas.gas_info['gasPrice'] > 0, "gas_info['gasPrice'] is not fetched properly" - assert gas.gas_info['gas'] is None - assert gas.gas_info['maxFeePerGas'] is None - assert gas.gas_info['maxPriorityFeePerGas'] is None - gas_info_core = {'max_fee_per_gas': None, 'max_priority_fee_per_gas': None, 'legacy_gas_price': 20.2} + assert gas.gas_info["gasPrice"] is not None, "gas_info['gasPrice'] returned None" + assert gas.gas_info["gasPrice"] > 0, "gas_info['gasPrice'] is not fetched properly" + assert gas.gas_info["gas"] is None + assert gas.gas_info["maxFeePerGas"] is None + assert gas.gas_info["maxPriorityFeePerGas"] is None + gas_info_core = {"max_fee_per_gas": None, "max_priority_fee_per_gas": None, "legacy_gas_price": 20.2} assert gas.get_gas_info_core() == gas_info_core gas.web3 = Mock() @@ -115,37 +128,47 @@ async def test_update_gas_fees(gas_fees_object): status = gas.update_gas_fees() assert not status.ok assert "Failed to update gas fees for legacy type transaction:" in status.error - assert gas.gas_info['gasPrice'] is None, "gas_info['gasPrice'] should be None since gas update failed" + assert gas.gas_info["gasPrice"] is None, "gas_info['gasPrice'] should be None since gas update failed" # set gas price to None and check if gas info is updated properly gas_info_core["legacy_gas_price"] = None assert gas.get_gas_info_core() == gas_info_core - + # change to type 2 transactions mock_fee_history = AttributeDict( - {'baseFeePerGas': [13676801331, 14273862890, 13972887310, 14813623596, 14046654284, 13615655875], - 'reward': [[100000000, 100000000, 1500000000], [100000000, 100000000, 1500000000], [100000000, 1000000000, 3000000000], [100000000, 528000000, 2000000000], [100000000, 103445451, 1650000000]] - }) + { + "baseFeePerGas": [13676801331, 14273862890, 13972887310, 14813623596, 14046654284, 13615655875], + "reward": [ + [100000000, 100000000, 1500000000], + [100000000, 100000000, 1500000000], + [100000000, 1000000000, 3000000000], + [100000000, 528000000, 2000000000], + [100000000, 103445451, 1650000000], + ], + } + ) gas.transaction_type = 2 type(gas.web3.eth).fee_history = Mock(return_value=mock_fee_history) status = gas.update_gas_fees() assert status.ok - assert gas.gas_info['gasPrice'] is None - assert gas.gas_info['gas'] is None - assert gas.gas_info['maxFeePerGas'] is not None - average_base_fee = round(sum(mock_fee_history['baseFeePerGas']) // len(mock_fee_history['baseFeePerGas'])) - assert gas.gas_info['maxFeePerGas'] == int(average_base_fee) + gas.gas_info["maxPriorityFeePerGas"] - assert gas.gas_info['maxPriorityFeePerGas'] == 1000000000 + assert gas.gas_info["gasPrice"] is None + assert gas.gas_info["gas"] is None + assert gas.gas_info["maxFeePerGas"] is not None + average_base_fee = round(sum(mock_fee_history["baseFeePerGas"]) // len(mock_fee_history["baseFeePerGas"])) + assert gas.gas_info["maxFeePerGas"] == int(average_base_fee) + gas.gas_info["maxPriorityFeePerGas"] + assert gas.gas_info["maxPriorityFeePerGas"] == 1000000000 type(gas.web3.eth).fee_history = Mock(side_effect=ValueError("Mock Error")) status = gas.update_gas_fees() - assert gas.gas_info['maxFeePerGas'] is None - assert gas.gas_info['maxPriorityFeePerGas'] is None - assert 'Failed to update gas fees for EIP1559 type transaction: \'unable to calculate EIP1559 gas price: "Error fetching fee history: ValueError(\\\'Mock Error\\\')"\'' in status.error + assert gas.gas_info["maxFeePerGas"] is None + assert gas.gas_info["maxPriorityFeePerGas"] is None + assert ( + "Failed to update gas fees for EIP1559 type transaction:" + "'unable to calculate EIP1559 gas price: \"Error fetching fee history: ValueError(\\'Mock Error\\')\"'" + in status.error + ) gas.transaction_type = 5 status = gas.update_gas_fees() - assert gas.gas_info['maxFeePerGas'] is None - assert gas.gas_info['maxPriorityFeePerGas'] is None + assert gas.gas_info["maxFeePerGas"] is None + assert gas.gas_info["maxPriorityFeePerGas"] is None assert "Failed to update gas fees: 'Invalid transaction type: 5'" in status.error - - From bf1604ad8226d70ca89bc0b9c8cfca31d4909f65 Mon Sep 17 00:00:00 2001 From: akrem Date: Thu, 22 Jun 2023 20:27:01 -0400 Subject: [PATCH 10/28] Fix test --- tests/reporters/test_gas_fees.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/reporters/test_gas_fees.py b/tests/reporters/test_gas_fees.py index 907e4860..3ac14ba5 100644 --- a/tests/reporters/test_gas_fees.py +++ b/tests/reporters/test_gas_fees.py @@ -73,7 +73,7 @@ async def test_get_eip1559_gas_price(gas_fees_object): assert status.ok assert eip1559_gas_price["maxPriorityFeePerGas"] == 1000000000 base_fee = mock_fee_history["baseFeePerGas"][-1] - assert eip1559_gas_price["maxFeePerGas"] == base_fee + eip1559_gas_price["maxPriorityFeePerGas"] + assert eip1559_gas_price["maxFeePerGas"] == gas.get_max_fee(base_fee) # fee history is None type(gas.web3.eth).fee_history = Mock(return_value=None) eip1559_gas_price, status = gas.get_eip1559_gas_price() @@ -123,7 +123,6 @@ async def test_update_gas_fees(gas_fees_object): assert gas.get_gas_info_core() == gas_info_core gas.web3 = Mock() - type(gas.web3.eth)._max_priorit_fee = Mock(return_value=gas.to_wei(1)) type(gas.web3.eth).gas_price = PropertyMock(return_value=None) status = gas.update_gas_fees() assert not status.ok @@ -148,24 +147,21 @@ async def test_update_gas_fees(gas_fees_object): ) gas.transaction_type = 2 type(gas.web3.eth).fee_history = Mock(return_value=mock_fee_history) + type(gas.web3.eth)._max_priority_fee = Mock(return_value=gas.to_wei(1)) status = gas.update_gas_fees() + base_fee = mock_fee_history["baseFeePerGas"][-1] assert status.ok assert gas.gas_info["gasPrice"] is None assert gas.gas_info["gas"] is None assert gas.gas_info["maxFeePerGas"] is not None - average_base_fee = round(sum(mock_fee_history["baseFeePerGas"]) // len(mock_fee_history["baseFeePerGas"])) - assert gas.gas_info["maxFeePerGas"] == int(average_base_fee) + gas.gas_info["maxPriorityFeePerGas"] + assert gas.gas_info["maxFeePerGas"] == gas.get_max_fee(base_fee) assert gas.gas_info["maxPriorityFeePerGas"] == 1000000000 type(gas.web3.eth).fee_history = Mock(side_effect=ValueError("Mock Error")) status = gas.update_gas_fees() assert gas.gas_info["maxFeePerGas"] is None assert gas.gas_info["maxPriorityFeePerGas"] is None - assert ( - "Failed to update gas fees for EIP1559 type transaction:" - "'unable to calculate EIP1559 gas price: \"Error fetching fee history: ValueError(\\'Mock Error\\')\"'" - in status.error - ) + assert "Failed to update gas fees for EIP1559 type transaction:" in status.error gas.transaction_type = 5 status = gas.update_gas_fees() From 01b06b0716c73d2f09ec4db1c547a57aa5c90b10 Mon Sep 17 00:00:00 2001 From: akrem Date: Fri, 23 Jun 2023 23:28:16 -0400 Subject: [PATCH 11/28] Add/Fix tests plus add comments --- src/telliot_feeds/reporters/flashbot.py | 3 +- src/telliot_feeds/reporters/gas.py | 137 ++++++++++++++-------- src/telliot_feeds/reporters/stake.py | 14 +-- src/telliot_feeds/reporters/tellor_360.py | 72 ++++++------ tests/reporters/test_backup_reporter.py | 2 +- tests/reporters/test_gas_fees.py | 19 +-- tests/reporters/test_reporter.py | 65 ++++++++++ 7 files changed, 207 insertions(+), 105 deletions(-) create mode 100644 tests/reporters/test_reporter.py diff --git a/src/telliot_feeds/reporters/flashbot.py b/src/telliot_feeds/reporters/flashbot.py index 77731515..435c4ade 100644 --- a/src/telliot_feeds/reporters/flashbot.py +++ b/src/telliot_feeds/reporters/flashbot.py @@ -42,9 +42,10 @@ def __init__(self, signature_account: ChainedAccount, *args: Any, **kwargs: Any) logger.info(f"Flashbots provider endpoint: {flashbots_uri}") flashbot(self.endpoint._web3, self.signature_account, flashbots_uri) - def send_transaction(self, tx_signed: Any) -> Tuple[Optional[TxReceipt], ResponseStatus]: + def sign_n_send_transaction(self, built_tx: Any) -> Tuple[Optional[TxReceipt], ResponseStatus]: status = ResponseStatus() # Create bundle of one pre-signed, EIP-1559 (type 2) transaction + tx_signed = self.account.local_account.sign_transaction(built_tx) bundle = [ {"signed_transaction": tx_signed.rawTransaction}, ] diff --git a/src/telliot_feeds/reporters/gas.py b/src/telliot_feeds/reporters/gas.py index 61c01754..0d7f55f5 100644 --- a/src/telliot_feeds/reporters/gas.py +++ b/src/telliot_feeds/reporters/gas.py @@ -44,6 +44,47 @@ class GasFees: "gas": None, } + @staticmethod + def to_gwei(value: Union[Wei, int]) -> Union[int, float]: + """Converts wei to gwei.""" + converted_value = Web3.fromWei(value, "gwei") + # Returns float if Gwei value is Decimal, otherwise returns int + if isinstance(converted_value, Decimal): + return float(converted_value) + return converted_value + + @staticmethod + def from_gwei(value: Union[int, float, Decimal]) -> Wei: + """Converts gwei to wei.""" + return Web3.toWei(value, "gwei") + + @staticmethod + def optional_to_gwei(value: Union[Wei, int, None]) -> Union[int, float, None]: + """Converts wei to gwei and if value is None, returns None.""" + if value is None: + return None + return GasFees.to_gwei(value) + + @staticmethod + def optional_from_gwei(value: Union[int, float, Decimal, None]) -> Optional[Wei]: + """Converts gwei to wei and if value is None, returns None.""" + if value is None: + return None + return GasFees.from_gwei(value) + + @staticmethod + def to_ether(value: Union[Wei, int]) -> Union[int, float]: + """Converts wei to ether. ie 1e18 wei = 1 ether""" + converted_value = Web3.fromWei(value, "ether") + if isinstance(converted_value, Decimal): + return float(converted_value) + return converted_value + + @staticmethod + def from_ether(value: Union[int, float, Decimal]) -> Wei: + """Converts ether to wei. ie 1 ether = 1e18 wei""" + return Web3.toWei(value, "ether") + def __init__( self, endpoint: RPCEndpoint, @@ -65,12 +106,12 @@ def __init__( self.account = account self.transaction_type = transaction_type self.gas_limit = gas_limit - self.legacy_gas_price = legacy_gas_price self.gas_multiplier = gas_multiplier - self.max_fee_per_gas = max_fee_per_gas - self.priority_fee_per_gas = priority_fee_per_gas - self.base_fee_per_gas = base_fee_per_gas - self.max_priority_fee_range = max_priority_fee_range + self.legacy_gas_price = self.optional_from_gwei(legacy_gas_price) + self.max_fee_per_gas = self.optional_from_gwei(max_fee_per_gas) + self.priority_fee_per_gas = self.optional_from_gwei(priority_fee_per_gas) + self.base_fee_per_gas = self.optional_from_gwei(base_fee_per_gas) + self.max_priority_fee_range = self.from_gwei(max_priority_fee_range) self.reward_percentile = reward_percentile or [25.0, 50.0, 75.0] self.block_count = block_count self.min_native_token_balance = min_native_token_balance @@ -80,13 +121,15 @@ def __init__( assert self.web3 is not None, f"Web3 is not initialized, check endpoint {endpoint}" def set_gas_info(self, fees: FEES) -> None: - """Set gas info""" + """Set class variable gas_info keys to values in fees""" for fee in fees: logger.debug(f"Setting gas info {fee} to {fees[fee]}") self.gas_info[fee] = fees[fee] def _reset_gas_info(self) -> None: - """Reset gas info whenever gas price fails to update""" + """Resets class variable gas_info keys to None values + This is used to reset gas_info before updating gas_info + """ self.gas_info = { "maxPriorityFeePerGas": None, "maxFeePerGas": None, @@ -98,32 +141,22 @@ def get_gas_info(self) -> Dict[str, Any]: """Get gas info and remove None values""" return {k: v for k, v in self.gas_info.items() if v is not None} - def get_gas_info_core(self) -> Dict[str, Optional[Union[int, float]]]: - """Convert gas info to gwei and update keys to follow telliot core naming convention""" + def get_gas_info_core(self) -> Dict[str, Union[int, float, Wei, None]]: + """Convert gas info to gwei and update keys to follow telliot core param convention""" gas = self.gas_info return { - "max_fee_per_gas": self.from_wei(gas["maxFeePerGas"]), - "max_priority_fee_per_gas": self.from_wei(gas["maxPriorityFeePerGas"]), - "legacy_gas_price": self.from_wei(gas["gasPrice"]), + "max_fee_per_gas": self.optional_to_gwei(gas["maxFeePerGas"]), + "max_priority_fee_per_gas": self.optional_to_gwei(gas["maxPriorityFeePerGas"]), + "legacy_gas_price": self.optional_to_gwei(gas["gasPrice"]), + "gas_limit": gas["gas"], } - @staticmethod - def from_wei(value: Optional[Wei]) -> Optional[Union[int, float]]: - if value is None: - return None - converted_value = Web3.fromWei(value, "gwei") - if isinstance(converted_value, Decimal): - return float(converted_value) - return converted_value - - @staticmethod - def to_wei(value: Union[int, float, Decimal]) -> Wei: - return Web3.toWei(value, "gwei") - def estimate_gas_amount(self, pre_built_transaction: ContractFunction) -> Tuple[Optional[int], ResponseStatus]: """Estimate the gas amount for a given transaction - should also take in to account the possiblity of a wrong estimation - 'out of gas' error + ie how many gas units will a transaction need to be executed + + Returns: + - gas amount in wei int """ if self.gas_limit is not None: self.set_gas_info({"gas": self.gas_limit}) @@ -142,8 +175,7 @@ def get_legacy_gas_price(self) -> Tuple[Optional[FEES], ResponseStatus]: - gas_price in gwei int """ if self.legacy_gas_price is not None: - gas_price = self.to_wei(self.legacy_gas_price) - return {"gasPrice": gas_price}, ResponseStatus() + return {"gasPrice": self.legacy_gas_price}, ResponseStatus() try: gas_price = self.web3.eth.gas_price @@ -174,12 +206,12 @@ def fee_history(self) -> Tuple[Optional[FeeHistory], ResponseStatus]: # "base fee for the next block after the newest of the returned range" return fee_history, ResponseStatus() except Exception as e: - return None, error_status("Error fetching fee history", e=e) + return None, error_status("Error fetching fee history", e=e, log=logger.error) def get_max_fee(self, base_fee: Wei) -> Wei: """Calculate the max fee for a type 2 (EIP1559) transaction""" if self.max_fee_per_gas is not None: - return self.to_wei(self.max_fee_per_gas) + return self.max_fee_per_gas # if a block is 100% full, the base fee per gas is set to increase by 12.5% for the next block percentage = 12.5 # adding 12.5% to base_fee arg to ensure inclusion to at least the next block @@ -189,15 +221,20 @@ def get_max_fee(self, base_fee: Wei) -> Wei: def get_max_priority_fee(self, fee_history: Optional[FeeHistory] = None) -> Tuple[Optional[Wei], ResponseStatus]: """Return the max priority fee for a type 2 (EIP1559) transaction if priority fee is provided then return the provided priority fee - else return the max priority fee based on the fee history + else try to fetch a priority fee suggestion from the node using Eth._max_priority_fee method + with a fallback that returns the max priority fee based on the fee history Args: - fee_history: Optional[FeeHistory] + + Returns: + - max_priority_fee: Wei + - ResponseStatus """ priority_fee = self.priority_fee_per_gas - max_range = self.to_wei(self.max_priority_fee_range) + max_range = self.max_priority_fee_range if priority_fee is not None: - return self.to_wei(priority_fee), ResponseStatus() + return priority_fee, ResponseStatus() else: try: max_priority_fee = self.web3.eth._max_priority_fee() @@ -217,11 +254,11 @@ def get_max_priority_fee(self, fee_history: Optional[FeeHistory] = None) -> Tupl def get_base_fee(self) -> Tuple[Optional[Union[Wei, FeeHistory]], ResponseStatus]: """Return the base fee for a type 2 (EIP1559) transaction. if base fee is provided then return the provided base fee - else return the base fee based on the fee history + else return the base fee based on the Eth.feed_history method response """ base_fee = self.base_fee_per_gas if base_fee is not None: - return self.to_wei(base_fee), ResponseStatus() + return base_fee, ResponseStatus() else: fee_history, status = self.fee_history() if fee_history is None: @@ -261,20 +298,21 @@ def get_eip1559_gas_price(self) -> Tuple[Optional[FEES], ResponseStatus]: return None, error_status(msg, e=status.error, log=logger.error) # Get max fee max_fee = self.get_max_fee(base_fee) + logger.debug(f"base fee: {base_fee}, priority fee: {priority_fee}, max fee: {max_fee}") else: # if two args are given then we can calculate the third if self.base_fee_per_gas is not None and self.priority_fee_per_gas is not None: # calculate max fee - max_fee = self.get_max_fee(self.to_wei(self.base_fee_per_gas)) - priority_fee = self.to_wei(self.priority_fee_per_gas) + max_fee = self.get_max_fee(self.base_fee_per_gas) + priority_fee = self.priority_fee_per_gas elif self.base_fee_per_gas is not None and self.max_fee_per_gas is not None: # calculate priority fee - priority_fee = Wei(self.to_wei(self.max_fee_per_gas) - self.to_wei(self.base_fee_per_gas)) - max_fee = self.to_wei(self.max_fee_per_gas) + priority_fee = Wei(self.max_fee_per_gas - self.base_fee_per_gas) + max_fee = self.max_fee_per_gas elif self.priority_fee_per_gas is not None and self.max_fee_per_gas is not None: - priority_fee = self.to_wei(self.priority_fee_per_gas) - max_fee = self.to_wei(self.max_fee_per_gas) + priority_fee = self.priority_fee_per_gas + max_fee = self.max_fee_per_gas else: # this should never happen? return None, error_status("Error calculating EIP1559 gas price no args provided", logger.error) @@ -285,21 +323,20 @@ def get_eip1559_gas_price(self) -> Tuple[Optional[FEES], ResponseStatus]: }, ResponseStatus() def update_gas_fees(self) -> ResponseStatus: - """Return gas parameters for a transaction""" + """Update class gas_info with the latest gas fees whenever called""" + self._reset_gas_info() if self.transaction_type == 0: legacy_gas_fees, status = self.get_legacy_gas_price() if legacy_gas_fees is None: - self._reset_gas_info() return error_status( - "Failed to update gas fees for legacy type transaction", e=status.error, log=logger.debug + "Failed to update gas fees for legacy type transaction", e=status.error, log=logger.error ) self.set_gas_info(legacy_gas_fees) - logger.debug(f"Gas price: {legacy_gas_fees} status: {status}") + logger.debug(f"Legacy transaction gas price: {legacy_gas_fees} status: {status}") return status elif self.transaction_type == 2: eip1559_gas_fees, status = self.get_eip1559_gas_price() if eip1559_gas_fees is None: - self._reset_gas_info() return error_status( "Failed to update gas fees for EIP1559 type transaction", e=status.error, log=logger.debug ) @@ -307,7 +344,5 @@ def update_gas_fees(self) -> ResponseStatus: logger.debug(f"Gas fees: {eip1559_gas_fees} status: {status}") return status else: - self._reset_gas_info() - msg = "Failed to update gas fees" - e = f"Invalid transaction type: {self.transaction_type}" - return error_status(msg, e=e, log=logger.error) + msg = f"Failed to update gas fees: invalid transaction type: {self.transaction_type}" + return error_status(msg, log=logger.error) diff --git a/src/telliot_feeds/reporters/stake.py b/src/telliot_feeds/reporters/stake.py index 0764a8c6..ab117718 100644 --- a/src/telliot_feeds/reporters/stake.py +++ b/src/telliot_feeds/reporters/stake.py @@ -35,21 +35,20 @@ async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: if not allowance_status.ok: msg = "Unable to check allowance:" return False, error_status(msg, e=allowance_status.error, log=logger.error) - logger.debug(f"Current allowance: {allowance / 1e18!r}") - # calculate and set gas params - status = self.update_gas_fees() - if not status.ok: - return False, error_status("unable to calculate fees for approve txn", e=status.error, log=logger.error) - fees = self.get_gas_info_core() # if allowance is less than amount_to_stake then approve if allowance < amount: # Approve token spending logger.info(f"Approving {self.oracle.address} token spending: {amount}...") + # calculate and set gas params + status = self.update_gas_fees() + if not status.ok: + return False, error_status("unable to calculate fees for approve txn", e=status.error, log=logger.error) + fees = self.get_gas_info_core() + approve_receipt, approve_status = await self.token.write( func_name="approve", - gas_limit=self.gas_limit, # have to convert to gwei because of telliot_core where numbers are converted to wei # consider changing this in telliot_core spender=self.oracle.address, @@ -73,7 +72,6 @@ async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: fees = self.get_gas_info_core() deposit_receipt, deposit_status = await self.oracle.write( func_name="depositStake", - gas_limit=self.gas_limit, _amount=amount, **fees, ) diff --git a/src/telliot_feeds/reporters/tellor_360.py b/src/telliot_feeds/reporters/tellor_360.py index e40d3838..a2a05b3f 100644 --- a/src/telliot_feeds/reporters/tellor_360.py +++ b/src/telliot_feeds/reporters/tellor_360.py @@ -43,7 +43,6 @@ class Tellor360Reporter(Stake): def __init__( self, - min_native_token_balance: int, autopay: Contract, chain_id: int, datafeed: Optional[DataFeed[Any]] = None, @@ -56,13 +55,12 @@ def __init__( **kwargs: Any, ) -> None: super().__init__(**kwargs) - self.min_native_token_balance = min_native_token_balance self.autopay = autopay self.datafeed = datafeed self.use_random_feeds: bool = use_random_feeds self.qtag_selected = False if self.datafeed is None else True self.expected_profit = expected_profit - self.stake: float = stake + self.stake: float = self.from_ether(stake) self.stake_info = StakeInfo() self.check_rewards: bool = check_rewards self.autopaytip = 0 @@ -78,11 +76,11 @@ async def get_stake_amount(self) -> Tuple[Optional[int], ResponseStatus]: Returns: - (int, ResponseStatus) the current stake amount in TellorFlex """ - response, status = await self.oracle.read("getStakeAmount") + stake_amount: int + stake_amount, status = await self.oracle.read("getStakeAmount") if not status.ok: - msg = "Unable to read current stake amount" + msg = f"Unable to read current stake amount: {status.error}" return None, error_status(msg, status.e, log=logger.error) - stake_amount: int = response return stake_amount, status async def get_staker_details(self) -> Tuple[Optional[StakerInfo], ResponseStatus]: @@ -93,19 +91,19 @@ async def get_staker_details(self) -> Tuple[Optional[StakerInfo], ResponseStatus """ response, status = await self.oracle.read("getStakerInfo", _stakerAddress=self.acct_addr) if not status.ok: - msg = "Unable to read account staker info:" + msg = f"Unable to read account staker info: {status.error}" return None, error_status(msg, status.e, log=logger.error) staker_details = StakerInfo(*response) return staker_details, status async def get_current_token_balance(self) -> Tuple[Optional[int], ResponseStatus]: """Reads the current balance of the account""" - response, status = await self.token.read("balanceOf", account=self.acct_addr) + wallet_balance: int + wallet_balance, status = await self.token.read("balanceOf", account=self.acct_addr) if not status.ok: - msg = "Unable to read account balance:" + msg = f"Unable to read account balance: {status.error}" return None, error_status(msg, status.e, log=logger.error) - wallet_balance: int = response - logger.info(f"Current wallet TRB balance: {wallet_balance / 1e18!r}") + logger.info(f"Current wallet TRB balance: {self.to_ether(wallet_balance)!r}") return wallet_balance, status async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: @@ -143,7 +141,7 @@ async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: STAKER INFO start date: {staker_details.start_date} - stake_balance: {staker_details.stake_balance / 1e18!r} + stake_balance: {self.to_ether(staker_details.stake_balance)!r} locked_balance: {staker_details.locked_balance} last report: {staker_details.last_report} reports count: {staker_details.reports_count} @@ -152,7 +150,7 @@ async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: # deposit stake if stakeAmount in oracle is greater than account stake or # a stake in cli is selected thats greater than account stake - chosen_stake_amount = (self.stake * 1e18) > staker_details.stake_balance + chosen_stake_amount = self.stake > staker_details.stake_balance if chosen_stake_amount: logger.info("Chosen stake is greater than account stake balance") if self.stake_info.stake_amount_gt_staker_balance or chosen_stake_amount: @@ -161,7 +159,7 @@ async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: # current oracle stake amount vs current account stake balance to_stake_amount_1 = self.stake_info.current_stake_amount - staker_details.stake_balance # chosen stake via cli flag vs current account stake balance - to_stake_amount_2 = (self.stake * 1e18) - staker_details.stake_balance + to_stake_amount_2 = self.stake - staker_details.stake_balance amount_to_stake = max(int(to_stake_amount_1), int(to_stake_amount_2)) # check TRB wallet balance! @@ -171,8 +169,8 @@ async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: if amount_to_stake > wallet_balance: msg = ( - f"Amount to stake: {amount_to_stake/1e18:.04f} " - f"is greater than your balance: {wallet_balance/1e18:.04f} so " + f"Amount to stake: {self.to_ether(amount_to_stake):.04f} " + f"is greater than your balance: {self.to_ether(wallet_balance):.04f} so " "not enough TRB to cover the stake" ) return False, error_status(msg, log=logger.warning) @@ -225,7 +223,7 @@ async def rewards(self) -> int: elif self.chain_id in CHAINS_WITH_TBR: logger.info("Fetching time based rewards") time_based_rewards = await get_time_based_rewards(self.oracle) - logger.info(f"Time based rewards: {time_based_rewards/1e18:.04f}") + logger.info(f"Time based rewards: {self.to_ether(time_based_rewards):.04f}") if time_based_rewards is not None: self.autopaytip += time_based_rewards return self.autopaytip @@ -260,7 +258,7 @@ async def fetch_datafeed(self) -> Optional[DataFeed[Any]]: if suggested_feed is not None and tip_amount is not None: logger.info(f"Most funded datafeed in Autopay: {suggested_feed.query.type}") - logger.info(f"Tip amount: {tip_amount/1e18}") + logger.info(f"Tip amount: {self.to_ether(tip_amount)}") self.autopaytip += tip_amount self.datafeed = suggested_feed @@ -274,7 +272,7 @@ async def ensure_profitable(self) -> ResponseStatus: if not self.check_rewards: return status - tip = self.autopaytip + tip = self.to_ether(self.autopaytip) # Fetch token prices in USD native_token_feed = get_native_token_feed(self.chain_id) price_feeds = [native_token_feed, trb_usd_median_feed] @@ -285,31 +283,30 @@ async def ensure_profitable(self) -> ResponseStatus: if price_native_token is None or price_trb_usd is None: return error_status("Unable to fetch token price", log=logger.warning) - if not self.gas_info: - return error_status("Gas info not set", log=logger.warning) + gas_info = self.get_gas_info_core() + max_fee_per_gas = gas_info["max_fee_per_gas"] + legacy_gas_price = gas_info["legacy_gas_price"] + gas_limit = gas_info["gas_limit"] - gas_info = self.gas_info - - m = gas_info["maxFeePerGas"] if gas_info["maxFeePerGas"] else gas_info["gasPrice"] + max_gas = max_fee_per_gas if max_fee_per_gas else legacy_gas_price # multiply gasPrice by gasLimit - if m is None: + if max_gas is None or gas_limit is None: return error_status("Unable to calculate profitablity, no gas fees set", log=logger.warning) - max_fee = float(self.from_wei(m)) # type: ignore - gas_ = float(self.from_wei(gas_info["gas"])) # type: ignore - txn_fee = max_fee * gas_ + + txn_fee = max_gas * self.to_gwei(int(gas_limit)) logger.info( f"""\n - Tips: {tip/1e18} + Tips: {tip} Transaction fee: {txn_fee} {tkn_symbol(self.chain_id)} - Gas limit: {gas_info["gas"]} - Gas price: {self.from_wei(gas_info["gasPrice"])} gwei - Max fee per gas: {self.from_wei(gas_info["maxFeePerGas"])} gwei - Max priority fee per gas: {self.from_wei(gas_info["maxPriorityFeePerGas"])} gwei + Gas limit: {gas_limit} + Gas price: {legacy_gas_price} gwei + Max fee per gas: {max_fee_per_gas} gwei + Max priority fee per gas: {gas_info["max_priority_fee_per_gas"]} gwei Txn type: {self.transaction_type}\n""" ) # Calculate profit - rev_usd = tip / 1e18 * price_trb_usd + rev_usd = tip * price_trb_usd costs_usd = txn_fee * price_native_token # convert gwei costs to eth, then to usd profit_usd = rev_usd - costs_usd logger.info(f"Estimated profit: ${round(profit_usd, 2)}") @@ -391,15 +388,16 @@ def build_transaction( if contract_function is None: return None, error_status("Error building function to estimate gas", status.e, logger.error) - _, status = self.estimate_gas_amount(contract_function) - if not status: - return None, error_status(f"Error estimating gas for function: {contract_function}", status.e, logger.error) # set gas parameters globally status = self.update_gas_fees() logger.debug(status) if not status.ok: return None, error_status("Error setting gas parameters", status.e, logger.error) + _, status = self.estimate_gas_amount(contract_function) + if not status.ok: + return None, error_status(f"Error estimating gas for function: {contract_function}", status.e, logger.error) + params, status = self.tx_params(**self.get_gas_info()) logger.debug(f"Transaction parameters: {params}") if params is None: diff --git a/tests/reporters/test_backup_reporter.py b/tests/reporters/test_backup_reporter.py index 82f28b2c..f9d6c86f 100644 --- a/tests/reporters/test_backup_reporter.py +++ b/tests/reporters/test_backup_reporter.py @@ -12,7 +12,7 @@ @pytest.fixture(scope="function") -async def reporter(tellor_360, guaranteed_price_source, mock_flex_contract, mock_token_contract): +async def reporter(tellor_360, guaranteed_price_source): contracts, account = tellor_360 feed = eth_usd_median_feed feed.source = guaranteed_price_source diff --git a/tests/reporters/test_gas_fees.py b/tests/reporters/test_gas_fees.py index 3ac14ba5..b87f0f15 100644 --- a/tests/reporters/test_gas_fees.py +++ b/tests/reporters/test_gas_fees.py @@ -95,18 +95,18 @@ async def test_get_eip1559_gas_price(gas_fees_object): gas.base_fee_per_gas = 136 eip1559_gas_price, status = gas.get_eip1559_gas_price() assert status.ok - assert eip1559_gas_price["maxPriorityFeePerGas"] == 1000000000 # 1 Gwei - assert eip1559_gas_price["maxFeePerGas"] == gas.get_max_fee(gas.to_wei(136)) + assert eip1559_gas_price["maxPriorityFeePerGas"] == 1 + assert eip1559_gas_price["maxFeePerGas"] == gas.get_max_fee(136) # when 1 out 3 fee args are user provided fee history from node used # and maxFeePerGas and priority fee are calculated from fee history type(gas.web3.eth).fee_history = Mock(return_value=mock_fee_history) gas.priority_fee_per_gas = None - gas.base_fee_per_gas = 150 + gas.base_fee_per_gas = gas.from_gwei(150) eip1559_gas_price, status = gas.get_eip1559_gas_price() assert status.ok assert eip1559_gas_price["maxPriorityFeePerGas"] == 1000000000 # 1 Gwei - assert eip1559_gas_price["maxFeePerGas"] == gas.get_max_fee(gas.to_wei(150)) + assert eip1559_gas_price["maxFeePerGas"] == gas.get_max_fee(gas.from_gwei(150)) @pytest.mark.asyncio @@ -119,7 +119,12 @@ async def test_update_gas_fees(gas_fees_object): assert gas.gas_info["gas"] is None assert gas.gas_info["maxFeePerGas"] is None assert gas.gas_info["maxPriorityFeePerGas"] is None - gas_info_core = {"max_fee_per_gas": None, "max_priority_fee_per_gas": None, "legacy_gas_price": 20.2} + gas_info_core = { + "gas_limit": None, + "max_fee_per_gas": None, + "max_priority_fee_per_gas": None, + "legacy_gas_price": 20.2, + } assert gas.get_gas_info_core() == gas_info_core gas.web3 = Mock() @@ -147,7 +152,7 @@ async def test_update_gas_fees(gas_fees_object): ) gas.transaction_type = 2 type(gas.web3.eth).fee_history = Mock(return_value=mock_fee_history) - type(gas.web3.eth)._max_priority_fee = Mock(return_value=gas.to_wei(1)) + type(gas.web3.eth)._max_priority_fee = Mock(return_value=gas.from_gwei(1)) status = gas.update_gas_fees() base_fee = mock_fee_history["baseFeePerGas"][-1] assert status.ok @@ -167,4 +172,4 @@ async def test_update_gas_fees(gas_fees_object): status = gas.update_gas_fees() assert gas.gas_info["maxFeePerGas"] is None assert gas.gas_info["maxPriorityFeePerGas"] is None - assert "Failed to update gas fees: 'Invalid transaction type: 5'" in status.error + assert "Failed to update gas fees: invalid transaction type: 5" in status.error diff --git a/tests/reporters/test_reporter.py b/tests/reporters/test_reporter.py new file mode 100644 index 00000000..029732c0 --- /dev/null +++ b/tests/reporters/test_reporter.py @@ -0,0 +1,65 @@ +import pytest +from unittest.mock import Mock +from telliot_feeds.reporters.types import StakerInfo +from telliot_feeds.reporters.tellor_360 import Tellor360Reporter + + +@pytest.mark.asyncio +async def test_get_stake_amount(tellor_flex_reporter): + r: Tellor360Reporter = tellor_flex_reporter + stake_amount, status = await r.get_stake_amount() + assert stake_amount > 0 + assert isinstance(stake_amount, int) + assert status.ok + + type(r.oracle.contract).get_function_by_name = Mock(side_effect=Exception("Mocked exception")) + stake_amount, status = await r.get_stake_amount() + assert stake_amount is None + assert not status.ok + assert status.error == ( + "Unable to read current stake amount: error reading from contract: Exception('Mocked exception')" + ) + +@pytest.mark.asyncio +async def test_get_staker_details(tellor_flex_reporter): + r: Tellor360Reporter = tellor_flex_reporter + staker_details, status = await r.get_staker_details() + # staker details before any staking/reporting + assert isinstance(staker_details, StakerInfo) + assert staker_details.start_date == 0 + assert status.ok + + type(r.oracle.contract).get_function_by_name = Mock(side_effect=Exception("Mocked exception")) + staker_details, status = await r.get_staker_details() + assert staker_details is None + assert not status.ok + assert status.error == ( + "Unable to read account staker info: error reading from contract: Exception('Mocked exception')" + ) + +@pytest.mark.asyncio +async def test_get_current_token_balance(tellor_flex_reporter): + r: Tellor360Reporter = tellor_flex_reporter + token_balance, status = await r.get_current_token_balance() + assert token_balance > 0 + assert isinstance(token_balance, int) + assert status.ok + + type(r.token.contract).get_function_by_name = Mock(side_effect=Exception("Mocked exception")) + token_balance, status = await r.get_current_token_balance() + assert token_balance is None + assert not status.ok + assert status.error == ( + "Unable to read account balance: error reading from contract: Exception('Mocked exception')" + ) + +@pytest.mark.asyncio +async def test_ensure_staked(tellor_flex_reporter): + r: Tellor360Reporter = tellor_flex_reporter + staked, status = await r.ensure_staked() + type(r.oracle.contract).get_function_by_name = Mock(side_effect=Exception("Mocked exception")) + staked, status = await r.ensure_staked() + assert not status.ok + assert not staked + print(status.error) + \ No newline at end of file From cc3706ca792236547b44856d0f363529c6443c14 Mon Sep 17 00:00:00 2001 From: akrem Date: Fri, 23 Jun 2023 23:29:29 -0400 Subject: [PATCH 12/28] tox style --- tests/reporters/test_reporter.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/reporters/test_reporter.py b/tests/reporters/test_reporter.py index 029732c0..4f2e323b 100644 --- a/tests/reporters/test_reporter.py +++ b/tests/reporters/test_reporter.py @@ -1,7 +1,9 @@ -import pytest from unittest.mock import Mock -from telliot_feeds.reporters.types import StakerInfo + +import pytest + from telliot_feeds.reporters.tellor_360 import Tellor360Reporter +from telliot_feeds.reporters.types import StakerInfo @pytest.mark.asyncio @@ -20,6 +22,7 @@ async def test_get_stake_amount(tellor_flex_reporter): "Unable to read current stake amount: error reading from contract: Exception('Mocked exception')" ) + @pytest.mark.asyncio async def test_get_staker_details(tellor_flex_reporter): r: Tellor360Reporter = tellor_flex_reporter @@ -37,6 +40,7 @@ async def test_get_staker_details(tellor_flex_reporter): "Unable to read account staker info: error reading from contract: Exception('Mocked exception')" ) + @pytest.mark.asyncio async def test_get_current_token_balance(tellor_flex_reporter): r: Tellor360Reporter = tellor_flex_reporter @@ -53,6 +57,7 @@ async def test_get_current_token_balance(tellor_flex_reporter): "Unable to read account balance: error reading from contract: Exception('Mocked exception')" ) + @pytest.mark.asyncio async def test_ensure_staked(tellor_flex_reporter): r: Tellor360Reporter = tellor_flex_reporter @@ -62,4 +67,3 @@ async def test_ensure_staked(tellor_flex_reporter): assert not status.ok assert not staked print(status.error) - \ No newline at end of file From 8839d22178d5d669fd2154b37a65389418635c97 Mon Sep 17 00:00:00 2001 From: akrem Date: Sat, 24 Jun 2023 19:17:27 -0400 Subject: [PATCH 13/28] Fix broken tests --- src/telliot_feeds/cli/commands/liquity.py | 17 ++++++++--------- tests/sources/test_evm_call_source.py | 2 +- tests/test_bct_usd.py | 2 +- tests/test_dai_usd.py | 2 +- tests/test_numeric_api_response_feed.py | 2 +- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/telliot_feeds/cli/commands/liquity.py b/src/telliot_feeds/cli/commands/liquity.py index 6875db1d..9895366a 100644 --- a/src/telliot_feeds/cli/commands/liquity.py +++ b/src/telliot_feeds/cli/commands/liquity.py @@ -41,8 +41,9 @@ async def liquity( ctx: Context, tx_type: int, gas_limit: int, - max_fee: Optional[float], - priority_fee: Optional[float], + 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, @@ -84,9 +85,6 @@ async def liquity( raise click.UsageError("Invalid chain link feed address") ctx.obj["CHAIN_ID"] = chain_id # used in reporter_cli_core - # if max_fee flag is set, then priority_fee must also be set - if (max_fee is not None and priority_fee is None) or (max_fee is None and priority_fee is not None): - raise click.UsageError("Must specify both max fee and priority fee") # Initialize telliot core app using CLI context async with reporter_cli_core(ctx) as core: @@ -110,8 +108,8 @@ async def liquity( 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}") - click.echo(f"Priority fee (gwei): {priority_fee}") + 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") @@ -125,8 +123,9 @@ async def liquity( "account": account, "datafeed": datafeed, "gas_limit": gas_limit, - "max_fee": max_fee, - "priority_fee": priority_fee, + "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, diff --git a/tests/sources/test_evm_call_source.py b/tests/sources/test_evm_call_source.py index f32efc1c..fdcf70ea 100644 --- a/tests/sources/test_evm_call_source.py +++ b/tests/sources/test_evm_call_source.py @@ -123,7 +123,7 @@ async def test_report_for_bad_calldata(tellor_360): await r.report_once() # call r.ensure_staked to update staker info await r.ensure_staked() - assert r.staker_info.reports_count == 1 + assert r.stake_info.reports_count == 1 def test_evm_call_on_previous_block(): diff --git a/tests/test_bct_usd.py b/tests/test_bct_usd.py index 8fe94dcc..34d88630 100644 --- a/tests/test_bct_usd.py +++ b/tests/test_bct_usd.py @@ -40,7 +40,7 @@ async def test_bct_usd_reporter_submit_once( autopay=flex.autopay, transaction_type=0, datafeed=bct_usd_median_feed, - max_fee=100, + max_fee_per_gas=100, ) ORACLE_ADDRESSES = {mock_flex_contract.address} diff --git a/tests/test_dai_usd.py b/tests/test_dai_usd.py index 4b275291..659c77eb 100644 --- a/tests/test_dai_usd.py +++ b/tests/test_dai_usd.py @@ -40,7 +40,7 @@ async def test_dai_usd_reporter_submit_once( autopay=flex.autopay, transaction_type=0, datafeed=dai_usd_median_feed, - max_fee=100, + max_fee_per_gas=100, ) ORACLE_ADDRESSES = {mock_flex_contract.address} diff --git a/tests/test_numeric_api_response_feed.py b/tests/test_numeric_api_response_feed.py index 82975370..0c6aa377 100644 --- a/tests/test_numeric_api_response_feed.py +++ b/tests/test_numeric_api_response_feed.py @@ -49,7 +49,7 @@ async def test_api_reporter_submit_once( autopay=flex.autopay, transaction_type=0, datafeed=numeric_api_rsp_feed, - max_fee=100, + max_fee_per_gas=100, ) ORACLE_ADDRESSES = {mock_flex_contract.address} From e832beb7787d53716c4de5768447237306c5d77f Mon Sep 17 00:00:00 2001 From: akrem Date: Mon, 26 Jun 2023 07:01:02 -0400 Subject: [PATCH 14/28] Add test --- tests/reporters/test_reporter.py | 142 +++++++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 6 deletions(-) diff --git a/tests/reporters/test_reporter.py b/tests/reporters/test_reporter.py index 4f2e323b..64aa99c2 100644 --- a/tests/reporters/test_reporter.py +++ b/tests/reporters/test_reporter.py @@ -1,7 +1,12 @@ from unittest.mock import Mock +from unittest.mock import patch import pytest +from telliot_core.utils.response import ResponseStatus +from web3.contract import Contract +from telliot_feeds.feeds import matic_usd_median_feed +from telliot_feeds.feeds import trb_usd_median_feed from telliot_feeds.reporters.tellor_360 import Tellor360Reporter from telliot_feeds.reporters.types import StakerInfo @@ -60,10 +65,135 @@ async def test_get_current_token_balance(tellor_flex_reporter): @pytest.mark.asyncio async def test_ensure_staked(tellor_flex_reporter): + """Test ensure_staked method.""" r: Tellor360Reporter = tellor_flex_reporter - staked, status = await r.ensure_staked() - type(r.oracle.contract).get_function_by_name = Mock(side_effect=Exception("Mocked exception")) - staked, status = await r.ensure_staked() - assert not status.ok - assert not staked - print(status.error) + # staker balance should initiate with None + assert r.stake_info.current_staker_balance is None + # ensure stake method fetches stake amount and staker details + # and stakes reporter if not already staked or has a low stake + known_stake, status = await r.ensure_staked() + # check that reporter got staked + assert r.stake_info.current_staker_balance > 0 + assert status.ok + assert known_stake + # mock the oracle read call to raise an exception + with patch.object(Contract, "get_function_by_name", side_effect=Exception("Mocked exception")): + known_stake, status = await r.ensure_staked() + assert not status.ok + assert not known_stake + + +@pytest.mark.asyncio +async def test_check_stake_amount_change(tellor_flex_reporter): + """Test what happens when the stake amount changes during reporting loops.""" + r: Tellor360Reporter = tellor_flex_reporter + # check stake amount + stake_amount, status = await r.get_stake_amount() + assert stake_amount > 0 + # check staker details + check1, status = await r.get_staker_details() + assert status.ok + assert check1.stake_balance == 0 + # stake by calling ensure_staked method which automatically stakes reporter + # if staked balance is lower than stake amount + known_stake, status = await r.ensure_staked() + assert known_stake + assert status.ok + # check staker details again + check2, status = await r.get_staker_details() + assert status.ok + assert check2.stake_balance > check1.stake_balance + # check staker details again after calling ensure_staked, should be same as check2 + known_stake, status = await r.ensure_staked() + assert known_stake + assert status.ok + check3, status = await r.get_staker_details() + assert status.ok + assert check3.stake_balance == check2.stake_balance + # mock increasing stake amount + with patch.object(Tellor360Reporter, "get_stake_amount", return_value=(stake_amount + 1, ResponseStatus())): + known_stake, status = await r.ensure_staked() + assert known_stake + assert status.ok + check4, status = await r.get_staker_details() + assert status.ok + # reporter staked balance should be increased by 1 + assert check4.stake_balance == check3.stake_balance + 1 + # mock additional stake chosen by user + assert r.stake == 0 + r.stake = stake_amount + 2 + known_stake, status = await r.ensure_staked() + assert known_stake + assert status.ok + b, status = await r.get_staker_details() + assert status.ok + # reporter staked balance should be increased by 2 + assert b.stake_balance == check3.stake_balance + 2 + + +@pytest.mark.asyncio +async def test_staking_after_a_reporter_slashing(tellor_flex_reporter, caplog): + """Test when reporter is disputed that they automatically be staked again""" + r: Tellor360Reporter = tellor_flex_reporter + # ensure reporter is staked + known_stake, status = await r.ensure_staked() + assert known_stake + assert status.ok + # mock reporter being slashed + with patch.object( + Tellor360Reporter, + "get_staker_details", + return_value=(StakerInfo(0, 0, 0, 0, 0, 0, 0, 0, True), ResponseStatus()), + ): + trb_balance, status = await r.get_current_token_balance() + known_stake, status = await r.ensure_staked() + assert known_stake + assert status.ok + assert "Your staked balance has decreased, account might be in dispute" in caplog.text + trb_balance2, status = await r.get_current_token_balance() + assert trb_balance2 < trb_balance + + +@pytest.mark.asyncio +async def test_stake_info(tellor_flex_reporter, guaranteed_price_source, chain): + """Test stake info changes and status""" + r: Tellor360Reporter = tellor_flex_reporter + feed = matic_usd_median_feed + feed.source = guaranteed_price_source + trb_usd_median_feed.source = guaranteed_price_source + r.expected_profit = "YOLO" + r.datafeed = feed + with patch.object(Tellor360Reporter, "check_reporter_lock", return_value=ResponseStatus()): + assert r.stake_info.last_report == 0 + assert r.stake_info.reports_count == 0 + assert not r.stake_info.is_in_dispute() + assert r.stake_info.last_report_time == 0 + assert r.stake_info.last_report_time == 0 + assert len(r.stake_info.stake_amount_history) == 0 + assert len(r.stake_info.staker_balance_history) == 0 + # report and check info + await r.report_once() + assert len(r.stake_info.stake_amount_history) == 1 + # this should be of length 2 since its updated after staking + assert len(r.stake_info.staker_balance_history) == 2 + # bypass 12 hour reporting lock + chain.sleep(84600) + r.datafeed = feed + await r.report_once() + # last report time should update during the second reporting loop + assert r.stake_info.last_report_time > 0 + # reports count should be 1 during the second reporting loop + assert r.stake_info.reports_count == 1 + # should be of length always since thats the max datapoints + assert len(r.stake_info.stake_amount_history) == 2 + assert len(r.stake_info.staker_balance_history) == 2 + + await r.report_once() + # reports count should be 2 during the third reporting loop + assert r.stake_info.reports_count == 2 + assert len(r.stake_info.stake_amount_history) == 2 + assert len(r.stake_info.staker_balance_history) == 2 + # mock a dispute by inputing a bad value to staker balance history deque + r.stake_info.staker_balance_history.append(0) + # dispute should be detected and return True + assert r.stake_info.is_in_dispute() From 51aceef9f334622b02e11a09a0c882efb1685341 Mon Sep 17 00:00:00 2001 From: akrem Date: Mon, 26 Jun 2023 11:26:33 -0400 Subject: [PATCH 15/28] Fix diva_protocol/report with new changes --- src/telliot_feeds/integrations/diva_protocol/report.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/telliot_feeds/integrations/diva_protocol/report.py b/src/telliot_feeds/integrations/diva_protocol/report.py index c939a6f3..19e4f905 100644 --- a/src/telliot_feeds/integrations/diva_protocol/report.py +++ b/src/telliot_feeds/integrations/diva_protocol/report.py @@ -4,7 +4,6 @@ import asyncio import time from typing import Any -from typing import Dict from typing import Optional from typing import Tuple @@ -148,7 +147,6 @@ async def settle_pool(self, pool_id: str) -> ResponseStatus: return error_status("unable to generate gas fees", log=logger.error) gas_fees = self.get_gas_info_core() - # gas_price = int(gas_price) if gas_price >= 1 else 1 status = await self.set_final_ref_value(pool_id=pool_id, gas_fees=gas_fees) if status is not None and status.ok: logger.info(f"Pool {pool_id} settled.") @@ -221,6 +219,7 @@ def sign_n_send_transaction(self, built_tx: Any) -> Tuple[Optional[TxReceipt], R local_account = self.account.local_account tx_signed = local_account.sign_transaction(built_tx) try: + logger.debug("Sending submitValue transaction") tx_hash = self.web3.eth.send_raw_transaction(tx_signed.rawTransaction) except Exception as e: note = "Send transaction failed" From 177095769629fab0dc76e63bf72bdd08a9fb27a5 Mon Sep 17 00:00:00 2001 From: akrem Date: Mon, 26 Jun 2023 12:05:45 -0400 Subject: [PATCH 16/28] fix import --- src/telliot_feeds/integrations/diva_protocol/report.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/telliot_feeds/integrations/diva_protocol/report.py b/src/telliot_feeds/integrations/diva_protocol/report.py index 19e4f905..840dfc35 100644 --- a/src/telliot_feeds/integrations/diva_protocol/report.py +++ b/src/telliot_feeds/integrations/diva_protocol/report.py @@ -4,6 +4,7 @@ import asyncio import time from typing import Any +from typing import Dict from typing import Optional from typing import Tuple From 080209ccebf43c00407538469d328b817a8a797e Mon Sep 17 00:00:00 2001 From: akrem Date: Mon, 26 Jun 2023 13:18:24 -0400 Subject: [PATCH 17/28] Fix diva test --- src/telliot_feeds/integrations/diva_protocol/report.py | 1 - src/telliot_feeds/reporters/tellor_360.py | 9 +++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/telliot_feeds/integrations/diva_protocol/report.py b/src/telliot_feeds/integrations/diva_protocol/report.py index 840dfc35..c582da5d 100644 --- a/src/telliot_feeds/integrations/diva_protocol/report.py +++ b/src/telliot_feeds/integrations/diva_protocol/report.py @@ -237,7 +237,6 @@ def sign_n_send_transaction(self, built_tx: Any) -> Tuple[Optional[TxReceipt], R return tx_receipt, error_status(msg, log=logger.error) logger.info(f"View reported data: \n{tx_url}") - self.last_submission_timestamp = 0 # Update reported pools pools = get_reported_pools() cur_time = int(time.time()) diff --git a/src/telliot_feeds/reporters/tellor_360.py b/src/telliot_feeds/reporters/tellor_360.py index a2a05b3f..ca210f5d 100644 --- a/src/telliot_feeds/reporters/tellor_360.py +++ b/src/telliot_feeds/reporters/tellor_360.py @@ -320,15 +320,9 @@ async def ensure_profitable(self) -> ResponseStatus: status.ok = False status.error = "Estimated profitability below threshold." logger.info(status.error) - # reset datafeed for a new suggestion if qtag wasn't selected in cli - if self.qtag_selected is False: - self.datafeed = None return status # reset autopay tip to check for tips again self.autopaytip = 0 - # reset datafeed for a new suggestion if qtag wasn't selected in cli - if self.qtag_selected is False: - self.datafeed = None return status @@ -502,6 +496,9 @@ async def report_once( logger.debug("Sending submitValue transaction") tx_receipt, status = self.sign_n_send_transaction(build_tx) + # reset datafeed for a new suggestion if qtag wasn't selected in cli + if self.qtag_selected is False: + self.datafeed = None return tx_receipt, status From d1ccd89248e0a8498a4528408c844ab93c9abb36 Mon Sep 17 00:00:00 2001 From: akrem Date: Mon, 17 Jul 2023 23:45:35 -0400 Subject: [PATCH 18/28] Make suggested changes and add tests --- .../cli/commands/request_withdraw_stake.py | 69 ++++--------------- src/telliot_feeds/cli/commands/stake.py | 4 +- src/telliot_feeds/cli/commands/withdraw.py | 57 +++++++++++++++ src/telliot_feeds/cli/main.py | 2 + src/telliot_feeds/cli/utils.py | 49 +++++++++++++ .../integrations/diva_protocol/report.py | 1 + src/telliot_feeds/reporters/gas.py | 8 +-- src/telliot_feeds/utils/stake_info.py | 4 +- tests/cli/test_stake_e2e_cmd.py | 29 ++++++++ tests/cli/test_utils.py | 62 +++++++++++++++++ tests/reporters/test_360_reporter.py | 4 -- tests/reporters/test_gas_fees.py | 3 +- 12 files changed, 221 insertions(+), 71 deletions(-) create mode 100644 src/telliot_feeds/cli/commands/withdraw.py create mode 100644 tests/cli/test_stake_e2e_cmd.py diff --git a/src/telliot_feeds/cli/commands/request_withdraw_stake.py b/src/telliot_feeds/cli/commands/request_withdraw_stake.py index b774b30f..3add5ca8 100644 --- a/src/telliot_feeds/cli/commands/request_withdraw_stake.py +++ b/src/telliot_feeds/cli/commands/request_withdraw_stake.py @@ -2,21 +2,16 @@ import click from click.core import Context -from eth_utils import to_checksum_address from telliot_core.cli.utils import async_run +from telliot_feeds.cli.utils import call_oracle from telliot_feeds.cli.utils import common_options from telliot_feeds.cli.utils import get_accounts_from_name -from telliot_feeds.cli.utils import reporter_cli_core -from telliot_feeds.reporters.gas import GasFees -from telliot_feeds.utils.cfg import check_endpoint -from telliot_feeds.utils.cfg import setup_config -from telliot_feeds.utils.reporter_utils import has_native_token_funds @click.group() def request_withdraw_stake() -> None: - """request to withdraw tokens from the Tellor oracle which locks them for 7 days.""" + """Request to withdraw tokens from the Tellor oracle which locks them for 7 days.""" pass @@ -51,50 +46,16 @@ async def request_withdraw( ctx.obj["CHAIN_ID"] = accounts[0].chains[0] # 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) - - 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) - - contracts = core.get_tellor360_contracts() - # set private key for oracle interaction calls - contracts.oracle._private_key = account.local_account.privateKey - - class_kwargs = { - "endpoint": core.endpoint, - "account": account, - "gas_limit": gas_limit, - "base_fee_per_gas": base_fee_per_gas, - "priority_fee_per_gas": priority_fee_per_gas, - "max_fee_per_gas": max_fee_per_gas, - "legacy_gas_price": legacy_gas_price, - "transaction_type": tx_type, - "gas_multiplier": gas_multiplier, - "max_priority_fee_range": max_priority_fee_range, - } - if has_native_token_funds( - to_checksum_address(account.address), - core.endpoint.web3, - min_balance=int(min_native_token_balance * 10**18), - ): - gas = GasFees(**class_kwargs) - gas.update_gas_fees() - gas_info = gas.get_gas_info_core() - _ = await contracts.oracle.write( - "requestStakingWithdraw", - _amount=int(amount * 1e18), - gas_limit=gas_limit, - **gas_info, - ) + user_inputs = { + "password": password, + "min_native_token_balance": min_native_token_balance, + "gas_limit": gas_limit, + "base_fee_per_gas": base_fee_per_gas, + "priority_fee_per_gas": priority_fee_per_gas, + "max_fee_per_gas": max_fee_per_gas, + "legacy_gas_price": legacy_gas_price, + "transaction_type": tx_type, + "gas_multiplier": gas_multiplier, + "max_priority_fee_range": max_priority_fee_range, + } + await call_oracle(ctx=ctx, func="requestStakingWithdraw", user_inputs=user_inputs, _amount=int(amount * 1e18)) diff --git a/src/telliot_feeds/cli/commands/stake.py b/src/telliot_feeds/cli/commands/stake.py index 154899d3..c3f8a245 100644 --- a/src/telliot_feeds/cli/commands/stake.py +++ b/src/telliot_feeds/cli/commands/stake.py @@ -67,9 +67,9 @@ async def stake( contracts = core.get_tellor360_contracts() # set private key for token approval txn via token contract - contracts.token._private_key = account.local_account.privateKey + contracts.token._private_key = account.local_account.key # set private key for oracle stake deposit txn - contracts.oracle._private_key = account.local_account.privateKey + contracts.oracle._private_key = account.local_account.key if has_native_token_funds( to_checksum_address(account.address), diff --git a/src/telliot_feeds/cli/commands/withdraw.py b/src/telliot_feeds/cli/commands/withdraw.py new file mode 100644 index 00000000..d81f32ed --- /dev/null +++ b/src/telliot_feeds/cli/commands/withdraw.py @@ -0,0 +1,57 @@ +from typing import Optional + +import click +from click.core import Context +from telliot_core.cli.utils import async_run + +from telliot_feeds.cli.utils import call_oracle +from telliot_feeds.cli.utils import common_options +from telliot_feeds.cli.utils import get_accounts_from_name + + +@click.group() +def withdraw_stake() -> None: + """Withdraw staked tokens from the oracle after 7 days.""" + pass + + +@withdraw_stake.command() +@common_options +@click.pass_context +@async_run +async def withdraw( + ctx: Context, + account_str: str, + tx_type: int, + gas_limit: int, + base_fee_per_gas: Optional[float], + priority_fee_per_gas: Optional[float], + max_fee_per_gas: Optional[float], + legacy_gas_price: Optional[int], + password: str, + min_native_token_balance: float, + gas_multiplier: int, + max_priority_fee_range: int, +) -> None: + """Withdraw of tokens from oracle""" + ctx.obj["ACCOUNT_NAME"] = account_str + + accounts = get_accounts_from_name(account_str) + if not accounts: + return + + ctx.obj["CHAIN_ID"] = accounts[0].chains[0] # used in reporter_cli_core + + user_inputs = { + "password": password, + "min_native_token_balance": min_native_token_balance, + "gas_limit": gas_limit, + "base_fee_per_gas": base_fee_per_gas, + "priority_fee_per_gas": priority_fee_per_gas, + "max_fee_per_gas": max_fee_per_gas, + "legacy_gas_price": legacy_gas_price, + "transaction_type": tx_type, + "gas_multiplier": gas_multiplier, + "max_priority_fee_range": max_priority_fee_range, + } + await call_oracle(ctx=ctx, func="withdrawStake", user_inputs=user_inputs) diff --git a/src/telliot_feeds/cli/main.py b/src/telliot_feeds/cli/main.py index e0f3888b..2d6be747 100644 --- a/src/telliot_feeds/cli/main.py +++ b/src/telliot_feeds/cli/main.py @@ -17,6 +17,7 @@ from telliot_feeds.cli.commands.request_withdraw_stake import request_withdraw from telliot_feeds.cli.commands.settle import settle from telliot_feeds.cli.commands.stake import stake +from telliot_feeds.cli.commands.withdraw import withdraw from telliot_feeds.utils.log import get_logger @@ -49,6 +50,7 @@ def main( main.add_command(stake) main.add_command(liquity) main.add_command(request_withdraw) +main.add_command(withdraw) if __name__ == "__main__": main() diff --git a/src/telliot_feeds/cli/utils.py b/src/telliot_feeds/cli/utils.py index 20603ebc..0e0abff6 100644 --- a/src/telliot_feeds/cli/utils.py +++ b/src/telliot_feeds/cli/utils.py @@ -2,6 +2,7 @@ import os from typing import Any from typing import Callable +from typing import Dict from typing import get_args from typing import get_type_hints from typing import Optional @@ -24,6 +25,10 @@ from telliot_feeds.feeds import DATAFEED_BUILDER_MAPPING from telliot_feeds.queries.abi_query import AbiQuery from telliot_feeds.queries.query_catalog import query_catalog +from telliot_feeds.reporters.gas import GasFees +from telliot_feeds.utils.cfg import check_endpoint +from telliot_feeds.utils.cfg import setup_config +from telliot_feeds.utils.reporter_utils import has_native_token_funds load_dotenv() @@ -426,3 +431,47 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return f(*args, **kwargs) return wrapper + + +async def call_oracle( + *, + ctx: click.Context, + func: str, + user_inputs: Dict[str, Any], + **params: Any, +) -> None: + # Initialize telliot core app using CLI context + async with reporter_cli_core(ctx) as core: + + core._config, account = setup_config(core.config, account_name=ctx.obj.get("ACCOUNT_NAME")) + + 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(user_inputs.pop("password")) + + contracts = core.get_tellor360_contracts() + # set private key for oracle interaction calls + contracts.oracle._private_key = account.local_account.key + min_native_token_balance = user_inputs.pop("min_native_token_balance") + if has_native_token_funds( + to_checksum_address(account.address), + core.endpoint.web3, + min_balance=int(min_native_token_balance * 10**18), + ): + gas = GasFees(endpoint=core.endpoint, account=account, **user_inputs) + gas.update_gas_fees() + gas_info = gas.get_gas_info_core() + + try: + _ = await contracts.oracle.write(func, **params, **gas_info) + except ValueError as e: + if "no gas strategy selected" in str(e): + click.echo("Can't set gas fees automatically. Please specify gas fees manually.") diff --git a/src/telliot_feeds/integrations/diva_protocol/report.py b/src/telliot_feeds/integrations/diva_protocol/report.py index c582da5d..d8b33ebe 100644 --- a/src/telliot_feeds/integrations/diva_protocol/report.py +++ b/src/telliot_feeds/integrations/diva_protocol/report.py @@ -136,6 +136,7 @@ async def fetch_datafeed(self) -> Optional[DataFeed[Any]]: error_status(note=msg, log=logger.warning) return None self.datafeed = datafeed + logger.info(f"Current query: {datafeed.query}") return datafeed async def set_final_ref_value(self, pool_id: str, gas_fees: Dict[str, Any]) -> ResponseStatus: diff --git a/src/telliot_feeds/reporters/gas.py b/src/telliot_feeds/reporters/gas.py index 0d7f55f5..c7339304 100644 --- a/src/telliot_feeds/reporters/gas.py +++ b/src/telliot_feeds/reporters/gas.py @@ -203,7 +203,6 @@ def fee_history(self) -> Tuple[Optional[FeeHistory], ResponseStatus]: ) if fee_history is None: return None, error_status("unable to fetch fee history from node") - # "base fee for the next block after the newest of the returned range" return fee_history, ResponseStatus() except Exception as e: return None, error_status("Error fetching fee history", e=e, log=logger.error) @@ -213,10 +212,8 @@ def get_max_fee(self, base_fee: Wei) -> Wei: if self.max_fee_per_gas is not None: return self.max_fee_per_gas # if a block is 100% full, the base fee per gas is set to increase by 12.5% for the next block - percentage = 12.5 - # adding 12.5% to base_fee arg to ensure inclusion to at least the next block - # if not included in current block - return Wei(int(base_fee * (1 + (percentage / 100)))) + # adding 12.5% to base_fee arg to ensure inclusion to at least the next block if not included in current block + return Wei(int(base_fee * 1.125)) def get_max_priority_fee(self, fee_history: Optional[FeeHistory] = None) -> Tuple[Optional[Wei], ResponseStatus]: """Return the max priority fee for a type 2 (EIP1559) transaction @@ -314,7 +311,6 @@ def get_eip1559_gas_price(self) -> Tuple[Optional[FEES], ResponseStatus]: priority_fee = self.priority_fee_per_gas max_fee = self.max_fee_per_gas else: - # this should never happen? return None, error_status("Error calculating EIP1559 gas price no args provided", logger.error) return { diff --git a/src/telliot_feeds/utils/stake_info.py b/src/telliot_feeds/utils/stake_info.py index 344c5bbb..539c39ba 100644 --- a/src/telliot_feeds/utils/stake_info.py +++ b/src/telliot_feeds/utils/stake_info.py @@ -12,9 +12,7 @@ @dataclass class StakeInfo: """Check if a datafeed is in dispute - by tracking staker balance flucutations - and also tracking current oracle stake amount - keep a deque going for both + by tracking staker balance flucutations. """ max_data: int = 2 diff --git a/tests/cli/test_stake_e2e_cmd.py b/tests/cli/test_stake_e2e_cmd.py new file mode 100644 index 00000000..2bf7d3c5 --- /dev/null +++ b/tests/cli/test_stake_e2e_cmd.py @@ -0,0 +1,29 @@ +from click.testing import CliRunner + +from telliot_feeds.cli.main import main as cli_main + + +def test_request_withdraw_cmd(): + """Test request withdraw stake command.""" + runner = CliRunner() + result = runner.invoke(cli_main, ["request-withdraw", "-a", "git-tellorflex-test-key"]) + assert "Missing option '--amount' / '-amt'" in result.stdout + assert result.exception + assert result.exit_code == 2 + + +def test_withdraw_cmd(): + """Test withdraw stake command.""" + runner = CliRunner() + result = runner.invoke(cli_main, ["withdraw"]) + assert result.exception + assert result.exit_code == 2 + + +def test_staking_cmd(): + """Test stake command.""" + runner = CliRunner() + result = runner.invoke(cli_main, ["stake", "-a", "git-tellorflex-test-key"]) + assert "Missing option '--amount' / '-amt'" in result.stdout + assert result.exception + assert result.exit_code == 2 diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py index a355276f..28bdb19f 100644 --- a/tests/cli/test_utils.py +++ b/tests/cli/test_utils.py @@ -1,8 +1,12 @@ from unittest import mock +import pytest + from telliot_feeds.cli.utils import build_query +from telliot_feeds.cli.utils import call_oracle from telliot_feeds.queries.abi_query import AbiQuery from telliot_feeds.queries.price.spot_price import SpotPrice +from telliot_feeds.reporters.stake import Stake def test_build_query(): @@ -18,3 +22,61 @@ def test_build_query(): assert isinstance(query, SpotPrice) assert query.asset == "eth" assert query.currency == "usd" + + +@pytest.mark.asyncio +async def test_call_oracle(tellor_360, caplog, chain): + """Test calling the oracle.""" + user_inputs = { + "min_native_token_balance": 0.0, + "gas_limit": 350000, + "base_fee_per_gas": None, + "priority_fee_per_gas": None, + "max_fee_per_gas": None, + "legacy_gas_price": 100, + "transaction_type": 0, + "gas_multiplier": None, + } + + contracts, account = tellor_360 + s = Stake( + oracle=contracts.oracle, + token=contracts.token, + endpoint=contracts.oracle.node, + account=account, + **user_inputs, + ) + + _ = await s.deposit_stake(int(1 * 1e18)) + + class ctx: + def __init__(self): + self.obj = {"CHAIN_ID": 80001, "ACCOUNT_NAME": "brownie-acct", "TEST_CONFIG": None} + + user_inputs["password"] = "" + with mock.patch("telliot_core.apps.core.TelliotCore.get_tellor360_contracts", return_value=contracts): + with mock.patch("click.confirm", return_value="y"): + await call_oracle( + ctx=ctx(), + func="requestStakingWithdraw", + user_inputs=user_inputs, + _amount=1, + ) + assert "requestStakingWithdraw transaction succeeded" in caplog.text + user_inputs["password"] = "" + user_inputs["min_native_token_balance"] = 0.0 + await call_oracle( + ctx=ctx(), + func="withdrawStake", + user_inputs=user_inputs, + ) + assert "revert 7 days didn't pass" in caplog.text + chain.sleep(604800) + user_inputs["password"] = "" + user_inputs["min_native_token_balance"] = 0.0 + await call_oracle( + ctx=ctx(), + func="withdrawStake", + user_inputs=user_inputs, + ) + assert "withdrawStake transaction succeeded" in caplog.text diff --git a/tests/reporters/test_360_reporter.py b/tests/reporters/test_360_reporter.py index 1d0b299d..7add7b85 100644 --- a/tests/reporters/test_360_reporter.py +++ b/tests/reporters/test_360_reporter.py @@ -520,14 +520,10 @@ async def offline(): return False with patch("asyncio.sleep", side_effect=InterruptedError): - r = tellor_flex_reporter - r.is_online = lambda: offline() - with pytest.raises(InterruptedError): await r.report() - assert "Unable to connect to the internet!" in caplog.text diff --git a/tests/reporters/test_gas_fees.py b/tests/reporters/test_gas_fees.py index b87f0f15..dca8a3ef 100644 --- a/tests/reporters/test_gas_fees.py +++ b/tests/reporters/test_gas_fees.py @@ -86,8 +86,7 @@ async def test_get_eip1559_gas_price(gas_fees_object): assert not status.ok assert eip1559_gas_price is None assert "Error fetching fee history" in status.error - # assert status.error == ( - # 'unable to calculate EIP1559 gas price: "Error fetching fee history: ValueError(\'Mock Error\')') + assert "unable to fetch history to set base fee" in status.error # when 2 out 3 fee args are user provided fee history from node isn't used # instead maxFeePerGas is calculated from user provided values type(gas.web3.eth).fee_history = Mock(return_value=mock_fee_history) From 627385e7e3f75d9b065627a3678b4293314b9507 Mon Sep 17 00:00:00 2001 From: akrem Date: Mon, 24 Jul 2023 10:03:40 -0400 Subject: [PATCH 19/28] tox change --- src/telliot_feeds/cli/utils.py | 4 +++- tests/cli/test_utils.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/telliot_feeds/cli/utils.py b/src/telliot_feeds/cli/utils.py index 94d42686..9bc54808 100644 --- a/src/telliot_feeds/cli/utils.py +++ b/src/telliot_feeds/cli/utils.py @@ -2,8 +2,8 @@ import os from typing import Any from typing import Callable -from typing import Dict from typing import cast +from typing import Dict from typing import get_args from typing import get_type_hints from typing import Optional @@ -478,6 +478,8 @@ async def call_oracle( except ValueError as e: if "no gas strategy selected" in str(e): click.echo("Can't set gas fees automatically. Please specify gas fees manually.") + + class CustomHexBytes(HexBytes): """Wrapper around HexBytes that doesn't accept int or bool""" diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py index f60899c8..ae63787a 100644 --- a/tests/cli/test_utils.py +++ b/tests/cli/test_utils.py @@ -1,11 +1,10 @@ from unittest import mock import pytest +from hexbytes import HexBytes from telliot_feeds.cli.utils import build_query from telliot_feeds.cli.utils import call_oracle -from hexbytes import HexBytes - from telliot_feeds.cli.utils import CustomHexBytes from telliot_feeds.queries.abi_query import AbiQuery from telliot_feeds.queries.price.spot_price import SpotPrice @@ -83,6 +82,8 @@ def __init__(self): user_inputs=user_inputs, ) assert "withdrawStake transaction succeeded" in caplog.text + + def test_custom_hexbytes_wrapper(): """Test custom hexbytes wrapper.""" # test when 0x is present and not present From d4182db91f2e34ae9427c724fa40302fc78644c0 Mon Sep 17 00:00:00 2001 From: akrem Date: Tue, 25 Jul 2023 22:04:30 -0400 Subject: [PATCH 20/28] fix gas limit from user input --- src/telliot_feeds/reporters/gas.py | 1 + src/telliot_feeds/reporters/stake.py | 89 ++++++++++++++++------- src/telliot_feeds/reporters/tellor_360.py | 23 ------ 3 files changed, 65 insertions(+), 48 deletions(-) diff --git a/src/telliot_feeds/reporters/gas.py b/src/telliot_feeds/reporters/gas.py index c7339304..5232d7a4 100644 --- a/src/telliot_feeds/reporters/gas.py +++ b/src/telliot_feeds/reporters/gas.py @@ -321,6 +321,7 @@ def get_eip1559_gas_price(self) -> Tuple[Optional[FEES], ResponseStatus]: def update_gas_fees(self) -> ResponseStatus: """Update class gas_info with the latest gas fees whenever called""" self._reset_gas_info() + self.set_gas_info({"gas": self.gas_limit}) if self.transaction_type == 0: legacy_gas_fees, status = self.get_legacy_gas_price() if legacy_gas_fees is None: diff --git a/src/telliot_feeds/reporters/stake.py b/src/telliot_feeds/reporters/stake.py index ab117718..e6619aa6 100644 --- a/src/telliot_feeds/reporters/stake.py +++ b/src/telliot_feeds/reporters/stake.py @@ -1,5 +1,6 @@ import time from typing import Any +from typing import Optional from typing import Tuple from telliot_core.contract.contract import Contract @@ -26,39 +27,77 @@ def __init__( self.oracle = oracle self.token = token - async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: - """Deposits stake into the oracle contract""" - # check allowance to avoid unnecessary approval transactions + async def get_current_token_balance(self) -> Tuple[Optional[int], ResponseStatus]: + """Reads the current balance of the account""" + wallet_balance: int + wallet_balance, status = await self.token.read("balanceOf", account=self.acct_address) + if not status.ok: + msg = f"Unable to read account balance: {status.error}" + return None, error_status(msg, status.e, log=logger.error) + + logger.info(f"Current wallet TRB balance: {self.to_ether(wallet_balance)!r}") + return wallet_balance, status + + async def check_allowance(self, amount: int) -> Tuple[Optional[int], ResponseStatus]: + """ "Read the spender allowance for the accounts TRB""" allowance, allowance_status = await self.token.read( "allowance", owner=self.acct_address, spender=self.oracle.address ) if not allowance_status.ok: msg = "Unable to check allowance:" - return False, error_status(msg, e=allowance_status.error, log=logger.error) - logger.debug(f"Current allowance: {allowance / 1e18!r}") + return None, error_status(msg, e=allowance_status.error, log=logger.error) + + logger.debug(f"Current allowance: {self.to_ether(allowance):.04f}") + return allowance, allowance_status + + async def approve_spending(self, amount: int) -> Tuple[bool, ResponseStatus]: + """Approve contract to spend TRB tokens""" + logger.info(f"Approving {self.oracle.address} token spending: {amount}...") + # calculate and set gas params + status = self.update_gas_fees() + if not status.ok: + return False, error_status("unable to calculate fees for approve txn", e=status.error, log=logger.error) + fees = self.get_gas_info_core() + + approve_receipt, approve_status = await self.token.write( + func_name="approve", + # have to convert to gwei because of telliot_core where numbers are converted to wei + # consider changing this in telliot_core + spender=self.oracle.address, + amount=amount, + **fees, + ) + if not approve_status.ok: + msg = "Unable to approve staking: " + return False, error_status(msg, e=approve_status.error, log=logger.error) + logger.debug(f"Approve transaction status: {approve_receipt.status}, block: {approve_receipt.blockNumber}") + return True, approve_status + + async def deposit_stake(self, amount: int) -> Tuple[bool, ResponseStatus]: + """Deposits stake into the oracle contract""" + # check TRB wallet balance! + wallet_balance, wallet_balance_status = await self.get_current_token_balance() + if wallet_balance is None or not wallet_balance_status.ok: + return False, wallet_balance_status + + if amount > wallet_balance: + msg = ( + f"Amount to stake: {self.to_ether(amount):.04f} " + f"is greater than your balance: {self.to_ether(wallet_balance):.04f} so " + "not enough TRB to cover the stake" + ) + return False, error_status(msg, log=logger.warning) + + # check allowance to avoid unnecessary approval transactions + allowance, allowance_status = await self.check_allowance(amount) + if allowance is None or not allowance_status.ok: + return False, allowance_status # if allowance is less than amount_to_stake then approve if allowance < amount: - # Approve token spending - logger.info(f"Approving {self.oracle.address} token spending: {amount}...") - # calculate and set gas params - status = self.update_gas_fees() - if not status.ok: - return False, error_status("unable to calculate fees for approve txn", e=status.error, log=logger.error) - fees = self.get_gas_info_core() - - approve_receipt, approve_status = await self.token.write( - func_name="approve", - # have to convert to gwei because of telliot_core where numbers are converted to wei - # consider changing this in telliot_core - spender=self.oracle.address, - amount=amount, - **fees, - ) - if not approve_status.ok: - msg = "Unable to approve staking: " - return False, error_status(msg, e=approve_status.error, log=logger.error) - logger.debug(f"Approve transaction status: {approve_receipt.status}, block: {approve_receipt.blockNumber}") + approve_receipt, approve_status = await self.approve_spending(amount - allowance) + if not approve_receipt or not approve_status.ok: + return False, approve_status # Add this to avoid nonce error from txn happening too fast time.sleep(1) diff --git a/src/telliot_feeds/reporters/tellor_360.py b/src/telliot_feeds/reporters/tellor_360.py index ca210f5d..d315d4fd 100644 --- a/src/telliot_feeds/reporters/tellor_360.py +++ b/src/telliot_feeds/reporters/tellor_360.py @@ -96,16 +96,6 @@ async def get_staker_details(self) -> Tuple[Optional[StakerInfo], ResponseStatus staker_details = StakerInfo(*response) return staker_details, status - async def get_current_token_balance(self) -> Tuple[Optional[int], ResponseStatus]: - """Reads the current balance of the account""" - wallet_balance: int - wallet_balance, status = await self.token.read("balanceOf", account=self.acct_addr) - if not status.ok: - msg = f"Unable to read account balance: {status.error}" - return None, error_status(msg, status.e, log=logger.error) - logger.info(f"Current wallet TRB balance: {self.to_ether(wallet_balance)!r}") - return wallet_balance, status - async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: """Compares stakeAmount and stakerInfo every loop to monitor changes to the stakeAmount or stakerInfo and deposits stake if needed for continuous reporting @@ -162,19 +152,6 @@ async def ensure_staked(self) -> Tuple[bool, ResponseStatus]: to_stake_amount_2 = self.stake - staker_details.stake_balance amount_to_stake = max(int(to_stake_amount_1), int(to_stake_amount_2)) - # check TRB wallet balance! - wallet_balance, wallet_balance_status = await self.get_current_token_balance() - if not wallet_balance or not wallet_balance_status.ok: - return False, wallet_balance_status - - if amount_to_stake > wallet_balance: - msg = ( - f"Amount to stake: {self.to_ether(amount_to_stake):.04f} " - f"is greater than your balance: {self.to_ether(wallet_balance):.04f} so " - "not enough TRB to cover the stake" - ) - return False, error_status(msg, log=logger.warning) - _, deposit_status = await self.deposit_stake(amount_to_stake) if not deposit_status.ok: return False, deposit_status From 4d383a9e20c78612d0f379b005cc84663f63f79d Mon Sep 17 00:00:00 2001 From: akrem Date: Thu, 27 Jul 2023 15:17:25 -0400 Subject: [PATCH 21/28] merge sweth_source --- src/telliot_feeds/feeds/sweth_usd_feed.py | 3 + src/telliot_feeds/sources/sweth_source.py | 78 +++++++++++++++++++++++ tests/feeds/test_sweth_usd_feed.py | 2 +- tests/sources/test_spot_price_sources.py | 11 ++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/telliot_feeds/sources/sweth_source.py diff --git a/src/telliot_feeds/feeds/sweth_usd_feed.py b/src/telliot_feeds/feeds/sweth_usd_feed.py index 26b5c52b..ee19c85e 100644 --- a/src/telliot_feeds/feeds/sweth_usd_feed.py +++ b/src/telliot_feeds/feeds/sweth_usd_feed.py @@ -3,6 +3,8 @@ from telliot_feeds.sources.price.spot.coingecko import CoinGeckoSpotPriceSource from telliot_feeds.sources.price.spot.uniswapV3 import UniswapV3PriceSource from telliot_feeds.sources.price_aggregator import PriceAggregator +from telliot_feeds.sources.sweth_source import swETHSpotPriceSource + sweth_usd_median_feed = DataFeed( query=SpotPrice(asset="SWETH", currency="USD"), @@ -11,6 +13,7 @@ currency="usd", algorithm="median", sources=[ + swETHSpotPriceSource(asset="sweth", currency="usd"), CoinGeckoSpotPriceSource(asset="sweth", currency="usd"), UniswapV3PriceSource(asset="sweth", currency="usd"), ], diff --git a/src/telliot_feeds/sources/sweth_source.py b/src/telliot_feeds/sources/sweth_source.py new file mode 100644 index 00000000..e38a8985 --- /dev/null +++ b/src/telliot_feeds/sources/sweth_source.py @@ -0,0 +1,78 @@ +from dataclasses import dataclass +from dataclasses import field +from typing import Any +from typing import Optional + +from telliot_core.apps.telliot_config import TelliotConfig + +from telliot_feeds.dtypes.datapoint import datetime_now_utc +from telliot_feeds.dtypes.datapoint import OptionalDataPoint +from telliot_feeds.pricing.price_service import WebPriceService +from telliot_feeds.pricing.price_source import PriceSource +from telliot_feeds.utils.log import get_logger + +logger = get_logger(__name__) + + +class swETHSpotPriceService(WebPriceService): + """Custom swETH Price Service""" + + def __init__(self, **kwargs: Any) -> None: + kwargs["name"] = "Custom swETH Price Service" + kwargs["url"] = "" + super().__init__(**kwargs) + self.cfg = TelliotConfig() + + def get_sweth_eth_ratio(self) -> Optional[float]: + # get endpoint + endpoint = self.cfg.endpoints.find(chain_id=1) + if not endpoint: + logger.error("Endpoint not found for mainnet to get sweth_eth_ratio") + return None + ep = endpoint[0] + if not ep.connect(): + logger.error("Unable to connect endpoint for mainnet to get sweth_eth_ratio") + return None + w3 = ep.web3 + # get ratio + sweth_eth_ratio_bytes = w3.eth.call( + { + "to": "0xf951E335afb289353dc249e82926178EaC7DEd78", + "data": "0xd68b2cb6", + } + ) + sweth_eth_ratio_decoded = w3.toInt(sweth_eth_ratio_bytes) + sweth_eth_ratio = w3.fromWei(sweth_eth_ratio_decoded, "ether") + logger.debug(f"sweth_eth_ratio: {sweth_eth_ratio}") + return float(sweth_eth_ratio) + + async def get_price(self, asset: str, currency: str) -> OptionalDataPoint[float]: + """This implementation gets the median price of eth in usd and + converts the sweth/eth ratio to get sweth/usd price + """ + asset = asset.lower() + currency = currency.lower() + + sweth_eth_ratio = self.get_sweth_eth_ratio() + if asset == "sweth" and currency == "eth": + return sweth_eth_ratio, datetime_now_utc() + + if sweth_eth_ratio is None: + logger.error("Unable to get sweth_eth_ratio") + return None, None + from telliot_feeds.feeds.eth_usd_feed import eth_usd_median_feed + + source = eth_usd_median_feed.source + + eth_price, timestamp = await source.fetch_new_datapoint() + if eth_price is None: + logger.error("Unable to get eth/usd price") + return None, None + return sweth_eth_ratio * eth_price, timestamp + + +@dataclass +class swETHSpotPriceSource(PriceSource): + asset: str = "" + currency: str = "" + service: swETHSpotPriceService = field(default_factory=swETHSpotPriceService, init=False) diff --git a/tests/feeds/test_sweth_usd_feed.py b/tests/feeds/test_sweth_usd_feed.py index 5fe18876..6485d0f6 100644 --- a/tests/feeds/test_sweth_usd_feed.py +++ b/tests/feeds/test_sweth_usd_feed.py @@ -12,7 +12,7 @@ async def test_sweth_usd_median_feed(caplog): assert v is not None assert v > 0 - assert "sources used in aggregate: 2" in caplog.text.lower() + assert "sources used in aggregate: 3" in caplog.text.lower() print(f"SWETH/USD Price: {v}") # Get list of data sources from sources dict diff --git a/tests/sources/test_spot_price_sources.py b/tests/sources/test_spot_price_sources.py index 7367e919..35d1eee0 100644 --- a/tests/sources/test_spot_price_sources.py +++ b/tests/sources/test_spot_price_sources.py @@ -24,6 +24,7 @@ PancakeswapPriceService, ) from telliot_feeds.sources.price.spot.uniswapV3 import UniswapV3PriceService +from telliot_feeds.sources.sweth_source import swETHSpotPriceService service = { @@ -39,6 +40,7 @@ "bitfinex": BitfinexSpotPriceService(), "coinpaprika": CoinpaprikaSpotPriceService(), "curvefi": CurveFinanceSpotPriceService(), + "sweth": swETHSpotPriceService(), } @@ -305,3 +307,12 @@ async def test_curvefi(): validate_price(v, t) assert v is not None assert t is not None + + +@pytest.mark.asyncio +async def test_sweth_source(): + """Test swETH price service""" + v, t = await get_price("sweth", "usd", service["sweth"]) + validate_price(v, t) + assert v is not None + assert t is not None From 283c50c3ae7f1d95f4b59414e7bc97240ea3d0fe Mon Sep 17 00:00:00 2001 From: akrem Date: Thu, 27 Jul 2023 17:08:12 -0400 Subject: [PATCH 22/28] tox --- src/telliot_feeds/feeds/sweth_usd_feed.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/telliot_feeds/feeds/sweth_usd_feed.py b/src/telliot_feeds/feeds/sweth_usd_feed.py index 3d8a131b..ed01f983 100644 --- a/src/telliot_feeds/feeds/sweth_usd_feed.py +++ b/src/telliot_feeds/feeds/sweth_usd_feed.py @@ -3,9 +3,8 @@ from telliot_feeds.sources.price.spot.coingecko import CoinGeckoSpotPriceSource from telliot_feeds.sources.price.spot.uniswapV3 import UniswapV3PriceSource from telliot_feeds.sources.price_aggregator import PriceAggregator -from telliot_feeds.sources.sweth_source import swETHSpotPriceSource - from telliot_feeds.sources.sweth_source import swETHMaverickSpotPriceSource +from telliot_feeds.sources.sweth_source import swETHSpotPriceSource sweth_usd_median_feed = DataFeed( From d334f43f3599ba03d3e89ca4b193dfc0224db879 Mon Sep 17 00:00:00 2001 From: akrem Date: Thu, 27 Jul 2023 17:52:55 -0400 Subject: [PATCH 23/28] Add comment --- src/telliot_feeds/sources/sweth_source.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/telliot_feeds/sources/sweth_source.py b/src/telliot_feeds/sources/sweth_source.py index 1b5bbd42..eb115916 100644 --- a/src/telliot_feeds/sources/sweth_source.py +++ b/src/telliot_feeds/sources/sweth_source.py @@ -77,6 +77,7 @@ async def get_price(self, asset: str, currency: str) -> OptionalDataPoint[float] @dataclass class swETHSpotPriceSource(PriceSource): + """Gets data from swETH contract""" asset: str = "" currency: str = "" service: swETHSpotPriceService = field(default_factory=swETHSpotPriceService, init=False) @@ -88,6 +89,7 @@ def __post_init__(self) -> None: @dataclass class swETHMaverickSpotPriceSource(PriceSource): + """Gets data from Maverick AMM""" asset: str = "" currency: str = "" service: swETHSpotPriceService = field(default_factory=swETHSpotPriceService) From e6e22f6da6dd181180a3075e97c0ba82f22c6071 Mon Sep 17 00:00:00 2001 From: akrem Date: Thu, 27 Jul 2023 17:54:53 -0400 Subject: [PATCH 24/28] tox --- src/telliot_feeds/sources/sweth_source.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/telliot_feeds/sources/sweth_source.py b/src/telliot_feeds/sources/sweth_source.py index eb115916..99ad7a38 100644 --- a/src/telliot_feeds/sources/sweth_source.py +++ b/src/telliot_feeds/sources/sweth_source.py @@ -78,6 +78,7 @@ async def get_price(self, asset: str, currency: str) -> OptionalDataPoint[float] @dataclass class swETHSpotPriceSource(PriceSource): """Gets data from swETH contract""" + asset: str = "" currency: str = "" service: swETHSpotPriceService = field(default_factory=swETHSpotPriceService, init=False) @@ -90,6 +91,7 @@ def __post_init__(self) -> None: @dataclass class swETHMaverickSpotPriceSource(PriceSource): """Gets data from Maverick AMM""" + asset: str = "" currency: str = "" service: swETHSpotPriceService = field(default_factory=swETHSpotPriceService) From 04eda2db1c72689736cd079b36b40b35c39e2329 Mon Sep 17 00:00:00 2001 From: akrem Date: Tue, 1 Aug 2023 12:53:50 -0400 Subject: [PATCH 25/28] Add flashbot api for sepolia --- src/telliot_feeds/cli/utils.py | 2 +- src/telliot_feeds/flashbots/provider.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/telliot_feeds/cli/utils.py b/src/telliot_feeds/cli/utils.py index dd4802bf..01b90c50 100644 --- a/src/telliot_feeds/cli/utils.py +++ b/src/telliot_feeds/cli/utils.py @@ -103,7 +103,7 @@ def reporter_cli_core(ctx: click.Context) -> TelliotCore: # Ensure chain id compatible with flashbots relay if ctx.obj.get("SIGNATURE_ACCOUNT_NAME", None) is not None: # Only supports mainnet - assert core.config.main.chain_id in (1, 5) + assert core.config.main.chain_id in (1, 5, 11155111) if ctx.obj["TEST_CONFIG"]: try: diff --git a/src/telliot_feeds/flashbots/provider.py b/src/telliot_feeds/flashbots/provider.py index a9ff4e18..95f61ca3 100644 --- a/src/telliot_feeds/flashbots/provider.py +++ b/src/telliot_feeds/flashbots/provider.py @@ -25,6 +25,7 @@ def get_default_endpoint(chain_id: int = 1) -> URI: uri = { 1: URI(os.environ.get("FLASHBOTS_HTTP_PROVIDER_URI", "https://relay.flashbots.net")), 5: URI(os.environ.get("FLASHBOTS_HTTP_PROVIDER_URI_GOERLI", "https://relay-goerli.flashbots.net")), + 11155111: URI(os.environ.get("FLASHBOTS_HTTP_PROVIDER_URI_SEPOLIA", "https://relay-sepolia.flashbots.net")), } return uri[chain_id] From 699ef7900e82001219792d91658b3bd0cdd3cf00 Mon Sep 17 00:00:00 2001 From: akrem Date: Tue, 1 Aug 2023 12:54:19 -0400 Subject: [PATCH 26/28] fix sweth test --- src/telliot_feeds/sources/sweth_source.py | 2 +- tests/sources/test_spot_price_sources.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/telliot_feeds/sources/sweth_source.py b/src/telliot_feeds/sources/sweth_source.py index b1eeb65d..194446c8 100644 --- a/src/telliot_feeds/sources/sweth_source.py +++ b/src/telliot_feeds/sources/sweth_source.py @@ -7,6 +7,7 @@ from telliot_feeds.dtypes.datapoint import datetime_now_utc from telliot_feeds.dtypes.datapoint import OptionalDataPoint +from telliot_feeds.feeds.eth_usd_feed import eth_usd_median_feed from telliot_feeds.pricing.price_service import WebPriceService from telliot_feeds.pricing.price_source import PriceSource from telliot_feeds.utils.log import get_logger @@ -67,7 +68,6 @@ async def get_price(self, asset: str, currency: str) -> OptionalDataPoint[float] if sweth_eth_ratio is None: logger.error("Unable to get sweth_eth_ratio") return None, None - from telliot_feeds.feeds.eth_usd_feed import eth_usd_median_feed source = eth_usd_median_feed.source diff --git a/tests/sources/test_spot_price_sources.py b/tests/sources/test_spot_price_sources.py index 35d1eee0..9e7d58b7 100644 --- a/tests/sources/test_spot_price_sources.py +++ b/tests/sources/test_spot_price_sources.py @@ -24,7 +24,7 @@ PancakeswapPriceService, ) from telliot_feeds.sources.price.spot.uniswapV3 import UniswapV3PriceService -from telliot_feeds.sources.sweth_source import swETHSpotPriceService +from telliot_feeds.sources.sweth_source import SWETH_CONTRACT, swETHSpotPriceService service = { @@ -312,6 +312,8 @@ async def test_curvefi(): @pytest.mark.asyncio async def test_sweth_source(): """Test swETH price service""" + service["sweth"].contract = SWETH_CONTRACT + service["sweth"].calldata = "0xd68b2cb6" v, t = await get_price("sweth", "usd", service["sweth"]) validate_price(v, t) assert v is not None From 76b7769c5c9368a3ecd98fd3ff57bf0e0cc996b7 Mon Sep 17 00:00:00 2001 From: akrem Date: Wed, 2 Aug 2023 08:56:41 -0400 Subject: [PATCH 27/28] fix test --- src/telliot_feeds/cli/commands/liquity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/telliot_feeds/cli/commands/liquity.py b/src/telliot_feeds/cli/commands/liquity.py index 9895366a..6fa87c75 100644 --- a/src/telliot_feeds/cli/commands/liquity.py +++ b/src/telliot_feeds/cli/commands/liquity.py @@ -59,6 +59,7 @@ async def liquity( frozen_timeout: int, query_tag: str, chainlink_feed: str, + unsafe: bool, ) -> None: """Report values to Tellor oracle if certain conditions are met.""" click.echo("Starting Liquity Backup Reporter...") @@ -88,7 +89,7 @@ async def liquity( # 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) + core._config, account = setup_config(core.config, account_name=account_str, unsafe=unsafe) endpoint = check_endpoint(core._config) From fb1ad6e35f9293a81babe4dd8c372e9c575f4897 Mon Sep 17 00:00:00 2001 From: akrem Date: Wed, 2 Aug 2023 08:58:45 -0400 Subject: [PATCH 28/28] tox fix --- tests/sources/test_spot_price_sources.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/sources/test_spot_price_sources.py b/tests/sources/test_spot_price_sources.py index 9e7d58b7..1ac3a52a 100644 --- a/tests/sources/test_spot_price_sources.py +++ b/tests/sources/test_spot_price_sources.py @@ -24,7 +24,8 @@ PancakeswapPriceService, ) from telliot_feeds.sources.price.spot.uniswapV3 import UniswapV3PriceService -from telliot_feeds.sources.sweth_source import SWETH_CONTRACT, swETHSpotPriceService +from telliot_feeds.sources.sweth_source import SWETH_CONTRACT +from telliot_feeds.sources.sweth_source import swETHSpotPriceService service = {