From 5917f2f9c08e77523dfd63b70f2cdb3591f0b91c Mon Sep 17 00:00:00 2001 From: Mike Casale <46603283+mikewcasale@users.noreply.github.com> Date: Thu, 29 Feb 2024 18:57:08 -0800 Subject: [PATCH] Adds deterministic testing for Ethereum --- fastlane_bot/tests/deterministic/__init__.py | 0 .../_data/static_pool_data_testing.csv | 6 + .../tests/deterministic/dtest_constants.py | 122 ++++ .../tests/deterministic/dtest_manager.py | 653 ++++++++++++++++++ .../tests/deterministic/dtest_pool.py | 118 ++++ .../dtest_pool_params_builder.py | 193 ++++++ .../tests/deterministic/dtest_strategy.py | 129 ++++ .../tests/deterministic/dtest_token.py | 79 +++ .../tests/deterministic/dtest_tx_helper.py | 290 ++++++++ .../tests/deterministic/dtest_wallet.py | 33 + .../tests/deterministic/unit/fastlane_bot | 1 + .../unit/test_dtest_tx_helper.py | 42 ++ .../deterministic/unit/test_dtest_wallet.py | 71 ++ run_deterministic_tests.py | 330 +++++++++ 14 files changed, 2067 insertions(+) create mode 100644 fastlane_bot/tests/deterministic/__init__.py create mode 100644 fastlane_bot/tests/deterministic/_data/static_pool_data_testing.csv create mode 100644 fastlane_bot/tests/deterministic/dtest_constants.py create mode 100644 fastlane_bot/tests/deterministic/dtest_manager.py create mode 100644 fastlane_bot/tests/deterministic/dtest_pool.py create mode 100644 fastlane_bot/tests/deterministic/dtest_pool_params_builder.py create mode 100644 fastlane_bot/tests/deterministic/dtest_strategy.py create mode 100644 fastlane_bot/tests/deterministic/dtest_token.py create mode 100644 fastlane_bot/tests/deterministic/dtest_tx_helper.py create mode 100644 fastlane_bot/tests/deterministic/dtest_wallet.py create mode 120000 fastlane_bot/tests/deterministic/unit/fastlane_bot create mode 100644 fastlane_bot/tests/deterministic/unit/test_dtest_tx_helper.py create mode 100644 fastlane_bot/tests/deterministic/unit/test_dtest_wallet.py create mode 100644 run_deterministic_tests.py diff --git a/fastlane_bot/tests/deterministic/__init__.py b/fastlane_bot/tests/deterministic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastlane_bot/tests/deterministic/_data/static_pool_data_testing.csv b/fastlane_bot/tests/deterministic/_data/static_pool_data_testing.csv new file mode 100644 index 000000000..efa943c2c --- /dev/null +++ b/fastlane_bot/tests/deterministic/_data/static_pool_data_testing.csv @@ -0,0 +1,6 @@ +cid,last_updated,last_updated_block,descr,pair_name,exchange_name,fee,fee_float,address,anchor,tkn0_address,tkn1_address,tkn0_decimals,tkn1_decimals,exchange_id,tkn0_symbol,tkn1_symbol,timestamp,tkn0_balance,tkn1_balance,liquidity,sqrt_price_q96,tick,tick_spacing,exchange,pool_type,tkn0_weight,tkn1_weight,tkn2_address,tkn2_decimals,tkn2_symbol,tkn2_balance,tkn2_weight,tkn3_address,tkn3_decimals,tkn3_symbol,tkn3_balance,tkn3_weight,tkn4_address,tkn4_decimals,tkn4_symbol,tkn4_balance,tkn4_weight,tkn5_address,tkn5_decimals,tkn5_symbol,tkn5_balance,tkn5_weight,tkn6_address,tkn6_decimals,tkn6_symbol,tkn6_balance,tkn6_weight,tkn7_address,tkn7_decimals,tkn7_symbol,tkn7_balance,tkn7_weight,test,exchange_type,pool_address,tkn0_setBalance,tkn1_setBalance,slots,param_lists,param_blockTimestampLast,param_blockTimestampLast_type,param_reserve0,param_reserve0_type,param_reserve1,param_reserve1_type,param_liquidity,param_liquidity_type,param_sqrtPriceX96,param_sqrtPriceX96_type,param_tick,param_tick_type,param_observationIndex,param_observationIndex_type,param_observationCardinality,param_observationCardinality_type,param_observationCardinalityNext,param_observationCardinalityNext_type,param_feeProtocol,param_feeProtocol_type,param_unlocked,param_unlocked_type +,,0,uniswap_v2 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 0.003,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,uniswap_v2,3000,0.003,0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc,,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,6,18,3,USDC,WETH,,,,,,,60,uniswap_v2,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,1,uniswap_v2,0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc,49204613674917,21734090459912674200000,"[8,]","[['blockTimestampLast','reserve1','reserve0'],]",,uint32,49204613674917,uint112,21734090459912674200000,uint112,,,,,,,,,,,,,,,, +,,0,uniswap_v3 0x514910771AF9Ca656af840dff83E8264EcF986CA/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 0.003,0x514910771AF9Ca656af840dff83E8264EcF986CA/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,uniswap_v3,3000,0.003,0xa6Cc3C2531FdaA6Ae1A3CA84c2855806728693e8,,0x514910771AF9Ca656af840dff83E8264EcF986CA,0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,18,18,4,LINK,WETH,,,,,,,60,uniswap_v3,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,2,uniswap_v3,0xa6Cc3C2531FdaA6Ae1A3CA84c2855806728693e8,1312731411619255420525571,7101396323681156262130,"[4,0,]","[['liquidity'],['unlocked', 'feeProtocol', 'observationCardinalityNext', 'observationCardinality', 'observationIndex', 'tick', 'sqrtPriceX96']]",,,,,,,1456862313731161106039763,"uint128",6362445213301469813433370622,"uint160",-50441,"int24",85,"uint16",180,"uint16",180,"uint16",0,"uint8",True,bool +,,0,pancakeswap_v3 0x6B175474E89094C44Da98b954EedeAC495271d0F/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0.0001,0x6B175474E89094C44Da98b954EedeAC495271d0F/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,pancakeswap_v3,100,0.0001,0xD9e497BD8f491fE163b42A62c296FB54CaEA74B7,,0x6B175474E89094C44Da98b954EedeAC495271d0F,0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,18,6,10,DAI,USDC,0,0,0,0,0,0,1,pancakeswap_v3,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,3,pancakeswap_v3,0xD9e497BD8f491fE163b42A62c296FB54CaEA74B7,13653990890660798693042,135975525713,"[4,0,1,]","[['liquidity'],['observationCardinalityNext', 'observationCardinality', 'observationIndex', 'tick', 'sqrtPriceX96'],['unlocked', 'feeProtocol']]",,,,,,,1275240832730323472063,"uint128",79228147574959555694268,"uint160",-276325,"int24",0,"uint16",1,"uint16",1,"uint16",216272100,"uint32",True,bool +,,0,pancakeswap_v2 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 0.0025,0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,pancakeswap_v2,2500,0.0025,0x4AB6702B3Ed3877e9b1f203f90cbEF13d663B0e8,,0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599,0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,8,18,9,WBTC,WETH,0,0,0,,,,,pancakeswap_v2,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,1,pancakeswap_v2,0x4AB6702B3Ed3877e9b1f203f90cbEF13d663B0e8,421419304,78852853048776778963,"[8,]","[['blockTimestampLast','reserve1','reserve0'],]",,uint32,421419304,uint112,78852853048776778963,uint112,,,,,,,,,,,,,,,, +,,0,uniswap_v3 0x6B175474E89094C44Da98b954EedeAC495271d0F/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 0.0005,0x6B175474E89094C44Da98b954EedeAC495271d0F/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,uniswap_v3,500,0.0005,0x60594a405d53811d3BC4766596EFD80fd545A270,,0x6B175474E89094C44Da98b954EedeAC495271d0F,0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,18,18,4,DAI,WETH,,,,,,,10,uniswap_v3,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,3,uniswap_v3,0x60594a405d53811d3BC4766596EFD80fd545A270,2850299295281281234085971,1147049345011454055669,"[4,0,]","[['liquidity'],['unlocked', 'feeProtocol', 'observationCardinalityNext', 'observationCardinality', 'observationIndex', 'tick', 'sqrtPriceX96']]",,,,,,,451613902936689743409876,"uint128",1659826732499374354385938036,"uint160",-77317,"int24",110,"uint16",180,"uint16",180,"uint16",0,"uint8",True,bool \ No newline at end of file diff --git a/fastlane_bot/tests/deterministic/dtest_constants.py b/fastlane_bot/tests/deterministic/dtest_constants.py new file mode 100644 index 000000000..0c57d8d7d --- /dev/null +++ b/fastlane_bot/tests/deterministic/dtest_constants.py @@ -0,0 +1,122 @@ +""" +This file contains constants used in the deterministic tests. + +(c) Copyright Bprotocol foundation 2024. +Licensed under MIT License. +""" +from dataclasses import dataclass + +from fastlane_bot.tools.cpc import T + +KNOWN_UNABLE_TO_DELETE = { + 68737038118029569619601670701217178714718: ("pDFS", "ETH"), +} +TEST_MODE_AMT = ( + 115792089237316195423570985008687907853269984665640564039457584007913129639935 +) +ETH_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" +SUPPORTED_EXCHANGES = ["uniswap_v2", "uniswap_v3", "pancakeswap_v2", "pancakeswap_v3"] +BNT_ADDRESS = "0x1F573D6Fb3F13d689FF844B4cE37794d79a7FF1C" +USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +USDT_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7" +DEFAULT_GAS = 2000000 +DEFAULT_GAS_PRICE = 0 +DEFAULT_FROM_BLOCK = 1000000 +TENDERLY_RPC_KEY = "fb866397-29bd-4886-8406-a2cc7b7c5b1f" # https://virtual.mainnet.rpc.tenderly.co/9ea4ceb3-d0f5-4faf-959e-f51cf1f6b52b, from_block: 19325893, fb866397-29bd-4886-8406-a2cc7b7c5b1f +FILE_DATA_DIR = "fastlane_bot/data/blockchain_data" +TEST_FILE_DATA_DIR = "fastlane_bot/tests/deterministic/_data" +binance14 = "0x28C6c06298d514Db089934071355E5743bf21d60" +TOKENS_MODIFICATIONS = { + "0x0": { + "address": "0x5a3e6A77ba2f983eC0d371ea3B475F8Bc0811AD5", + "modifications": { + "before": { + "0x0000000000000000000000000000000000000000000000000000000000000006": "0x01", + "0x0000000000000000000000000000000000000000000000000000000000000007": "0x01", + "0x000000000000000000000000000000000000000000000000000000000000000d": "0x01", + }, + "after": { + "0x0000000000000000000000000000000000000000000000000000000000000006": "0x0000000000000000000000000000000000000000000000000000000000000019", + "0x0000000000000000000000000000000000000000000000000000000000000007": "0x0000000000000000000000000000000000000000000000000000000000000005", + "0x000000000000000000000000000000000000000000000000000000000000000d": "0x2386f26fc10000", + }, + "balance": 288551667147, + }, + "strategy_id": 9868188640707215440437863615521278132277, + "strategy_beneficiary": "0xe3d51681Dc2ceF9d7373c71D9b02c5308D852dDe", + }, + "PAXG": { + "address": "0x45804880De22913dAFE09f4980848ECE6EcbAf78", + "modifications": { + "before": { + "0x000000000000000000000000000000000000000000000000000000000000000d": "0x00", + }, + "after": { + "0x000000000000000000000000000000000000000000000000000000000000000d": "0x00000000000000000000000000000000000000000000000000000000000000c8", + }, + "balance": 395803389286127, + }, + "strategy_id": 15312706511442230855851857334429569515620, + "strategy_beneficiary": "0xFf365375777069eBd8Fa575635EB31a0787Afa6c", + }, +} + + +@dataclass +class TestCommandLineArgs: + """ + This class is used to mock the command line arguments for the main.py + """ + + cache_latest_only: str = "True" + backdate_pools: str = "True" + static_pool_data_filename: str = "static_pool_data_testing" + arb_mode: str = "multi_pairwise_all" + flashloan_tokens: str = ( + f"{T.LINK},{T.NATIVE_ETH},{T.BNT},{T.WBTC},{T.DAI},{T.USDC},{T.USDT},{T.WETH}" + ) + n_jobs: int = -1 + exchanges: str = "carbon_v1,bancor_v3,bancor_v2,bancor_pol,uniswap_v3,uniswap_v2,sushiswap_v2,balancer,pancakeswap_v2,pancakeswap_v3" + polling_interval: int = 0 + alchemy_max_block_fetch: int = 1 + reorg_delay: int = 0 + logging_path: str = "" + loglevel: str = "INFO" + use_cached_events: str = "False" + run_data_validator: str = "False" + randomizer: int = 1 + limit_bancor3_flashloan_tokens: str = "True" + default_min_profit_gas_token: str = "0.002" # "0.01" + timeout: int = None + target_tokens: str = None + replay_from_block: int = None + tenderly_fork_id: int = None + tenderly_event_exchanges: str = "pancakeswap_v2,pancakeswap_v3" + increment_time: int = 1 + increment_blocks: int = 1 + blockchain: str = "ethereum" + pool_data_update_frequency: int = -1 + use_specific_exchange_for_target_tokens: str = None + prefix_path: str = "" + version_check_frequency: int = 1 + self_fund: str = "False" + read_only: str = "False" + is_args_test: str = "False" + rpc_url: str = None + + @staticmethod + def args_to_command_line(args): + """ + Convert a TestCommandLineArgs instance to a list of command-line arguments. + + Args: + args: An instance of TestCommandLineArgs. + + Returns: + A list of command-line arguments. + """ + cmd_args = [] + for field, value in args.__dict__.items(): + if value is not None: # Only include fields that have a value + cmd_args.extend((f"--{field}", str(value))) + return cmd_args diff --git a/fastlane_bot/tests/deterministic/dtest_manager.py b/fastlane_bot/tests/deterministic/dtest_manager.py new file mode 100644 index 000000000..388b565e6 --- /dev/null +++ b/fastlane_bot/tests/deterministic/dtest_manager.py @@ -0,0 +1,653 @@ +""" +A module to manage Carbon strategies. + +(c) Copyright Bprotocol foundation 2024. +Licensed under MIT License. +""" +import argparse +import glob +import json +import os +import time +from typing import Dict + +import pandas as pd +import requests +from black import datetime +from eth_typing import Address, ChecksumAddress +from web3 import Web3 +from web3.contract import Contract + +from fastlane_bot.data.abi import CARBON_CONTROLLER_ABI +from fastlane_bot.tests.deterministic.dtest_constants import ( + DEFAULT_GAS, + DEFAULT_GAS_PRICE, + ETH_ADDRESS, + TEST_FILE_DATA_DIR, + TOKENS_MODIFICATIONS, + TestCommandLineArgs, +) +from fastlane_bot.tests.deterministic.dtest_strategy import TestStrategy + + +class TestManager: + """ + A class to manage Web3 contracts and Carbon strategies. + """ + + def __init__(self, args: argparse.Namespace): + """ + Initializes the TestManager. + + Args: + args (argparse.Namespace): The command-line arguments. + """ + self.w3 = Web3(Web3.HTTPProvider(args.rpc_url, {"timeout": 60})) + assert self.w3.is_connected(), "Web3 connection failed" + + multichain_addresses_path = os.path.normpath( + "fastlane_bot/data/multichain_addresses.csv" + ) + + # Get the Carbon Controller Address for the network + carbon_controller_address = self.get_carbon_controller_address( + multichain_addresses_path=multichain_addresses_path, network=args.network + ) + + # Initialize the Carbon Controller contract + carbon_controller = self.get_carbon_controller( + address=carbon_controller_address + ) + + self.carbon_controller = carbon_controller + + @property + def logs_path(self) -> str: + return os.path.normpath("./logs/*") + + def get_carbon_controller(self, address: Address or str) -> Contract: + """ + Gets the Carbon Controller contract on the given network. + + Args: + address (Address or str): The address. + + Returns: + Contract: The Carbon Controller contract. + """ + return self.w3.eth.contract(address=address, abi=CARBON_CONTROLLER_ABI) + + @staticmethod + def get_carbon_controller_address( + multichain_addresses_path: str, network: str + ) -> str: + """ + Gets the Carbon Controller contract address on the given network. + + Args: + multichain_addresses_path (str): The path to the multichain addresses file. + network (str): The network. + + Returns: + str: The Carbon Controller contract address. + """ + lookup_table = pd.read_csv(multichain_addresses_path) + return ( + lookup_table.query("exchange_name=='carbon_v1'") + .query(f"chain=='{network}'") + .factory_address.values[0] + ) + + @staticmethod + def create_new_testnet() -> tuple: + """ + Creates a new testnet on Tenderly. + + Returns: + tuple: The URI and the block number. + """ + + # Replace these variables with your actual data + ACCOUNT_SLUG = os.environ["TENDERLY_USER"] + PROJECT_SLUG = os.environ["TENDERLY_PROJECT"] + ACCESS_KEY = os.environ["TENDERLY_ACCESS_KEY"] + + url = f"https://api.tenderly.co/api/v1/account/{ACCOUNT_SLUG}/project/{PROJECT_SLUG}/testnet/container" + + headers = {"Content-Type": "application/json", "X-Access-Key": ACCESS_KEY} + + data = { + "slug": f"testing-api-endpoint-{datetime.now().strftime('%Y%m%d%H%M%S%f')}", + "displayName": "Automated Test Env", + "description": "", + "visibility": "TEAM", + "tags": {"purpose": "development"}, + "networkConfig": { + "networkId": "1", + "blockNumber": "latest", + "baseFeePerGas": "1", + "chainConfig": {"chainId": "1"}, + }, + "private": True, + "syncState": False, + } + + response = requests.post(url, headers=headers, data=json.dumps(data)) + + uri = f"{response.json()['container']['connectivityConfig']['endpoints'][0]['uri']}" + from_block = int(response.json()["container"]["networkConfig"]["blockNumber"]) + + return uri, from_block + + @staticmethod + def process_order_data(log_args: dict, order_key: str) -> dict: + """ + Transforms nested order data by appending a suffix to each key. + + Args: + log_args (dict): The log arguments. + order_key (str): The order key. + + Returns: + dict: The processed order data. + """ + if order_data := log_args.get(order_key): + suffix = order_key[-1] # Assumes order_key is either 'order0' or 'order1' + return {f"{key}{suffix}": value for key, value in order_data.items()} + return {} + + @staticmethod + def print_state_changes( + args: argparse.Namespace, + all_carbon_strategies: list, + deleted_strategies: list, + remaining_carbon_strategies: list, + ) -> None: + """ + Prints the state changes of Carbon strategies. + + Args: + args (argparse.Namespace): The command-line arguments. + all_carbon_strategies (list): The all carbon strategies. + deleted_strategies (list): The deleted strategies. + remaining_carbon_strategies (list): The remaining carbon strategies. + """ + args.logger.debug( + f"{len(all_carbon_strategies)} Carbon strategies have been created" + ) + args.logger.debug( + f"{len(deleted_strategies)} Carbon strategies have been deleted" + ) + args.logger.debug( + f"{len(remaining_carbon_strategies)} Carbon strategies remain" + ) + + def get_generic_events( + self, args: argparse.Namespace, event_name: str, from_block: int + ) -> pd.DataFrame: + """ + Fetches logs for a specified event from a smart contract. + + Args: + args (argparse.Namespace): The command-line arguments. + event_name (str): The event name. + from_block (int): The block number. + + Returns: + pd.DataFrame: The logs for the specified event. + """ + args.logger.debug( + f"Fetching logs for {event_name} event, from_block: {from_block}" + ) + try: + log_list = getattr(self.carbon_controller.events, event_name).get_logs( + fromBlock=from_block + ) + data = [] + for log in log_list: + log_data = { + "block_number": log["blockNumber"], + "transaction_hash": self.w3.to_hex(log["transactionHash"]), + **log["args"], + } + + # Process and update log_data for 'order0' and 'order1', if present + for order_key in ["order0", "order1"]: + if order_data := self.process_order_data(log["args"], order_key): + log_data.update(order_data) + del log_data[order_key] + + data.append(log_data) + + df = pd.DataFrame(data) + return ( + df.sort_values(by="block_number") + if "block_number" in df.columns + else df + ).reset_index(drop=True) + except Exception as e: + args.logger.debug( + f"Error fetching logs for {event_name} event: {e}, returning empty df" + ) + return pd.DataFrame({}) + + def get_state_of_carbon_strategies( + self, args: argparse.Namespace, from_block: int + ) -> tuple: + """ + Fetches the state of Carbon strategies. + + Args: + args (argparse.Namespace): The command-line arguments. + from_block (int): The block number. + + Returns: + tuple: The strategy created dataframe, the strategy deleted dataframe, and the remaining carbon strategies. + """ + strategy_created_df = self.get_generic_events( + args=args, event_name="StrategyCreated", from_block=from_block + ) + all_carbon_strategies = ( + [] + if strategy_created_df.empty + else [ + (strategy_created_df["id"][i], strategy_created_df["owner"][i]) + for i in strategy_created_df.index + ] + ) + strategy_deleted_df = self.get_generic_events( + args=args, event_name="StrategyDeleted", from_block=from_block + ) + deleted_strategies = ( + [] if strategy_deleted_df.empty else strategy_deleted_df["id"].to_list() + ) + remaining_carbon_strategies = [ + x for x in all_carbon_strategies if x[0] not in deleted_strategies + ] + self.print_state_changes( + args, all_carbon_strategies, deleted_strategies, remaining_carbon_strategies + ) + return strategy_created_df, strategy_deleted_df, remaining_carbon_strategies + + def modify_storage(self, w3: Web3, address: str, slot: str, value: str): + """Modify storage directly via Tenderly. + + Args: + w3 (Web3): The Web3 instance. + address (str): The address. + slot (str): The slot. + value (str): The value. + """ + params = [address, slot, value] + w3.provider.make_request(method="tenderly_setStorageAt", params=params) + + @staticmethod + def set_balance_via_faucet( + args: argparse.Namespace, + w3: Web3, + token_address: str, + amount_wei: int, + wallet: Address or ChecksumAddress, + retry_num=0, + ): + """ + Set the balance of a wallet via a faucet. + + Args: + args (argparse.Namespace): The command-line arguments. + w3 (Web3): The Web3 instance. + token_address (str): The token address. + amount_wei (int): The amount in wei. + wallet (Address or ChecksumAddress): The wallet address. + retry_num (int): The number of retries. + """ + token_address = w3.to_checksum_address(token_address) + wallet = w3.to_checksum_address(wallet) + if token_address in {ETH_ADDRESS}: + method = "tenderly_setBalance" + params = [[wallet], w3.to_hex(amount_wei)] + else: + method = "tenderly_setErc20Balance" + params = [token_address, wallet, w3.to_hex(amount_wei)] + + try: + w3.provider.make_request(method=method, params=params) + except requests.exceptions.HTTPError: + time.sleep(1) + if retry_num < 3: + args.logger.debug(f"Retrying faucet request for {token_address}") + TestManager.set_balance_via_faucet( + args, w3, token_address, amount_wei, wallet, retry_num + 1 + ) + + args.logger.debug(f"Reset Balance to {amount_wei}") + + def modify_token( + self, + args: argparse.Namespace, + token_address: str, + modifications: dict, + strategy_id: int, + strategy_beneficiary: Address, + ): + """General function to modify token parameters and handle deletion. + + Args: + args (argparse.Namespace): The command-line arguments. + token_address (str): The token address. + modifications (dict): The modifications to be made. + strategy_id (int): The strategy id. + strategy_beneficiary (Address): The strategy beneficiary. + """ + + # Modify the tax parameters + for slot, value in modifications["before"].items(): + self.modify_storage(self.w3, token_address, slot, value) + + # Ensure there is sufficient funds for withdrawal + self.set_balance_via_faucet( + args, + self.w3, + token_address, + modifications["balance"], + self.carbon_controller.address, + ) + + self.delete_strategy(strategy_id, strategy_beneficiary) + + # Reset the tax parameters to their original state + for slot, value in modifications["after"].items(): + self.modify_storage(self.w3, token_address, slot, value) + + # Empty out this token from CarbonController + self.set_balance_via_faucet( + args, self.w3, token_address, 0, self.carbon_controller.address + ) + + def modify_tokens_for_deletion(self, args: argparse.Namespace) -> None: + """Custom modifications to tokens to allow their deletion from Carbon. + + Args: + args (argparse.Namespace): The command-line arguments. + """ + for token_name, details in TOKENS_MODIFICATIONS.items(): + args.logger.debug(f"Modifying {token_name} token..., details: {details}") + self.modify_token( + args, + details["address"], + details["modifications"], + details["strategy_id"], + details["strategy_beneficiary"], + ) + args.logger.debug(f"Modification for {token_name} token completed.") + + def create_strategy(self, args: argparse.Namespace, strategy: TestStrategy) -> str: + """ + Creates a Carbon strategy. + + Args: + args (argparse.Namespace): The command-line arguments. + strategy (TestStrategy): The test strategy. + + Returns: + str: The transaction hash. + """ + tx_params = { + "from": strategy.wallet.address, + "nonce": strategy.wallet.nonce, + "gasPrice": DEFAULT_GAS_PRICE, + "gas": DEFAULT_GAS, + } + if strategy.value: + tx_params["value"] = strategy.value + + tx_hash = self.carbon_controller.functions.createStrategy( + strategy.token0.address, + strategy.token1.address, + ( + [strategy.y0, strategy.z0, strategy.A0, strategy.B0], + [strategy.y1, strategy.z1, strategy.A1, strategy.B1], + ), + ).transact(tx_params) + + tx_receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) + if tx_receipt.status != 1: + args.logger.debug("Creation Failed") + return None + else: + args.logger.debug("Successfully Created Strategy") + return self.w3.to_hex(tx_receipt.transactionHash) + + def delete_strategy(self, strategy_id: int, wallet: Address) -> int: + """ + Deletes a Carbon strategy. + + Args: + strategy_id (int): The strategy id. + wallet (Address): The wallet address. + + Returns: + int: The transaction receipt status. + """ + nonce = self.w3.eth.get_transaction_count(wallet) + tx_params = { + "from": wallet, + "nonce": nonce, + "gasPrice": DEFAULT_GAS_PRICE, + "gas": DEFAULT_GAS, + } + tx_hash = self.carbon_controller.functions.deleteStrategy(strategy_id).transact( + tx_params + ) + + tx_receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) + return tx_receipt.status + + def delete_all_carbon_strategies( + self, args: argparse.Namespace, carbon_strategy_id_owner_list: list + ) -> list: + """ + Deletes all Carbon strategies. + + Args: + args (argparse.Namespace): The command-line arguments. + carbon_strategy_id_owner_list (list): The carbon strategy id owner list. + + Returns: + list: The undeleted strategies. + """ + self.modify_tokens_for_deletion(args) + undeleted_strategies = [] + for strategy_id, owner in carbon_strategy_id_owner_list: + args.logger.debug("Attempt 1") + status = self.delete_strategy(strategy_id, owner) + if status == 0: + try: + strategy_info = self.carbon_controller.functions.strategy( + strategy_id + ).call() + current_owner = strategy_info[1] + try: + args.logger.debug("Attempt 2") + status = self.delete_strategy(strategy_id, current_owner) + if status == 0: + args.logger.debug( + f"Unable to delete strategy {strategy_id}" + ) + undeleted_strategies += [strategy_id] + except Exception as e: + args.logger.debug( + f"Strategy {strategy_id} not found - already deleted {e}" + ) + except Exception as e: + args.logger.debug( + f"Strategy {strategy_id} not found - already deleted {e}" + ) + elif status == 1: + args.logger.debug(f"Strategy {strategy_id} successfully deleted") + else: + args.logger.debug("Possible error") + return undeleted_strategies + + @staticmethod + def get_test_strategies(args: argparse.Namespace) -> dict: + """ + Gets test strategies from a JSON file. + """ + test_strategies_path = os.path.normpath( + f"{TEST_FILE_DATA_DIR}/test_strategies.json" + ) + with open(test_strategies_path) as file: + test_strategies = json.load(file)["test_strategies"] + args.logger.debug(f"{len(test_strategies.keys())} test strategies imported") + return test_strategies + + def append_strategy_ids( + self, args: argparse.Namespace, test_strategy_txhashs: dict, from_block: int + ) -> dict: + """ + Appends the strategy ids to the test strategies. + + Args: + args (argparse.Namespace): The command-line arguments. + test_strategy_txhashs (dict): The test strategy txhashs. + from_block (int): The block number. + + Returns: + dict: The test strategy txhashs with the strategy ids. + """ + args.logger.debug("\nAdd the strategy ids...") + + # Get the new state of the carbon strategies + ( + strategy_created_df, + strategy_deleted_df, + remaining_carbon_strategies, + ) = self.get_state_of_carbon_strategies(args, from_block) + + for i in test_strategy_txhashs: + try: + test_strategy_txhashs[i]["strategyid"] = strategy_created_df[ + strategy_created_df["transaction_hash"] + == test_strategy_txhashs[i]["txhash"] + ].id.values[0] + args.logger.debug(f"Added the strategy ids: {i}") + except Exception as e: + args.logger.debug(f"Add the strategy ids Error: {i}, {e}") + return test_strategy_txhashs + + @staticmethod + def write_strategy_txhashs_to_json(test_strategy_txhashs: dict): + """ + Writes the test strategy txhashs to a file. + + Args: + test_strategy_txhashs (dict): The test strategy txhashs. + """ + test_strategy_txhashs_path = os.path.normpath( + f"{TEST_FILE_DATA_DIR}/test_strategy_txhashs.json" + ) + with open(test_strategy_txhashs_path, "w") as f: + json.dump(test_strategy_txhashs, f) + f.close() + + @staticmethod + def get_strats_created_from_block(args: argparse.Namespace, w3: Web3) -> int: + """ + Gets the block number from which new strategies were created. + + Args: + args (argparse.Namespace): The command-line arguments. + + Returns: + int: The block number. + """ + strats_created_from_block = w3.eth.get_block_number() + args.logger.debug(f"strats_created_from_block: {strats_created_from_block}") + return strats_created_from_block + + def approve_and_create_strategies( + self, args: argparse.Namespace, test_strategies: dict, from_block: int + ) -> dict: + """ + Approves and creates test strategies. + + Args: + args (argparse.Namespace): The command-line arguments. + test_strategies (dict): The test strategies. + from_block (int): The block number. + + Returns: + dict: All the relevant test strategies + """ + test_strategy_txhashs: Dict[TestStrategy] or Dict = {} + for i, (key, arg) in enumerate(test_strategies.items()): + arg["w3"] = self.w3 + test_strategy = TestStrategy(**arg) + test_strategy.get_token_approval( + token_id=0, approval_address=self.carbon_controller.address + ) + test_strategy.get_token_approval( + token_id=1, approval_address=self.carbon_controller.address + ) + tx_hash = self.create_strategy(args, test_strategy) + test_strategy_txhashs[str(i + 1)] = {"txhash": tx_hash} + + test_strategy_txhashs = self.append_strategy_ids( + args, test_strategy_txhashs, from_block + ) + return test_strategy_txhashs + + @staticmethod + def overwrite_command_line_args(args: argparse.Namespace) -> TestCommandLineArgs: + """ + Overwrites the command-line arguments with the default main args. + + Args: + args (argparse.Namespace): The command-line arguments. + + Returns: + TestCommandLineArgs: The default main args. + """ + # Get the default main args + default_main_args = TestCommandLineArgs() + default_main_args.blockchain = args.network + default_main_args.arb_mode = args.arb_mode + default_main_args.timeout = args.timeout + default_main_args.rpc_url = args.rpc_url + return default_main_args + + def get_most_recent_pool_data_path(self, args: argparse.Namespace) -> str: + """ + Gets the path to the most recent pool data file. + + Args: + args (argparse.Namespace): The command-line arguments. + + Returns: + str: The path to the most recent pool data file. + """ + + # Use glob to list all directories + most_recent_log_folder = [ + f for f in glob.glob(self.logs_path) if os.path.isdir(f) + ][-1] + args.logger.debug(f"Accessing log folder {most_recent_log_folder}") + return os.path.join(most_recent_log_folder, "latest_pool_data.json") + + def delete_old_logs(self, args: argparse.Namespace): + """ + Deletes all files and directories in the logs directory. + + Args: + args (argparse.Namespace): The command-line arguments. + """ + logs = [f for f in glob.glob(self.logs_path) if os.path.isdir(f)] + for log in logs: + for root, dirs, files in os.walk(log, topdown=False): + for name in files: + os.remove(os.path.join(root, name)) + for name in dirs: + os.rmdir(os.path.join(root, name)) + os.rmdir(log) + args.logger.debug("Deleted old logs") diff --git a/fastlane_bot/tests/deterministic/dtest_pool.py b/fastlane_bot/tests/deterministic/dtest_pool.py new file mode 100644 index 000000000..74ccbc92f --- /dev/null +++ b/fastlane_bot/tests/deterministic/dtest_pool.py @@ -0,0 +1,118 @@ +""" +This module contains the Wallet class, which is a dataclass that represents a wallet on the given network. + +(c) Copyright Bprotocol foundation 2024. +Licensed under MIT License. +""" +import argparse +import ast +import os +from dataclasses import dataclass + +import pandas as pd +from web3 import Web3 +from web3.types import RPCEndpoint + +from fastlane_bot.tests.deterministic.dtest_constants import ( + SUPPORTED_EXCHANGES, + TEST_FILE_DATA_DIR, +) +from fastlane_bot.tests.deterministic.dtest_token import TestTokenBalance + + +@dataclass +class TestPool: + """ + This class is used to represent a pool on the given network. + """ + + exchange_type: str + pool_address: str + tkn0_address: str + tkn1_address: str + slots: str or list # List after __post_init__, str before + param_lists: str or list # List after __post_init__, str before + tkn0_setBalance: TestTokenBalance or int # TokenBalance after __post_init__, int before + tkn1_setBalance: TestTokenBalance or int # TokenBalance after __post_init__, int before + param_blockTimestampLast: int = None + param_blockTimestampLast_type: str = None + param_reserve0: int = None + param_reserve0_type: str = None + param_reserve1: int = None + param_reserve1_type: str = None + param_liquidity: int = None + param_liquidity_type: str = None + param_sqrtPriceX96: int = None + param_sqrtPriceX96_type: str = None + param_tick: int = None + param_tick_type: str = None + param_observationIndex: int = None + param_observationIndex_type: str = None + param_observationCardinality: int = None + param_observationCardinality_type: str = None + param_observationCardinalityNext: int = None + param_observationCardinalityNext_type: str = None + param_feeProtocol: int = None + param_feeProtocol_type: str = None + param_unlocked: int = None + param_unlocked_type: str = None + + def __post_init__(self): + self.slots = ast.literal_eval(self.slots) + self.param_lists = ast.literal_eval(self.param_lists) + + @staticmethod + def attributes(): + """ + Returns the attributes of the TestPool class. + """ + return list(TestPool.__dataclass_fields__.keys()) + + @property + def param_dict(self): + """ + Returns a dictionary mapping the slots to the param_lists. + """ + return dict(zip(self.slots, self.param_lists)) + + @property + def is_supported(self): + """ + Returns True if the pool is supported, otherwise False. + """ + return self.exchange_type in SUPPORTED_EXCHANGES + + def set_balance_via_faucet(self, args: argparse.Namespace, + w3: Web3, token_id: int): + """ + This method sets the balance of the given token to the given amount using the faucet. + + Args: + args: The command-line arguments. + w3: The Web3 instance. + token_id: The token id. + """ + token_address = self.tkn0_address if token_id == 0 else self.tkn1_address + amount_wei = self.tkn0_setBalance if token_id == 0 else self.tkn1_setBalance + token_balance = TestTokenBalance(token=token_address, balance=amount_wei) + params = token_balance.faucet_params(wallet_address=self.pool_address) + method_name = RPCEndpoint( + "tenderly_setBalance" + if token_balance.token.is_eth + else "tenderly_setErc20Balance" + ) + w3.provider.make_request(method=method_name, params=params) + token_balance.balance = amount_wei + if token_id == 0: + self.tkn0_setBalance = token_balance + else: + self.tkn1_setBalance = token_balance + args.logger.debug(f"Reset Balance to {amount_wei}") + + @staticmethod + def load_test_pools(): + # Import pool data + static_pool_data_testing_path = os.path.normpath( + f"{TEST_FILE_DATA_DIR}/static_pool_data_testing.csv" + ) + return pd.read_csv(static_pool_data_testing_path, dtype=str) diff --git a/fastlane_bot/tests/deterministic/dtest_pool_params_builder.py b/fastlane_bot/tests/deterministic/dtest_pool_params_builder.py new file mode 100644 index 000000000..18d698610 --- /dev/null +++ b/fastlane_bot/tests/deterministic/dtest_pool_params_builder.py @@ -0,0 +1,193 @@ +""" +This module is used to build the parameters for the test pool. It is used to build the parameters for the test pool and +encode them into a string that can be used to update the storage of the pool contract. + +(c) Copyright Bprotocol foundation 2024. +Licensed under MIT License. +""" +import re +from dataclasses import dataclass + +import eth_abi +from web3 import Web3 +from web3.types import RPCEndpoint + +from fastlane_bot.tests.deterministic.dtest_pool import TestPool + + +@dataclass +class TestPoolParam: + """ + This class is used to represent a parameter of the test pool. + """ + + type: str + value: any + + +class TestPoolParamsBuilder: + """ + This class is used to build the parameters for the test pool. + """ + + def __init__(self, w3: Web3): + self.w3 = w3 + + @staticmethod + def convert_to_bool(value: str or int) -> bool: + """ + This method is used to convert a string value to a boolean. + """ + if isinstance(value, str): + return value.lower() in ["true", "1"] + return bool(value) + + @staticmethod + def safe_int_conversion(value: any) -> int or None: + """ + This method is used to convert a value to an integer. + """ + try: + return int(value) + except (ValueError, TypeError): + print(f"Error converting {value} to int") + return None + + @staticmethod + def append_zeros(value: any, type_str: str) -> str: + """ + This method is used to append zeros to a value based on the type. + """ + result = None + if type_str == "bool": + result = "0001" if str(value).lower() in {"true", "1"} else "0000" + elif type_str == "int24": + long_hex = eth_abi.encode(["int24"], [value]).hex() + result = long_hex[-6:] + elif "int" in type_str: + try: + hex_value = hex(value)[2:] + length = int(re.search(r"\d+", type_str).group()) // 4 + result = "0" * (length - len(hex_value)) + hex_value + except Exception as e: + print(f"Error building append_zeros {str(e)}") + return result + + def build_type_val_dict( + self, pool: TestPool, param_list_single: list[str] + ) -> tuple: + """ + This method is used to build the type_val_dict and the encoded_params for the given pool. + """ + type_val_dict = {} + for param in param_list_single: + param_value = self.get_param_value(pool, param) + if param_value is not None: + type_val_dict[param] = TestPoolParam( + type=pool.__getattribute__(f"param_{param}_type") or "uint256", + value=param_value, + ) + + encoded_params = self.encode_params(type_val_dict, param_list_single) + return type_val_dict, encoded_params + + def get_param_value(self, pool: TestPool, param: str) -> int or bool: + """ + This method is used to get the value of the given parameter. + """ + if param == "blockTimestampLast": + return self.get_latest_block_timestamp() + elif param == "unlocked": + return self.convert_to_bool(pool.param_unlocked) + else: + return self.safe_int_conversion( + pool.__getattribute__(f"param_{param}") or 0 + ) + + def get_latest_block_timestamp(self): + """ + This method is used to get the latest block timestamp. + """ + try: + return int(self.w3.eth.get_block("latest")["timestamp"]) + except Exception as e: + print(f"Error fetching latest block timestamp: {e}") + return None + + def encode_params(self, type_val_dict: dict, param_list_single: list[str]) -> str: + """ + This method is used to encode the parameters into a string that can be used to update the storage of the pool + contract. + + Args: + type_val_dict (dict): The type value dictionary. + param_list_single (list): The list of parameters. + + Returns: + str: The encoded parameters. + """ + try: + result = "".join( + self.append_zeros(type_val_dict[param].value, type_val_dict[param].type) + for param in param_list_single + ) + return "0x" + "0" * (64 - len(result)) + result + except Exception as e: + print(f"Error encoding params: {e}, {type_val_dict}") + return None + + def get_update_params_dict(self, pool: TestPool) -> dict: + """ + This method is used to get the update parameters dictionary for the given pool. + + Args: + pool (TestPool): The test pool. + + Returns: + dict: The update parameters dictionary. + """ + params_dict = {} + for i in range(len(pool.slots)): + params_dict[pool.slots[i]] = { + "slot": "0x" + self.append_zeros(int(pool.slots[i]), "uint256") + } + type_val_dict, encoded_params = self.build_type_val_dict( + pool, param_list_single=pool.param_lists[i] + ) + + params_dict[pool.slots[i]]["type_val_dict"] = type_val_dict + params_dict[pool.slots[i]]["encoded_params"] = encoded_params + return params_dict + + def set_storage_at(self, pool_address: str, update_params_dict_single: dict): + method = RPCEndpoint("tenderly_setStorageAt") + self.w3.provider.make_request( + method=method, + params=[ + pool_address, + update_params_dict_single["slot"], + update_params_dict_single["encoded_params"], + ], + ) + print(f"[set_storage_at] {pool_address}, {update_params_dict_single['slot']}") + print( + f"[set_storage_at] Updated storage parameters for {pool_address} at slot {update_params_dict_single['slot']}" + ) + + @staticmethod + def update_pools_by_exchange(args, builder, pools, w3): + # Handle each exchange_type differently for the required updates + for pool in pools: + # Set balances on pool + pool.set_balance_via_faucet(args, w3, 0) + pool.set_balance_via_faucet(args, w3, 1) + + # Set storage parameters + update_params_dict = builder.get_update_params_dict(pool) + + # Update storage parameters + for slot, params in update_params_dict.items(): + builder.set_storage_at(pool.pool_address, params) + args.logger.debug( + f"Updated storage parameters for {pool.pool_address} at slot {slot}" + ) diff --git a/fastlane_bot/tests/deterministic/dtest_strategy.py b/fastlane_bot/tests/deterministic/dtest_strategy.py new file mode 100644 index 000000000..d67b2db00 --- /dev/null +++ b/fastlane_bot/tests/deterministic/dtest_strategy.py @@ -0,0 +1,129 @@ +""" +This file contains the Strategy class, which is used to represent a strategy in the deterministic tests. + +(c) Copyright Bprotocol foundation 2024. +Licensed under MIT License. +""" +import argparse +from dataclasses import dataclass + +from eth_typing import ChecksumAddress +from web3 import Web3 + +from fastlane_bot.data.abi import ERC20_ABI +from fastlane_bot.tests.deterministic.dtest_constants import ( + BNT_ADDRESS, + DEFAULT_GAS, + DEFAULT_GAS_PRICE, + TEST_MODE_AMT, + USDC_ADDRESS, + USDT_ADDRESS, +) +from fastlane_bot.tests.deterministic.dtest_token import TestToken +from fastlane_bot.tests.deterministic.dtest_wallet import TestWallet + + +@dataclass +class TestStrategy: + """ + A class to represent a strategy in the deterministic tests. + """ + + w3: Web3 + token0: TestToken + token1: TestToken + y0: int + z0: int + A0: int + B0: int + y1: int + z1: int + A1: int + B1: int + wallet: TestWallet + + @property + def id(self): + return self._id or None + + @id.setter + def id(self, id: int): + self._id = id + + def __post_init__(self): + self.token0 = TestToken(self.token0) + self.token0.contract = self.w3.eth.contract( + address=self.token0.address, abi=ERC20_ABI + ) + self.token1 = TestToken(self.token1) + self.token1.contract = self.w3.eth.contract( + address=self.token1.address, abi=ERC20_ABI + ) + self.wallet = TestWallet(self.w3, self.wallet) + + def get_token_approval( + self, args: argparse.Namespace, token_id: int, approval_address: ChecksumAddress + ) -> str: + """ + This method is used to get the token approval for the given token and approval address. + + Args: + args (argparse.Namespace): The command line arguments. + token_id (int): The token ID. Should be 0 or 1. + approval_address (ChecksumAddress): The approval address. + + Returns: + str: The transaction hash. + """ + token = self.token0 if token_id == 0 else self.token1 + if token.address in [ + BNT_ADDRESS, + USDC_ADDRESS, + USDT_ADDRESS, + ]: + function_call = token.contract.functions.approve( + approval_address, 0 + ).transact( + { + "gasPrice": DEFAULT_GAS_PRICE, + "gas": DEFAULT_GAS, + "from": self.wallet.address, + "nonce": self.wallet.nonce, + } + ) + tx_reciept = self.w3.eth.wait_for_transaction_receipt(function_call) + tx_hash = self.w3.to_hex(dict(tx_reciept)["transactionHash"]) + + if dict(tx_reciept)["status"] != 1: + args.logger.debug("Approval Failed") + else: + args.logger.debug("Successfully Approved for 0") + + args.logger.debug(f"tx_hash = {tx_hash}") + + function_call = token.contract.functions.approve( + approval_address, TEST_MODE_AMT + ).transact( + { + "gasPrice": DEFAULT_GAS_PRICE, + "gas": DEFAULT_GAS, + "from": self.wallet.address, + "nonce": self.wallet.nonce, + } + ) + tx_reciept = self.w3.eth.wait_for_transaction_receipt(function_call) + tx_hash = self.w3.to_hex(dict(tx_reciept)["transactionHash"]) + + if dict(tx_reciept)["status"] != 1: + args.logger.debug("Approval Failed") + else: + args.logger.debug("Successfully Approved Token for Unlimited") + + args.logger.debug(f"tx_hash = {tx_hash}") + return tx_hash + + @property + def value(self): + return ( + self.y0 if self.token0.is_eth else self.y1 if self.token1.is_eth else None + ) diff --git a/fastlane_bot/tests/deterministic/dtest_token.py b/fastlane_bot/tests/deterministic/dtest_token.py new file mode 100644 index 000000000..6a1dd5251 --- /dev/null +++ b/fastlane_bot/tests/deterministic/dtest_token.py @@ -0,0 +1,79 @@ +""" +Token class to store token address and contract object for interacting with the token on the blockchain. + +(c) Copyright Bprotocol foundation 2024. +Licensed under MIT License. +""" +from dataclasses import dataclass + +from eth_typing import Address +from web3 import Web3 +from web3.contract import Contract + +from fastlane_bot.tests.deterministic.dtest_constants import ETH_ADDRESS + + +@dataclass +class TestToken: + """ + A class to represent a token on the blockchain. + + Attributes: + address: str or Address + """ + + address: str or Address # Address after __post_init__, str before + + def __post_init__(self): + self.address = Web3.to_checksum_address(self.address) + self._contract = None + + @property + def contract(self): + return self._contract + + @contract.setter + def contract(self, contract: Contract): + self._contract = contract + + @property + def is_eth(self): + return self.address == ETH_ADDRESS + + +@dataclass +class TestTokenBalance: + """ + A class to represent the balance of a token in a wallet in the deterministic tests. + + Attributes: + token: TestToken or str + balance: int + """ + + token: TestToken or str # Token after __post_init__, str before + balance: int + + def __post_init__(self): + self.token = TestToken(self.token) + self.balance = int(self.balance) + + @property + def hex_balance(self): + return Web3.to_hex(self.balance) + + def faucet_params(self, wallet_address: str = None) -> list: + """ + This method is used to return the faucet parameters for the token balance. + + Args: + wallet_address: str + + Returns: + list: The faucet parameters. + """ + return ( + [[self.token.address], self.hex_balance] + if self.token.is_eth + else [self.token.address, wallet_address, self.hex_balance] + ) diff --git a/fastlane_bot/tests/deterministic/dtest_tx_helper.py b/fastlane_bot/tests/deterministic/dtest_tx_helper.py new file mode 100644 index 000000000..cea03f8c1 --- /dev/null +++ b/fastlane_bot/tests/deterministic/dtest_tx_helper.py @@ -0,0 +1,290 @@ +""" +This module contains the TxHelper class which is a utility class to scan the logs directory for successful transactions +and clean and extract the transaction data. + +(c) Copyright Bprotocol foundation 2024. +Licensed under MIT License. +""" +import argparse +import glob +import json +import logging +import os +import time + +from fastlane_bot.tests.deterministic.dtest_constants import ( + TEST_FILE_DATA_DIR, +) + + +class TestTxHelper: + """ + This is a utility class to scan the logs directory for successful transactions and clean and extract the + transaction data. + """ + + @staticmethod + def find_most_recent_log_folder(logs_path="./logs/*") -> str: + """Find the most recent log folder. + + Args: + logs_path (str): The path to the logs directory. Defaults to "./logs/*". + + Returns: + str: The most recent log folder. + + """ + log_folders = [f for f in glob.glob(logs_path) if os.path.isdir(f)] + return max(log_folders, key=os.path.getmtime) + + @staticmethod + def wait_for_file(file_path: str, logger: logging.Logger, timeout: int = 120, check_interval: int = 10) -> bool: + """Wait for a specific file to exist, with a timeout. + + Args: + file_path (str): The path to the file. + logger (logging.Logger): The logger. + timeout (int): The timeout in seconds. Defaults to 120. + check_interval (int): The check interval in seconds. Defaults to 10. + + """ + start_time = time.time() + while not os.path.exists(file_path): + if time.time() - start_time > timeout: + logger.debug("Timeout waiting for file.") + return False + logger.debug("File not found, waiting.") + time.sleep(check_interval) + logger.debug("File found.") + return True + + @staticmethod + def load_json_data(file_path: str) -> dict: + """Safely load JSON data from a file. + + Args: + file_path (str): The path to the file. + + Returns: + dict: The JSON data. + """ + with open(file_path, "r") as file: + return json.load(file) + + @staticmethod + def read_transaction_files(log_folder: str) -> list: + """Read all transaction files in a folder and return their content. + + Args: + log_folder (str): The path to the log folder. + + Returns: + list: A list of transaction data. + """ + tx_files = glob.glob(os.path.join(log_folder, "*.txt")) + transactions = [] + for tx_file in tx_files: + with open(tx_file, "r") as file: + transactions.append(file.read()) + return transactions + + def tx_scanner(self, args: argparse.Namespace) -> list: + """ + Scan for successful transactions in the most recent log folder. + + Args: + args (argparse.Namespace): The command-line arguments. + + Returns: + list: A list of successful transactions. + """ + most_recent_log_folder = self.find_most_recent_log_folder() + args.logger.debug(f"Accessing log folder {most_recent_log_folder}") + + pool_data_file = os.path.join(most_recent_log_folder, "latest_pool_data.json") + if self.wait_for_file(pool_data_file, args.logger): + pool_data = self.load_json_data(pool_data_file) + args.logger.debug(f"len(pool_data): {len(pool_data)}") + + transactions = self.read_transaction_files(most_recent_log_folder) + successful_txs = [tx for tx in transactions if "'status': 1" in tx] + args.logger.debug(f"Found {len(successful_txs)} successful transactions.") + + return successful_txs + + @staticmethod + def clean_tx_data(tx_data: dict) -> dict: + """ + This method takes a transaction data dictionary and removes the cid0 key from the trades. + + Args: + tx_data (dict): The transaction data. + + Returns: + dict: The cleaned transaction data. + """ + if not tx_data: + return tx_data + + for trade in tx_data["trades"]: + if trade["exchange"] == "carbon_v1" and "cid0" in trade: + del trade["cid0"] + return tx_data + + @staticmethod + def get_tx_data(strategy_id: int, txt_all_successful_txs: list) -> dict: + """ + This method takes a list of successful transactions and a strategy_id and returns the transaction data for the + given strategy_id. + + Args: + strategy_id (int): The strategy id. + txt_all_successful_txs (list): A list of successful transactions. + + Returns: + dict: The transaction data for the given strategy_id. + """ + for tx in txt_all_successful_txs: + if str(strategy_id) in tx: + return json.loads( + tx.split( + """ + +""" + )[-1] + ) + + @staticmethod + def load_json_file(file_name: str, args: argparse.Namespace) -> dict: + """ + This method loads a json file and returns the data as a dictionary. + + Args: + file_name (str): The name of the file. + args (argparse.Namespace): The command-line arguments. + + Returns: + dict: The data from the json file. + """ + file_path = ( + os.path.normpath(f"{TEST_FILE_DATA_DIR}/{file_name}") + if "/" not in file_name + else os.path.normpath(file_name) + ) + + try: + with open(file_path) as f: + data = json.load(f) + args.logger.debug(f"len({file_name})={len(data)}") + except FileNotFoundError: + data = {} + return data + + @staticmethod + def log_txs(tx_list: list, args: argparse.Namespace): + """ + This method logs the transactions in a list. + + Args: + tx_list (list): A list of transactions. + args (argparse.Namespace): The command-line arguments. + """ + for i, tx in enumerate(tx_list): + args.logger.debug(f"\nsuccessful_txs[{i}]: {tx}") + + def log_results(self, args: argparse.Namespace, actual_txs: list, expected_txs: dict, test_strategy_txhashs: dict) -> dict: + """ + Logs the results of the tests and returns a dictionary with the results. + + Args: + args (argparse.Namespace): The command-line arguments. + actual_txs (list): A list of actual transactions. + expected_txs (dict): A dictionary of expected transactions. + test_strategy_txhashs (dict): A dictionary of test strategy txhashs. + + Returns: + dict: A dictionary with the results of the tests. + """ + results_description = {} + all_tests_passed = True + + for test_id, strategy in test_strategy_txhashs.items(): + strategy_id = strategy.get("strategyid") + + # Failure case 1: strategyid missing in test_strategy_txhashs + if not strategy_id: + self.log_test_failure(test_id, "strategyid missing in test_strategy_txhashs", results_description) + continue + + tx_data = self.clean_tx_data(self.get_tx_data(strategy_id, actual_txs)) + + # Failure case 2: The test_id is not found in expected_txs + if test_id not in expected_txs["test_data"]: + self.log_test_failure(test_id, f"Test ID {test_id} not found in expected_txs", results_description, tx_data) + all_tests_passed = False + continue + + expected_test_data = expected_txs["test_data"][test_id] + + # Failure case 3: The tx_data does not match the expected_test_data + if tx_data != expected_test_data: + self.log_test_failure(test_id, "Data mismatch", results_description, tx_data, expected_test_data) + all_tests_passed = False + continue + + # Success case + results_description[test_id] = {"msg": f"Test {test_id} PASSED"} + + self.log_final_result(args, all_tests_passed) + return results_description + + @staticmethod + def log_test_failure(test_id: int, + reason: str, results_description: dict, + tx_data: dict = None, expected_data: dict = None): + """ + Logs a test failure. + + Args: + test_id (int): The test id. + reason (str): The reason for the failure. + results_description (dict): The dictionary with the results. + tx_data (dict): The transaction data. + expected_data (dict): The expected data. + """ + result = {"msg": f"Test {test_id} FAILED", "reason": reason} + if tx_data: + result["tx_data"] = tx_data + if expected_data: + result["expected_data"] = expected_data + results_description[test_id] = result + + @staticmethod + def log_final_result(args: argparse.Namespace, all_tests_passed: bool): + """ + Logs the final result of all tests. + + Args: + args (argparse.Namespace): The command-line arguments. + all_tests_passed (bool): Whether all tests passed. + """ + if all_tests_passed: + args.logger.info("ALL TESTS PASSED") + else: + args.logger.warning("SOME TESTS FAILED") + + def wait_for_txs(self, args: argparse.Namespace) -> dict: + """ + This method waits for the transactions to be executed and returns the test strategy txhashs. + + Args: + args (argparse.Namespace): The command-line arguments. + + Returns: + dict: The test strategy txhashs. + """ + test_strategy_txhashs = self.load_json_file("test_strategy_txhashs.json", args) + sleep_seconds = int(35 * len(test_strategy_txhashs.keys()) + 15) + args.logger.debug(f"sleep_seconds: {sleep_seconds}") + time.sleep(sleep_seconds) + return test_strategy_txhashs diff --git a/fastlane_bot/tests/deterministic/dtest_wallet.py b/fastlane_bot/tests/deterministic/dtest_wallet.py new file mode 100644 index 000000000..1f1942d27 --- /dev/null +++ b/fastlane_bot/tests/deterministic/dtest_wallet.py @@ -0,0 +1,33 @@ +""" +This module contains the Wallet class, which is a dataclass that represents a wallet on the Ethereum network. + +(c) Copyright Bprotocol foundation 2024. +Licensed under MIT License. +""" +from dataclasses import dataclass + +from eth_typing import Address +from web3 import Web3 + +from fastlane_bot.tests.deterministic.dtest_token import TestTokenBalance + + +@dataclass +class TestWallet: + """ + A class to represent a wallet on the Ethereum network. + """ + + w3: Web3 + address: str or Address # Address after __post_init__, str before + balances: list[ + TestTokenBalance or dict + ] = None # List of TokenBalances after __post_init__, list of dicts before + + def __post_init__(self): + self.address = Web3.to_checksum_address(self.address) + self.balances = [TestTokenBalance(**args) for args in self.balances or []] + + @property + def nonce(self): + return self.w3.eth.get_transaction_count(self.address) diff --git a/fastlane_bot/tests/deterministic/unit/fastlane_bot b/fastlane_bot/tests/deterministic/unit/fastlane_bot new file mode 120000 index 000000000..57929bed7 --- /dev/null +++ b/fastlane_bot/tests/deterministic/unit/fastlane_bot @@ -0,0 +1 @@ +../../../../fastlane_bot \ No newline at end of file diff --git a/fastlane_bot/tests/deterministic/unit/test_dtest_tx_helper.py b/fastlane_bot/tests/deterministic/unit/test_dtest_tx_helper.py new file mode 100644 index 000000000..fbd1349b5 --- /dev/null +++ b/fastlane_bot/tests/deterministic/unit/test_dtest_tx_helper.py @@ -0,0 +1,42 @@ +import json + +import pytest +from unittest.mock import patch, MagicMock + +from fastlane_bot.tests.deterministic.dtest_tx_helper import TestTxHelper + + +@pytest.fixture +def mock_logger(): + return MagicMock() + + +def test_find_most_recent_log_folder(mocker): + mocker.patch('glob.glob', return_value=['./logs/folder1', './logs/folder2']) + mocker.patch('os.path.isdir', side_effect=lambda x: True) + mocker.patch('os.path.getmtime', side_effect=lambda x: {'./logs/folder1': 1, './logs/folder2': 2}[x]) + + assert TestTxHelper.find_most_recent_log_folder() == './logs/folder2' + + +def test_wait_for_file_exists_before_timeout(mocker, mock_logger): + mocker.patch('os.path.exists', return_value=True) + assert TestTxHelper.wait_for_file("dummy_path", mock_logger) is True + mock_logger.debug.assert_called_with("File found.") + + +def test_wait_for_file_timeout(mocker, mock_logger): + mocker.patch('os.path.exists', return_value=False) + mocker.patch('time.time', side_effect=[0, 0, 121]) # Simulating timeout + assert TestTxHelper.wait_for_file("dummy_path", mock_logger, timeout=120) is False + mock_logger.debug.assert_called_with("Timeout waiting for file.") + +def test_load_json_data(tmpdir): + # Create a temporary JSON file + file = tmpdir.join("test.json") + data = {"key": "value"} + file.write(json.dumps(data)) + + # Test loading the JSON data + loaded_data = TestTxHelper.load_json_data(str(file)) + assert loaded_data == data diff --git a/fastlane_bot/tests/deterministic/unit/test_dtest_wallet.py b/fastlane_bot/tests/deterministic/unit/test_dtest_wallet.py new file mode 100644 index 000000000..c123509b9 --- /dev/null +++ b/fastlane_bot/tests/deterministic/unit/test_dtest_wallet.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass +from unittest.mock import MagicMock, patch +import pytest +from web3 import Web3 + +from fastlane_bot.tests.deterministic.dtest_constants import ETH_ADDRESS +from fastlane_bot.tests.deterministic.dtest_token import TestToken, TestTokenBalance +from fastlane_bot.tests.deterministic.dtest_wallet import TestWallet + + +@pytest.fixture +def mock_web3(): + mock = MagicMock(spec=Web3) + eth_mock = MagicMock() + eth_mock.get_transaction_count = MagicMock(return_value=123) # Example nonce value + mock.eth = eth_mock + return mock + +def test_wallet_initialization_with_token_balances(mock_web3): + # Use a valid Ethereum address for the test token + valid_eth_address = '0x' + '1' * 40 # Example of a valid Ethereum address + valid_token_address = '0x' + '2' * 40 # Another example of a valid Ethereum address + + balances = [ + {'token': valid_eth_address, 'balance': 100}, # ETH + {'token': valid_token_address, 'balance': 200} # Another token + ] + wallet = TestWallet(w3=mock_web3, address=valid_eth_address, balances=balances) + + # Verify that the wallet's balances have been initialized correctly + assert all(isinstance(balance, TestTokenBalance) for balance in wallet.balances), "Balances should be instances of TestTokenBalance" + assert all(isinstance(balance.token, TestToken) for balance in wallet.balances), "Tokens should be instances of TestToken" + assert wallet.balances[0].token.address == Web3.to_checksum_address(valid_eth_address), "Token addresses should be checksummed" + assert wallet.balances[0].balance == 100, "Balance should be correctly set" + + +def test_testtoken_initialization_and_contract_assignment(mock_web3): + token_address = '0x' + '1' * 40 # Example token address + token = TestToken(token_address) + + assert token.address == Web3.to_checksum_address(token_address), "Token address should be checksummed" + + # Simulate assigning a contract + mock_contract = MagicMock() + token.contract = mock_contract + assert token.contract == mock_contract, "Contract assignment should work correctly" + +def test_testtokenbalance_initialization_and_properties(mock_web3): + token_address = '0x' + '1' * 40 + balance = 100 + token_balance = TestTokenBalance(token=token_address, balance=balance) + + assert isinstance(token_balance.token, TestToken), "Token should be an instance of TestToken" + assert token_balance.balance == balance, "Balance should be set correctly" + assert token_balance.hex_balance == Web3.to_hex(balance), "Hex balance should match" + +def test_faucet_params_for_eth_and_non_eth(mock_web3): + eth_token_balance = TestTokenBalance(token=ETH_ADDRESS, balance=100) + non_eth_token_balance = TestTokenBalance(token='0x' + '2' * 40, balance=200) + wallet_address = '0x' + '3' * 40 + + # ETH token + eth_params = eth_token_balance.faucet_params(wallet_address) + assert eth_params[0] == [ETH_ADDRESS], "ETH faucet params should include token address" + assert eth_params[1] == Web3.to_hex(100), "ETH faucet params should include hex balance" + + # Non-ETH token + non_eth_params = non_eth_token_balance.faucet_params(wallet_address) + assert non_eth_params[0] == non_eth_token_balance.token.address, "Non-ETH faucet params should include token address" + assert non_eth_params[1] == wallet_address, "Non-ETH faucet params should include wallet address" + assert non_eth_params[2] == Web3.to_hex(200), "Non-ETH faucet params should include hex balance" diff --git a/run_deterministic_tests.py b/run_deterministic_tests.py new file mode 100644 index 000000000..3d3dcfe8d --- /dev/null +++ b/run_deterministic_tests.py @@ -0,0 +1,330 @@ +""" +This script is used to run deterministic tests on the Fastlane Bot. + +The script is run from the command line with the following command: `python run_deterministic_tests.py --task +--rpc_url --network --arb_mode ` --timeout_minutes --from_block + --create_new_testnet + +The `--task` argument specifies the task to run. The options are: +- `set_test_state`: Set the test state based on the static_pool_data_testing.csv file. +- `get_carbon_strategies_and_delete`: Get the carbon strategies and delete them. +- `run_tests_on_mode`: Run tests on the specified arbitrage mode. +- `end_to_end`: Run all of the above tasks. + +The `--rpc_url` argument specifies the URL for the RPC endpoint. + +The `--network` argument specifies the network to test. The options are: +- `ethereum`: Ethereum network. + +The `--arb_mode` argument specifies the arbitrage mode to test. The options are: +- `single`: Single arbitrage mode. +- `multi`: Multi arbitrage mode. +- `triangle`: Triangle arbitrage mode. +- `multi_triangle`: Multi triangle arbitrage mode. + +The `--timeout_minutes` argument specifies the timeout for the tests (in minutes). + +The `--from_block` argument specifies the block number to start from. + +The `--create_new_testnet` argument specifies whether to create a new testnet. The options are: +- `True`: Create a new testnet. +- `False`: Do not create a new testnet. + +The script uses the `fastlane_bot/tests/deterministic/dtest_constants.py` file to get the constants used in the tests. + +The script uses the `fastlane_bot/tests/deterministic/utils.py` file to get the utility functions used in the tests. + +All data used in the tests is stored in the `fastlane_bot/tests/deterministic/_data` directory. + +Note: This script uses the function `get_default_main_args` which returns the default command line arguments for the +`main` function in the `main.py` file. If these arguments change in main.py then they should be updated in the +`get_default_main_args` function as well. + +(c) Copyright Bprotocol foundation 2024. +Licensed under MIT License. +""" +import argparse +import logging +import os +import subprocess +import time +from typing import Dict + +from web3 import Web3 + +from fastlane_bot.tests.deterministic.dtest_constants import ( + DEFAULT_FROM_BLOCK, + KNOWN_UNABLE_TO_DELETE, + TENDERLY_RPC_KEY, + TestCommandLineArgs, +) +from fastlane_bot.tests.deterministic.dtest_manager import TestManager +from fastlane_bot.tests.deterministic.dtest_pool import TestPool +from fastlane_bot.tests.deterministic.dtest_pool_params_builder import ( + TestPoolParamsBuilder, +) +from fastlane_bot.tests.deterministic.dtest_tx_helper import TestTxHelper + + +def get_logger(args: argparse.Namespace) -> logging.Logger: + """ + Get the logger for the script. + """ + logger = logging.getLogger(__name__) + logger.setLevel(args.loglevel) + logger.handlers.clear() # Clear existing handlers to avoid duplicate logging + + # Create console handler + ch = logging.StreamHandler() + ch.setLevel(args.loglevel) + + # Create formatter and add it to the handlers + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + ch.setFormatter(formatter) + + # Add the console handler to the logger + logger.addHandler(ch) + + return logger + + +def set_test_state_task(w3: Web3): + """ + Sets the test state based on the static_pool_data_testing.csv file. + + Args: + w3: Web3 instance + """ + args.logger.info("\nRunning set_test_state_task...") + + test_pools = TestPool.load_test_pools() + + pools = [ + TestPool(**test_pools_row[TestPool.attributes()].to_dict()) + for index, test_pools_row in test_pools.iterrows() + ] + pools = [pool for pool in pools if pool.is_supported] + builder = TestPoolParamsBuilder(w3) + builder.update_pools_by_exchange(args, builder, pools, w3) + + +def get_carbon_strategies_and_delete_task( + test_manager: TestManager, args: argparse.Namespace +): + """ + Get the carbon strategies and delete them. + + Args: + test_manager: TestManager, the test manager + args: argparse.Namespace, the command line arguments + """ + args.logger.info("\nRunning get_carbon_strategies_and_delete_task...") + + # Get the state of the carbon strategies + ( + strategy_created_df, + strategy_deleted_df, + remaining_carbon_strategies, + ) = test_manager.get_state_of_carbon_strategies(args, args.from_block) + + # takes about 4 minutes per 100 strategies, so 450 ~ 18 minutes + undeleted_strategies = test_manager.delete_all_carbon_strategies( + args, remaining_carbon_strategies + ) + + # These strategies cannot be deleted on Ethereum + assert all( + x in KNOWN_UNABLE_TO_DELETE for x in undeleted_strategies + ), f"Strategies not deleted that are unknown: {undeleted_strategies}" + + +def run_tests_on_mode_task( + args: argparse.Namespace, + test_manager: TestManager, + test_strategies: Dict, +): + """ + Run tests on the specified arbitrage mode. + + Args: + args: argparse.Namespace, the command line arguments + test_manager: TestManager, the test manager + test_strategies: Dict, the test strategies + """ + args.logger.info("\nRunning run_tests_on_mode_task...") + + # Get the default main.py CL args, then overwrite based on the current command line args + default_main_args = test_manager.overwrite_command_line_args(args) + + # Print the default main args + args.logger.debug(f"command-line args: {default_main_args}") + + # Run the main.py script with the default main args + cmd_args = ["python", "main.py"] + TestCommandLineArgs.args_to_command_line( + default_main_args + ) + proc = subprocess.Popen(cmd_args) + time.sleep(3) + most_recent_pool_data_path = test_manager.get_most_recent_pool_data_path(args) + + # Wait for the main.py script to create the latest_pool_data.json file + while not os.path.exists(most_recent_pool_data_path): + time.sleep(3) + args.logger.debug("Waiting for pool data...") + + strats_created_from_block = test_manager.get_strats_created_from_block( + args, test_manager.w3 + ) + + # Approve and create the strategies + test_strategy_txhashs = test_manager.approve_and_create_strategies( + args, test_strategies, strats_created_from_block + ) + + # Write the strategy txhashs to a json file + test_manager.write_strategy_txhashs_to_json(test_strategy_txhashs) + + # Run the results crosscheck task + run_results_crosscheck_task(args, proc) + + +def run_results_crosscheck_task(args, proc: subprocess.Popen): + """ + Run the results crosscheck task. + + Args: + args: argparse.Namespace, the command line arguments + proc: subprocess.Popen, the process + """ + args.logger.info("\nRunning run_results_crosscheck_task...") + + # Initialize the tx helper + tx_helper = TestTxHelper() + + # Wait for the transactions to be completed + test_strategy_txhashs = tx_helper.wait_for_txs(args) + + # Scan for successful transactions on Tenderly which are marked by status=1 + actual_txs = tx_helper.tx_scanner(args) + # tx_helper.log_txs(actual_txs, args) + expected_txs = tx_helper.load_json_file("test_results.json", args) + + results_description = tx_helper.log_results( + args, actual_txs, expected_txs, test_strategy_txhashs + ) + proc.terminate() + for k, v in results_description.items(): + args.logger.info(f"{k}: {v}") + + +def main(args: argparse.Namespace): + """ + Main function for the script. Runs the specified task based on the command line arguments. + + Args: + args: argparse.Namespace, the command line arguments + """ + + # Set up the logger + args.logger = get_logger(args) + args.logger.info(f"Running task: {args.task}") + + # Set the timeout in seconds + args.timeout = args.timeout_minutes * 60 + + if str(args.create_new_testnet).lower() == "true": + uri, from_block = TestManager.create_new_testnet() + args.rpc_url = uri + + # Initialize the Web3 Manager + test_manager = TestManager(args=args) + test_manager.delete_old_logs(args) + + if args.task == "set_test_state": + set_test_state_task(test_manager.w3) + elif args.task == "get_carbon_strategies_and_delete": + get_carbon_strategies_and_delete_task(test_manager, args) + elif args.task == "run_tests_on_mode": + _extracted_task_handling(test_manager, args) + elif args.task == "end_to_end": + get_carbon_strategies_and_delete_task(test_manager, args) + set_test_state_task(test_manager.w3) + _extracted_task_handling(test_manager, args) + else: + raise ValueError(f"Task {args.task} not recognized") + + +def _extracted_task_handling(test_manager: TestManager, args: argparse.Namespace): + """ + Extracted task handling. + """ + test_strategies = test_manager.get_test_strategies(args) + run_tests_on_mode_task(args, test_manager, test_strategies) + + +if __name__ == "__main__": + # Parse the command line arguments + parser = argparse.ArgumentParser(description="Fastlane Bot") + parser.add_argument( + "--task", + default="update_pool_params", + type=str, + choices=[ + "set_test_state", + "get_carbon_strategies_and_delete", + "run_tests_on_mode", + "run_results_crosscheck", + "end_to_end", + ], + help="Task to run", + ) + parser.add_argument( + "--rpc_url", + default=f"https://virtual.mainnet.rpc.tenderly.co/{TENDERLY_RPC_KEY}", + type=str, + help="URL for the RPC endpoint", + ) + parser.add_argument( + "--network", + default="ethereum", + type=str, + help="Network to test", + choices=["ethereum"], # TODO: add support for other networks + ) + parser.add_argument( + "--arb_mode", + default="multi", + type=str, + choices=["single", "multi", "triangle", "multi_triangle"], + help="Arbitrage mode to test", + ) + parser.add_argument( + "--timeout_minutes", + default=10, + type=int, + help="Timeout for the tests (in minutes)", + ) + parser.add_argument( + "--from_block", + default=DEFAULT_FROM_BLOCK, + type=int, + help="Replay from block", + ) + parser.add_argument( + "--create_new_testnet", + default="False", + type=str, + help="Create a new testnet", + ) + parser.add_argument( + "--loglevel", + default="DEBUG", + type=str, + help="Logging level", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + ) + + args = parser.parse_args() + main(args)