From d5e5fe83409f9df02f07f0c95a4690ff60552c81 Mon Sep 17 00:00:00 2001 From: Mike Shultz Date: Tue, 12 Mar 2019 13:25:40 -0600 Subject: [PATCH] initial minimum implementation of a gas report --- solidbyte/cli/test.py | 9 +- solidbyte/common/web3/__init__.py | 10 +++ solidbyte/testing/__init__.py | 55 +++++++++--- solidbyte/testing/gas.py | 143 ++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 solidbyte/testing/gas.py diff --git a/solidbyte/cli/test.py b/solidbyte/cli/test.py index a8f7064..70349ca 100644 --- a/solidbyte/cli/test.py +++ b/solidbyte/cli/test.py @@ -16,6 +16,8 @@ def add_parser_arguments(parser): help='Ethereum network to connect the console to') parser.add_argument('-a', '--address', type=str, required=False, help='Address of the Ethereum account to use for deployment') + parser.add_argument('-g', '--gas', action='store_true', required=False, + help='Finish with a gas report') parser.add_argument( '-p', '--passphrase', @@ -43,9 +45,14 @@ def main(parser_args): args = parser_args.FILE else: args = list() + + # If the user set debug, make sure pytest doesn't squash output + if '-d' in sys.argv: + args.append('-s') + try: return_code = run_tests(network_name, args=args, account_address=parser_args.address, - keystore_dir=parser_args.keystore) + keystore_dir=parser_args.keystore, gas_report=parser_args.gas) except DeploymentValidationError as err: if 'autodeployment' in str(err): log.error("The -a/--address option or --default must be provided for autodeployment") diff --git a/solidbyte/common/web3/__init__.py b/solidbyte/common/web3/__init__.py index a2b90ad..561872b 100644 --- a/solidbyte/common/web3/__init__.py +++ b/solidbyte/common/web3/__init__.py @@ -8,6 +8,9 @@ web3c = Web3ConfiguredConnection() +NO_FUNCTION_CALL_INPUTS = [''] + + def normalize_hexstring(hexstr): if isinstance(hexstr, HexBytes): hexstr = hexstr.hex() @@ -65,6 +68,13 @@ def abi_has_constructor(abi) -> bool: return False +def func_sig_from_input(data): + """ Return the 4-byte function signature from a transaction input field """ + if data in NO_FUNCTION_CALL_INPUTS: + return None + return remove_0x(data)[:8] + + def create_deploy_tx(w3inst, abi, bytecode, tx, *args, **kwargs): # Verify try: diff --git a/solidbyte/testing/__init__.py b/solidbyte/testing/__init__.py index 966b889..1d2a665 100644 --- a/solidbyte/testing/__init__.py +++ b/solidbyte/testing/__init__.py @@ -8,31 +8,40 @@ from ..common.networks import NetworksYML from ..common.exceptions import SolidbyteException, DeploymentValidationError from ..common.logging import getLogger +from .gas import GasReportStorage, construct_gas_report_middleware log = getLogger(__name__) class SolidbyteTestPlugin(object): - def __init__(self, network_name, web3=None, project_dir=None, keystore_dir=None): + def __init__(self, network_name, web3=None, project_dir=None, keystore_dir=None, + gas_report_storage=None): + self.network = network_name self._web3 = None if web3 is not None: self._web3 = web3 + else: + self._web3 = web3c.get_web3(self.network) self._project_dir = to_path_or_cwd(project_dir) self._contract_dir = self._project_dir.joinpath('contracts') self._deploy_dir = self._project_dir.joinpath('deploy') self._keystore_dir = to_path(keystore_dir) + if gas_report_storage is not None: + self._web3.middleware_stack.add( + construct_gas_report_middleware(gas_report_storage), + 'gas_report_middleware', + ) + def pytest_sessionfinish(self): # TODO: There was something I wanted to do here... pass @pytest.fixture def contracts(self): - if not self._web3: - self._web3 = web3c.get_web3(self.network) network_id = self._web3.net.chainId or self._web3.net.version d = Deployer(self.network, project_dir=self._project_dir) contracts_meta = d.deployed_contracts @@ -57,8 +66,6 @@ def contracts(self): @pytest.fixture def web3(self): - if not self._web3: - self._web3 = web3c.get_web3(self.network) return self._web3 @pytest.fixture @@ -68,7 +75,7 @@ def local_accounts(self): def run_tests(network_name, args=[], web3=None, project_dir=None, account_address=None, - keystore_dir=None): + keystore_dir=None, gas_report=False): """ Run all tests on project """ yml = NetworksYML(project_dir=project_dir) @@ -107,11 +114,31 @@ def run_tests(network_name, args=[], web3=None, project_dir=None, account_addres "your contracts using the `sb deploy` command." ) - return pytest.main(args, plugins=[ - SolidbyteTestPlugin( - network_name=network_name, - web3=web3, - project_dir=project_dir, - keystore_dir=keystore_dir, - ) - ]) + if not web3: + web3 = web3c.get_web3(network_name) + + report = None + if gas_report: + report = GasReportStorage() + + retval = None + try: + retval = pytest.main(args, plugins=[ + SolidbyteTestPlugin( + network_name=network_name, + web3=web3, + project_dir=project_dir, + keystore_dir=keystore_dir, + gas_report_storage=report, + ) + ]) + except Exception: + log.exception("Exception occurred while running tests.") + return 255 + else: + # TODO: Maybe this should be in pytest_sessionfinish in the plugin? + if gas_report: + report.update_gas_used_from_chain(web3) + report.log_report() + + return retval diff --git a/solidbyte/testing/gas.py b/solidbyte/testing/gas.py new file mode 100644 index 0000000..ac1aada --- /dev/null +++ b/solidbyte/testing/gas.py @@ -0,0 +1,143 @@ +""" Gas report junk """ +from typing import Optional, List, Dict, Tuple +from ..common.web3 import func_sig_from_input, normalize_hexstring +from ..common.logging import getLogger + +log = getLogger(__name__) + + +class GasTransaction(object): + def __init__(self, gas_limit, data): + self.gas_limit = gas_limit + self.data = data + self.tx_hash = None + self.gas_used = None + self.func_sig: Optional[str] = func_sig_from_input(data) + + +class GasReportStorage(object): + """ Simple transaction gas storage """ + + def __init__(self): + self.transactions: List[GasTransaction] = list() + self.total_gas: int = 0 + self.report: Dict[str, List[int]] = dict() + + def add_transaction(self, params: List) -> None: + + log.debug("GasReportStorage.add_transaction") + + for tx in params: + + if 'gas' not in tx or 'data' not in tx: + log.debug("TX: {}".format(tx)) + raise ValueError("Malformed transaction") + + self.transactions.append(GasTransaction(tx['gas'], tx['data'])) + + def update_last_transaction_set_hash(self, tx_hash): + assert len(self.transactions) > 0, "No transactions to update" + self.transactions[-1].tx_hash = tx_hash + + def update_transaction_gas_used(self, tx_hash, gas_used): + tx_idx = self._get_tx_idx(tx_hash) + if tx_idx < 0: + raise ValueError("Can not update gas used for transaction because it does not exist.") + self.transactions[tx_idx].gas_used = gas_used + self.total_gas += gas_used + + def update_gas_used_from_chain(self, web3): + """ Update all the transactions with gasUsed after the fact """ + + if not web3: + raise Exception("Brother, I need a web3 instance.") + + log.debug("Updating transactions with gasUsed from receipts...") + + for idx in range(0, len(self.transactions)): + receipt = web3.eth.getTransactionReceipt(self.transactions[idx].tx_hash) + if not receipt: + raise ValueError("Unable to get receipt for tx: {}".format( + self.transactions[idx].tx_hash + )) + if receipt.status == 0: + log.warning("Gas reporter found a failed transaction") + continue + log.debug("Transaction {} used {} gas".format(self.transactions[idx].tx_hash, receipt.gasUsed)) + self.transactions[idx].gas_used = receipt.gasUsed + self.total_gas += receipt.gasUsed + + def log_report(self, logger=None) -> None: + """ Log the report """ + + if len(self.report) < 1: + self._build_report() + + if logger is None: + logger = log + + for func in self.report.keys(): + lo = min(self.report[func]) + hi = max(self.report[func]) + avg = sum(self.report[func]) / len(self.report[func]) + if lo == hi: + logger.info("{}: {}".format(func, lo)) + else: + logger.info("{}: Low: {} High: {} Avg: {}".format(func, lo, hi, avg)) + + logger.info("Total Transactions: {}".format(len(self.transactions))) + logger.info("Total Gas: {}".format(self.total_gas)) + logger.info("Average Gas per Transaction: {}".format( + self.total_gas / len(self.transactions) + )) + + def _build_report(self) -> None: + self.report = {} + + log.debug("Building report...") + + for tx in self.transactions: + if tx.func_sig: + if tx.func_sig not in self.report: + self.report[tx.func_sig] = [tx.gas_used] + else: + self.report[tx.func_sig].append(tx.gas_used) + else: + log.warn("No function signature") + + def _get_tx_idx(self, tx_hash) -> int: + """ Get the index for a transaction with tx_hash """ + for idx in range(0, len(self.transactions)): + if self.transactions[idx].tx_hash == tx_hash: + return idx + return -1 + + +def construct_gas_report_middleware(gas_report_storage): + """ Create a middleware for web3.py """ + + def gas_report_middleware(make_request, web3): + """ web3.py middleware for building a gas report """ + + def middleware(method, params): + + if method == 'eth_sendTransaction': + gas_report_storage.add_transaction(params) + elif method == 'eth_sendRawTransaction': + log.warning("Raw transactions will be excluded from the gas report.") + + response = make_request(method, params) + + if method == 'eth_sendTransaction': + if 'result' in response: + tx_hash = normalize_hexstring(response['result']) + log.debug("tx_hash: {}".format(tx_hash)) + gas_report_storage.update_last_transaction_set_hash(tx_hash) + else: + log.warning("Malformed response: {}".format(response)) + + return response + + return middleware + + return gas_report_middleware