Skip to content

Commit

Permalink
initial minimum implementation of a gas report
Browse files Browse the repository at this point in the history
  • Loading branch information
mikeshultz committed Mar 12, 2019
1 parent 6fff089 commit d5e5fe8
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 15 deletions.
9 changes: 8 additions & 1 deletion solidbyte/cli/test.py
Expand Up @@ -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',
Expand Down Expand Up @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions solidbyte/common/web3/__init__.py
Expand Up @@ -8,6 +8,9 @@
web3c = Web3ConfiguredConnection()


NO_FUNCTION_CALL_INPUTS = ['']


def normalize_hexstring(hexstr):
if isinstance(hexstr, HexBytes):
hexstr = hexstr.hex()
Expand Down Expand Up @@ -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:
Expand Down
55 changes: 41 additions & 14 deletions solidbyte/testing/__init__.py
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
143 changes: 143 additions & 0 deletions 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

0 comments on commit d5e5fe8

Please sign in to comment.