From f4cac404276cdaaf9f3d85d7e3cd8e4ca3013618 Mon Sep 17 00:00:00 2001 From: Ivan Adamov Date: Wed, 18 Sep 2024 08:26:56 -0700 Subject: [PATCH] added cost reports for SPL contracts (certain actions) --- .github/workflows/basic.yml | 36 ++++++- clickfile.py | 27 +++-- conftest.py | 19 ++++ deploy/cli/cost_report.py | 97 ++++++++++++++++++ deploy/cli/dapps.py | 78 --------------- integration/tests/basic/erc/test_ERC20SPL.py | 9 +- integration/tests/basic/erc/test_ERC721.py | 3 +- integration/tests/conftest.py | 2 +- utils/erc20wrapper.py | 25 +++-- utils/erc721ForMetaplex.py | 15 +-- utils/models/cost_report_model.py | 15 +++ utils/stats_collector.py | 100 +++++++++++++++++++ 12 files changed, 316 insertions(+), 110 deletions(-) create mode 100644 deploy/cli/cost_report.py create mode 100644 utils/models/cost_report_model.py create mode 100644 utils/stats_collector.py diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index 2936eff0b1..2841172465 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -32,6 +32,11 @@ on: - 8 - 12 - auto + generate_cost_report: + type: boolean + default: false + required: false + description: "Flag defining whether cost report should be generated" env: NETWORK: ${{ github.event.inputs.network || 'terraform' }} NUMPROCESSES: ${{ github.event.inputs.numprocesses || 8 }} @@ -40,6 +45,7 @@ env: FAUCET_URL: "${{ secrets.DEVNET_FAUCET_URL }}" IMAGE: ${{ github.repository_owner }}/neon_tests DOCKER_HUB_ORG_NAME: ${{ github.repository_owner }} + GENERATE_COST_REPORT: ${{ github.event.inputs.generate_cost_report || 'false' }} jobs: dockerize: if: ${{ github.ref_name != 'develop'}} @@ -137,12 +143,35 @@ jobs: timeout-minutes: 60 id: basic run: | - docker exec -i ${{ env.CONTAINER }} \ - /bin/bash -c "export DEVNET_FAUCET_URL=${{ env.FAUCET_URL }} && \ - python3 ./clickfile.py run basic --network ${{ env.NETWORK }} --numprocesses ${{ env.NUMPROCESSES }}" + CMD="python3 ./clickfile.py run basic --network ${{ env.NETWORK }} --numprocesses ${{ env.NUMPROCESSES }}" + + if [[ "${{ env.GENERATE_COST_REPORT }}" == "true" ]]; then + CMD="$CMD --cost_reports_dir reports/cost_reports" + fi + + docker exec -i ${{ env.CONTAINER }} /bin/bash -c "export DEVNET_FAUCET_URL=${{ env.FAUCET_URL }} && $CMD" + - name: Set failed test group to basic if: failure() run: echo "FAILED_TEST_GROUP=basic" >> $GITHUB_ENV + - name: Copy cost reports from container + if: ${{ env.GENERATE_COST_REPORT == 'true' }} + run: | + mkdir -p ./reports/cost_reports/ && \ + docker cp ${{ env.CONTAINER }}:/opt/neon-tests/reports/cost_reports/. ./reports/cost_reports/ + - name: Upload cost reports as artifacts + if: ${{ env.GENERATE_COST_REPORT == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: cost-reports + path: reports/cost_reports/**.json + - name: Save Cost Reports to cost_reports.md and echo to Summary + if: ${{ env.GENERATE_COST_REPORT == 'true' }} + run: | + docker exec -i -e NETWORK=${{ env.NETWORK }} ${{ env.CONTAINER }} \ + python3 ./clickfile.py dapps save_dapps_cost_report_to_md \ + --directory reports/cost_reports && \ + docker exec -i ${{ env.CONTAINER }} cat cost_reports.md >> $GITHUB_STEP_SUMMARY - name: "Generate allure report" if: always() uses: ./.github/actions/generate-allure-report @@ -181,4 +210,3 @@ jobs: uses: ./.github/actions/destroy-tf-stand with: ci_stands_key_hcloud: ${{ secrets.CI_STANDS_KEY_HCLOUD }} - diff --git a/clickfile.py b/clickfile.py index 9dae7d5653..839588b907 100755 --- a/clickfile.py +++ b/clickfile.py @@ -18,6 +18,7 @@ import pytest +from deploy.cli.cost_report import prepare_report_data, report_data_to_markdown from deploy.test_results_db.db_handler import PostgresTestResultsHandler from deploy.test_results_db.test_results_handler import TestResultsHandler from utils.error_log import error_log @@ -58,9 +59,7 @@ } SRC_ALLURE_CATEGORIES = Path("./allure/categories.json") - DST_ALLURE_CATEGORIES = Path("./allure-results/categories.json") - DST_ALLURE_ENVIRONMENT = Path("./allure-results/environment.properties") BASE_EXTENSIONS_TPL_DATA = "ui/extensions/data" @@ -535,6 +534,7 @@ def update_contracts(branch): @click.option("-u", "--users", default=8, help="Accounts numbers used in OZ tests") @click.option("-c", "--case", default="", type=str, help="Specific test case name pattern to run") @click.option("--marker", help="Run tests by mark") +@click.option("--cost_reports_dir", default="", help="Directory where CostReports will be created") @click.option( "--ui-item", default="all", @@ -564,6 +564,7 @@ def run( case, keep_error_log: bool, marker: str, + cost_reports_dir: str, ): if not network and name == "ui": network = "devnet" @@ -613,14 +614,24 @@ def run( if network != "geth": assert wait_for_tracer_service(network) - if case != "": - command += " -vk {}".format(case) + if case: + if " " in case: + command += f' -k "{case}"' + else: + command += f" -k {case}" + if marker: - command += f" -m {marker}" + if " " in marker: + command += f' -m "{marker}"' + else: + command += f" -m {marker}" command += f" -s --network={network} --make-report --test-group {name}" if keep_error_log: command += " --keep-error-log" + if cost_reports_dir: + command += f" --cost_reports_dir {cost_reports_dir}" + args = command.split()[1:] exit_code = int(pytest.main(args=args)) if name != "ui": @@ -1079,7 +1090,7 @@ def save_dapps_cost_report_to_db( ): tag = evm_tag if repo == "evm" else proxy_tag - report_data = dapps_cli.prepare_report_data(directory) + report_data = prepare_report_data(directory) db = PostgresTestResultsHandler() # define if previous similar reports should be deleted @@ -1116,7 +1127,7 @@ def save_dapps_cost_report_to_db( @dapps.command("save_dapps_cost_report_to_md", help="Save dApps Cost Report to cost_reports.md") @click.option("-d", "--directory", default="reports", help="Directory with reports") def save_dapps_cost_report_to_md(directory: str): - report_data = dapps_cli.prepare_report_data(directory) + report_data = prepare_report_data(directory) # Add 'gas_used_%' column after 'gas_used' report_data.insert( @@ -1127,7 +1138,7 @@ def save_dapps_cost_report_to_md(directory: str): report_data["gas_used_%"] = report_data["gas_used_%"].round(2) # Dump report_data DataFrame to markdown, grouped by the dApp - report_as_markdown_table = dapps_cli.report_data_to_markdown(df=report_data) + report_as_markdown_table = report_data_to_markdown(df=report_data) Path("cost_reports.md").write_text(report_as_markdown_table) diff --git a/conftest.py b/conftest.py index 2a1a75d1d7..d2683d9e8b 100644 --- a/conftest.py +++ b/conftest.py @@ -1,3 +1,4 @@ +import builtins import os import json import shutil @@ -24,6 +25,7 @@ pytest_plugins = ["ui.plugins.browser"] +COST_REPORT_DIR: pathlib.Path = pathlib.Path() @dataclass @@ -58,6 +60,12 @@ def pytest_addoption(parser: Parser): default=False, help="Store tests result to file", ) + parser.addoption( + "--cost_reports_dir", + default="", + type=pathlib.Path, + help=f"Saves cost reports .json files in {COST_REPORT_DIR}", + ) known_args = parser.parse_known_args(args=sys.argv[1:]) test_group_required = known_args.make_report parser.addoption( @@ -82,6 +90,9 @@ def pytest_sessionstart(session: pytest.Session): if not keep_error_log: error_log.clear() + if COST_REPORT_DIR != pathlib.Path() and COST_REPORT_DIR.exists() and COST_REPORT_DIR.is_dir(): + shutil.rmtree(COST_REPORT_DIR) + def pytest_runtest_protocol(item: Item, nextitem): ihook = item.ihook @@ -101,6 +112,14 @@ def pytest_runtest_protocol(item: Item, nextitem): def pytest_configure(config: Config): + # redirect print to stderr for xdist-spawned processes because otherwise print statements get lost + if "PYTEST_XDIST_WORKER" in os.environ: + original_print = builtins.print + builtins.print = lambda *args, **kwargs: original_print(*args, file=sys.stderr, **kwargs) + + global COST_REPORT_DIR + COST_REPORT_DIR = config.getoption("--cost_reports_dir") + solana_url_env_vars = ["SOLANA_URL", "DEVNET_INTERNAL_RPC", "MAINNET_INTERNAL_RPC"] network_name = config.getoption("--network") envs_file = config.getoption("--envs") diff --git a/deploy/cli/cost_report.py b/deploy/cli/cost_report.py new file mode 100644 index 0000000000..69f364121e --- /dev/null +++ b/deploy/cli/cost_report.py @@ -0,0 +1,97 @@ +import glob +import json +import os +import pathlib +import re +from collections import Counter + +import pandas as pd + +from deploy.cli.dapps import NETWORK_MANAGER +from deploy.cli.infrastructure import get_solana_accounts_transactions_compute_units +from utils.web3client import NeonChainWeb3Client + + +def prepare_report_data(directory: str) -> pd.DataFrame: + proxy_url = NETWORK_MANAGER.get_network_param(os.environ.get("NETWORK"), "proxy_url") + web3_client = NeonChainWeb3Client(proxy_url) + + reports = {} + for path in glob.glob(str(pathlib.Path(directory) / "*-report.json")): + with open(path, "r") as f: + rep = json.load(f) + if isinstance(rep, list): + for r in rep: + if "actions" in r: + reports[r["name"]] = r["actions"] + else: + if "actions" in rep: + reports[rep["name"]] = rep["actions"] + + data = [] + + for app, actions in reports.items(): + counts = Counter([action["name"].lower().strip() for action in actions]) + duplicate_actions = [action for action, count in counts.items() if count > 1] + added_numbers = {dup_action: 1 for dup_action in duplicate_actions} + + for action in actions: + # Ensure action name is unique by appending a counter if necessary + base_action_name = action["name"].lower().strip() + if base_action_name in duplicate_actions: + added_number = added_numbers[base_action_name] + unique_action_name = f"{base_action_name} {added_number}" + added_numbers[base_action_name] += 1 + else: + unique_action_name = base_action_name + + accounts, trx, compute_units = get_solana_accounts_transactions_compute_units(action["tx"]) + # accounts, trx, compute_units = (2, 12, 8946) + tx = web3_client.get_transaction_by_hash(action["tx"]) + estimated_gas = int(tx.gas) if tx and tx.gas else None + # estimated_gas = 122879 + used_gas = int(action["usedGas"]) + + data.append( + { + "dapp_name": app.lower().strip(), + "action": unique_action_name, + "acc_count": accounts, + "trx_count": trx, + "gas_estimated": estimated_gas, + "gas_used": used_gas, + "compute_units": compute_units, + } + ) + + df = pd.DataFrame(data) + if df.empty: + raise Exception(f"no reports found in {directory}") + return df + + +def report_data_to_markdown(df: pd.DataFrame) -> str: + report_content = "" + dapp_names = df['dapp_name'].unique() + df.columns = [col.upper() for col in df.columns] + df['GAS_USED_%'] = df['GAS_USED_%'].apply(lambda x: f"{x:.2f}") + + for dapp_name in dapp_names: + dapp_df = df[df['DAPP_NAME'] == dapp_name].drop(columns='DAPP_NAME') + + # sort by ACTION (to mitigate [action 1, action 10, action 2, ...]) + dapp_df[['ACTION_TEXT', 'ACTION_NUM']] = dapp_df['ACTION'].apply(split_action).apply(pd.Series) + dapp_df = dapp_df.sort_values(by=['ACTION_TEXT', 'ACTION_NUM']) + dapp_df = dapp_df.drop(columns=['ACTION_TEXT', 'ACTION_NUM']) + + report_content += f'\n## Cost Report for "{dapp_name.title()}" dApp\n\n' + report_content += dapp_df.to_markdown(index=False) + "\n" + + return report_content + + +def split_action(action) -> tuple[str, int]: + match = re.match(r"(.+?)\s*(\d*)$", action) + text_ = match.group(1) + number = int(match.group(2)) if match.group(2).isdigit() else 0 + return text_, number diff --git a/deploy/cli/dapps.py b/deploy/cli/dapps.py index 1afea948ca..661db455da 100644 --- a/deploy/cli/dapps.py +++ b/deploy/cli/dapps.py @@ -1,16 +1,7 @@ import os -import glob -import json import typing as tp -import pathlib -from collections import Counter -import pandas as pd - -from deploy.cli.infrastructure import get_solana_accounts_transactions_compute_units from deploy.cli.network_manager import NetworkManager -from utils.web3client import NeonChainWeb3Client - NETWORK_MANAGER = NetworkManager() @@ -24,72 +15,3 @@ def set_github_env(envs: tp.Dict, upper=True) -> None: env_file.write(f"\n{key.upper() if upper else key}={str(value)}") -def prepare_report_data(directory: str) -> pd.DataFrame: - proxy_url = NETWORK_MANAGER.get_network_param(os.environ.get("NETWORK"), "proxy_url") - web3_client = NeonChainWeb3Client(proxy_url) - - reports = {} - for path in glob.glob(str(pathlib.Path(directory) / "*-report.json")): - with open(path, "r") as f: - rep = json.load(f) - if isinstance(rep, list): - for r in rep: - if "actions" in r: - reports[r["name"]] = r["actions"] - else: - if "actions" in rep: - reports[rep["name"]] = rep["actions"] - - data = [] - - for app, actions in reports.items(): - added_number = 1 - counts = Counter([action["name"].lower().strip() for action in actions]) - duplicate_actions = [action for action, count in counts.items() if count > 1] - - for action in actions: - # Ensure action name is unique by appending a counter if necessary - base_action_name = action["name"].lower().strip() - if base_action_name in duplicate_actions: - unique_action_name = f"{base_action_name} {added_number}" - added_number += 1 - else: - unique_action_name = base_action_name - - accounts, trx, compute_units = get_solana_accounts_transactions_compute_units(action["tx"]) - # accounts, trx, compute_units = (2, 12, 8946) - tx = web3_client.get_transaction_by_hash(action["tx"]) - estimated_gas = int(tx.gas) if tx and tx.gas else None - # estimated_gas = 122879 - used_gas = int(action["usedGas"]) - - data.append( - { - "dapp_name": app.lower().strip(), - "action": unique_action_name, - "acc_count": accounts, - "trx_count": trx, - "gas_estimated": estimated_gas, - "gas_used": used_gas, - "compute_units": compute_units, - } - ) - - df = pd.DataFrame(data) - if df.empty: - raise Exception(f"no reports found in {directory}") - return df - - -def report_data_to_markdown(df: pd.DataFrame) -> str: - report_content = "" - dapp_names = df['dapp_name'].unique() - df.columns = [col.upper() for col in df.columns] - df['GAS_USED_%'] = df['GAS_USED_%'].apply(lambda x: f"{x:.2f}") - - for dapp_name in dapp_names: - dapp_df = df[df['DAPP_NAME'] == dapp_name].drop(columns='DAPP_NAME') - report_content += f'\n## Cost Report for "{dapp_name.title()}" dApp\n\n' - report_content += dapp_df.to_markdown(index=False) + "\n" - - return report_content diff --git a/integration/tests/basic/erc/test_ERC20SPL.py b/integration/tests/basic/erc/test_ERC20SPL.py index d4ac553607..e911f5eef0 100644 --- a/integration/tests/basic/erc/test_ERC20SPL.py +++ b/integration/tests/basic/erc/test_ERC20SPL.py @@ -11,8 +11,9 @@ from spl.token import instructions from spl.token.constants import TOKEN_PROGRAM_ID -from utils import metaplex +from utils import metaplex, stats_collector from utils.consts import ZERO_ADDRESS +from utils.erc20wrapper import ERC20Wrapper from utils.helpers import gen_hash_of_block, wait_condition, create_invalid_address from utils.web3client import NeonChainWeb3Client from utils.solana_client import SolanaClient @@ -37,7 +38,7 @@ class TestERC20SPL: sol_client: SolanaClient @pytest.fixture(scope="class") - def erc20_contract(self, erc20_spl, eth_bank_account, pytestconfig: Config): + def erc20_contract(self, erc20_spl, eth_bank_account, pytestconfig: Config) -> ERC20Wrapper: if pytestconfig.getoption("--network") == "mainnet": self.web3_client.send_neon(eth_bank_account, erc20_spl.account.address, 10) return erc20_spl @@ -82,6 +83,7 @@ def test_name(self, erc20_contract): name = erc20_contract.contract.functions.name().call() assert name == erc20_contract.name + @stats_collector.test_case def test_burn(self, erc20_contract, restore_balance): balance_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() total_before = erc20_contract.contract.functions.totalSupply().call() @@ -155,6 +157,7 @@ def test_burnFrom_no_enough_gas(self, erc20_contract, param, msg): with pytest.raises(ValueError, match=msg): erc20_contract.burn_from(new_account, erc20_contract.account.address, 1, **param) + @stats_collector.test_case def test_approve_more_than_total_supply(self, erc20_contract): new_account = self.accounts[0] amount = erc20_contract.contract.functions.totalSupply().call() + 1 @@ -195,6 +198,7 @@ def test_allowance_for_new_account(self, erc20_contract): ).call() assert allowance == 0 + @stats_collector.test_case def test_transfer(self, erc20_contract, restore_balance): new_account = self.accounts.create_account() balance_acc1_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() @@ -365,6 +369,7 @@ def test_approveSolana( assert int(token_account.data.parsed["info"]["delegatedAmount"]["amount"]) == amount assert int(token_account.data.parsed["info"]["delegatedAmount"]["decimals"]) == erc20_contract.decimals + @stats_collector.test_case def test_claim( self, erc20_contract, diff --git a/integration/tests/basic/erc/test_ERC721.py b/integration/tests/basic/erc/test_ERC721.py index 254fb5b097..4bdbca65d2 100644 --- a/integration/tests/basic/erc/test_ERC721.py +++ b/integration/tests/basic/erc/test_ERC721.py @@ -16,7 +16,7 @@ ) from integration.tests.basic.helpers.assert_message import ErrorMessage -from utils import metaplex +from utils import metaplex, stats_collector from utils.accounts import EthAccounts from utils.consts import ZERO_ADDRESS from utils.erc721ForMetaplex import ERC721ForMetaplex @@ -219,6 +219,7 @@ def test_transferFrom_no_enough_gas(self, erc721, token_id, accounts, param, msg **param, ) + @stats_collector.test_case def test_transferFrom_with_approval(self, erc721, token_id, accounts): recipient = accounts[2] diff --git a/integration/tests/conftest.py b/integration/tests/conftest.py index 5858e76124..004a4fbd8b 100644 --- a/integration/tests/conftest.py +++ b/integration/tests/conftest.py @@ -173,7 +173,7 @@ def erc20_spl( solana_account, eth_bank_account, accounts_session, -): +) -> ERC20Wrapper: symbol = "".join([random.choice(string.ascii_uppercase) for _ in range(3)]) erc20 = ERC20Wrapper( web3_client_session, diff --git a/utils/erc20wrapper.py b/utils/erc20wrapper.py index 8f157b75fa..675476d370 100644 --- a/utils/erc20wrapper.py +++ b/utils/erc20wrapper.py @@ -5,8 +5,9 @@ from solana.transaction import Transaction from solders.keypair import Keypair from solders.pubkey import Pubkey +from web3.types import TxReceipt -from . import web3client +from . import web3client, stats_collector from .metaplex import create_metadata_instruction_data, create_metadata_instruction INIT_TOKEN_AMOUNT = 1000000000000000 @@ -182,37 +183,41 @@ def mint_tokens(self, signer, to_address, amount: int = INIT_TOKEN_AMOUNT, gas_p resp = self.web3_client.send_transaction(signer, instruction_tx) return resp - def claim(self, signer, from_address, amount: int = INIT_TOKEN_AMOUNT, gas_price=None, gas=None): + @stats_collector.cost_report_from_receipt + def claim(self, signer, from_address, amount: int = INIT_TOKEN_AMOUNT, gas_price=None, gas=None) -> TxReceipt: tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) instruction_tx = self.contract.functions.claim(from_address, amount).build_transaction(tx) resp = self.web3_client.send_transaction(signer, instruction_tx) return resp - def claim_to(self, signer, from_address, to_address, amount, gas_price=None, gas=None): + def claim_to(self, signer, from_address, to_address, amount, gas_price=None, gas=None) -> TxReceipt: tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) instruction_tx = self.contract.functions.claimTo(from_address, to_address, amount).build_transaction(tx) resp = self.web3_client.send_transaction(signer, instruction_tx) return resp - def burn(self, signer, amount, gas_price=None, gas=None): + @stats_collector.cost_report_from_receipt + def burn(self, signer, amount, gas_price=None, gas=None) -> TxReceipt: tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) instruction_tx = self.contract.functions.burn(amount).build_transaction(tx) resp = self.web3_client.send_transaction(signer, instruction_tx) return resp - def burn_from(self, signer, from_address, amount, gas_price=None, gas=None): + def burn_from(self, signer, from_address, amount, gas_price=None, gas=None) -> TxReceipt: tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) instruction_tx = self.contract.functions.burnFrom(from_address, amount).build_transaction(tx) resp = self.web3_client.send_transaction(signer, instruction_tx) return resp - def approve(self, signer, spender_address, amount, gas_price=None, gas=None): + @stats_collector.cost_report_from_receipt + def approve(self, signer, spender_address, amount, gas_price=None, gas=None) -> TxReceipt: tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) instruction_tx = self.contract.functions.approve(spender_address, amount).build_transaction(tx) resp = self.web3_client.send_transaction(signer, instruction_tx) return resp - def transfer(self, signer, address_to, amount, gas_price=None, gas=None): + @stats_collector.cost_report_from_receipt + def transfer(self, signer, address_to, amount, gas_price=None, gas=None) -> TxReceipt: tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) if isinstance(address_to, LocalAccount): address_to = address_to.address @@ -220,19 +225,19 @@ def transfer(self, signer, address_to, amount, gas_price=None, gas=None): resp = self.web3_client.send_transaction(signer, instruction_tx) return resp - def transfer_from(self, signer, address_from, address_to, amount, gas_price=None, gas=None): + def transfer_from(self, signer, address_from, address_to, amount, gas_price=None, gas=None) -> TxReceipt: tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) instruction_tx = self.contract.functions.transferFrom(address_from, address_to, amount).build_transaction(tx) resp = self.web3_client.send_transaction(signer, instruction_tx) return resp - def transfer_solana(self, signer, address_to, amount, gas_price=None, gas=None): + def transfer_solana(self, signer, address_to, amount, gas_price=None, gas=None) -> TxReceipt: tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) instruction_tx = self.contract.functions.transferSolana(address_to, amount).build_transaction(tx) resp = self.web3_client.send_transaction(signer, instruction_tx) return resp - def approve_solana(self, signer, spender, amount, gas_price=None, gas=None): + def approve_solana(self, signer, spender, amount, gas_price=None, gas=None) -> TxReceipt: tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) instruction_tx = self.contract.functions.approveSolana(spender, amount).build_transaction(tx) resp = self.web3_client.send_transaction(signer, instruction_tx) diff --git a/utils/erc721ForMetaplex.py b/utils/erc721ForMetaplex.py index 9487d21708..3e958beec8 100644 --- a/utils/erc721ForMetaplex.py +++ b/utils/erc721ForMetaplex.py @@ -1,8 +1,9 @@ import logging import allure +from web3.types import TxReceipt -from utils import web3client +from utils import web3client, stats_collector LOGGER = logging.getLogger(__name__) @@ -69,14 +70,15 @@ def safe_mint(self, seed, to_address, uri, data=None, gas_price=None, gas=None, return logs[0]["args"]["tokenId"] @allure.step("Transfer from") - def transfer_from(self, address_from, address_to, token_id, signer, gas_price=None, gas=None): + @stats_collector.cost_report_from_receipt + def transfer_from(self, address_from, address_to, token_id, signer, gas_price=None, gas=None) -> TxReceipt: tx = self.make_tx_object(signer.address, gas_price, gas) instruction_tx = self.contract.functions.transferFrom(address_from, address_to, token_id).build_transaction(tx) resp = self.web3_client.send_transaction(signer, instruction_tx) return resp @allure.step("Safe transfer from") - def safe_transfer_from(self, address_from, address_to, token_id, signer, data=None, gas_price=None, gas=None): + def safe_transfer_from(self, address_from, address_to, token_id, signer, data=None, gas_price=None, gas=None) -> TxReceipt: tx = self.make_tx_object(signer.address, gas_price, gas) if data is None: instruction_tx = self.contract.functions.safeTransferFrom( @@ -90,21 +92,22 @@ def safe_transfer_from(self, address_from, address_to, token_id, signer, data=No return resp @allure.step("Approve") - def approve(self, address_to, token_id, signer, gas_price=None, gas=None): + @stats_collector.cost_report_from_receipt + def approve(self, address_to, token_id, signer, gas_price=None, gas=None) -> TxReceipt: tx = self.make_tx_object(signer.address, gas_price, gas) instruction_tx = self.contract.functions.approve(address_to, token_id).build_transaction(tx) resp = self.web3_client.send_transaction(signer, instruction_tx) return resp @allure.step("Set approval for all") - def set_approval_for_all(self, operator, approved, signer, gas_price=None, gas=None): + def set_approval_for_all(self, operator, approved, signer, gas_price=None, gas=None) -> TxReceipt: tx = self.make_tx_object(signer.address, gas_price, gas) instruction_tx = self.contract.functions.setApprovalForAll(operator, approved).build_transaction(tx) resp = self.web3_client.send_transaction(signer, instruction_tx) return resp @allure.step("Transfer solana from") - def transfer_solana_from(self, from_address, to_address, token_id, signer, gas_price=None, gas=None): + def transfer_solana_from(self, from_address, to_address, token_id, signer, gas_price=None, gas=None) -> TxReceipt: tx = self.make_tx_object(signer.address, gas_price, gas) instruction_tx = self.contract.functions.transferSolanaFrom( from_address, to_address, token_id diff --git a/utils/models/cost_report_model.py b/utils/models/cost_report_model.py new file mode 100644 index 0000000000..0d614e35d1 --- /dev/null +++ b/utils/models/cost_report_model.py @@ -0,0 +1,15 @@ +import pydantic + +from utils.models.model_types import HexString + + +class CostReportAction(pydantic.BaseModel): + name: str + usedGas: int + gasPrice: int + tx: HexString + + +class CostReportModel(pydantic.BaseModel): + name: str + actions: list[CostReportAction] = [] diff --git a/utils/stats_collector.py b/utils/stats_collector.py new file mode 100644 index 0000000000..4da818e7c4 --- /dev/null +++ b/utils/stats_collector.py @@ -0,0 +1,100 @@ +import contextlib +import inspect +import json +from pathlib import Path +from typing import Callable, Generator +from functools import wraps + +from filelock import FileLock +from web3.types import TxReceipt + +import conftest +from utils.models.cost_report_model import CostReportModel, CostReportAction + + +class StatisticsCollector: + def __init__(self, report_file: Path): + self.report_file: Path = report_file + self.model = CostReportModel(name=report_file.stem.removesuffix("-report")) + self.lock = FileLock(self.report_file.with_suffix(self.report_file.suffix + ".lock"), is_singleton=True) + + def __create(self): + data = self.model.model_dump_json(indent=4) + self.report_file.write_text(data) + + def read(self) -> CostReportModel: + with self.report_file.open() as f: + data = json.load(f) + return CostReportModel(**data) + + @contextlib.contextmanager + def _update(self) -> Generator[CostReportModel, None, None]: + with self.lock: + if not self.report_file.exists(): + self.__create() + + report = self.read() + + yield report + + data = report.model_dump_json(indent=4) + self.report_file.write_text(data) + + +def test_case(test): + test.DO_COLLECT_COST_REPORT_DATA = True + return test + + +def cost_report_from_receipt(func: Callable[..., TxReceipt]) -> Callable[..., TxReceipt]: + @wraps(func) + def wrapper(*args, **kwargs) -> TxReceipt: + receipt = func(*args, **kwargs) + + if conftest.COST_REPORT_DIR != Path(): + stack = inspect.stack() + if is_called_from_test_marked_for_collection(stack=stack): + file_path = Path(inspect.getfile(func)).resolve() + neon_tests_index = file_path.parts.index('neon-tests') + relative_path = Path(*file_path.parts[neon_tests_index + 1:]).with_suffix('') + class_name = ".".join(func.__qualname__.split(".")[:-1]) if "." in func.__qualname__ else "" + report_file_name = '.'.join(relative_path.parts) + f".{class_name}" + "-report.json" + report_file = conftest.COST_REPORT_DIR / report_file_name + + collector = StatisticsCollector(report_file) + + used_gas = receipt['gasUsed'] + gas_price = receipt['effectiveGasPrice'] + tx_hash = receipt['transactionHash'].hex() + + action = CostReportAction( + name=func.__name__, + usedGas=used_gas, + gasPrice=gas_price, + tx=tx_hash, + ) + + with collector._update() as report: + report.actions.append(action) + + return receipt + + return wrapper + + +def is_called_from_test_marked_for_collection(stack: list[inspect.FrameInfo]) -> bool: + for frame_info in stack: + func_name = frame_info.function + if func_name.startswith("test_"): + frame = frame_info.frame + module = inspect.getmodule(frame) + + if "self" in frame.f_locals: + function = getattr(frame.f_locals["self"].__class__, func_name) + else: + function = getattr(module, func_name) + + do_collect = getattr(function, 'DO_COLLECT_COST_REPORT_DATA', False) + if do_collect: + return True + return False