From 1004a21cf5d74893ef889e2aca9ba02c82178536 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Tue, 26 Feb 2019 14:43:01 +0200 Subject: [PATCH 01/21] bump version, check.true and check.false require bools --- CHANGELOG | 7 ++++++- __main__.py | 2 +- docs/api.rst | 29 ++++++++++++++++++++--------- docs/conf.py | 2 +- lib/components/check.py | 6 +++++- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 6b4e05b92..7c07f0ad8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,10 @@ +0.9.5 (unreleased) +----- + + - check.true and check.false require booleans to pass + 0.9.4 ------ +----- - Improved console formatting for lists and dicts - Run method returns list of scripts when no argument is given diff --git a/__main__.py b/__main__.py index ea592cd12..3208c8ffb 100644 --- a/__main__.py +++ b/__main__.py @@ -9,7 +9,7 @@ from lib.services import color, git -__version__ = "0.9.4" # did you change this in docs/conf.py as well? +__version__ = "0.9.5" # did you change this in docs/conf.py as well? if git.get_branch() != "master": __version__+= "-"+git.get_branch()+"-"+git.get_commit() diff --git a/docs/api.rst b/docs/api.rst index 36172f350..0a2285455 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -883,38 +883,49 @@ The ``check`` module exposes the following methods that are used in place of ``a Module Methods -------------- -.. py:method:: check.true(statement, fail_msg = "Expected statement to be true") +.. py:method:: check.true(statement, fail_msg = "Expected statement to be True") - Raises if ``statement`` does not evaluate to True. + Raises if ``statement`` is not ``True``. .. code-block:: python + >>> check.true(True) >>> check.true(2 + 2 == 4) + >>> >>> check.true(0 > 1) File "brownie/lib/components/check.py", line 18, in true raise AssertionError(fail_msg) - AssertionError: Expected statement to be true + AssertionError: Expected statement to be True >>> check.true(False, "What did you expect?") - File "brownie/lib/console.py", line 82, in _run - exec('_result = ' + cmd, self.__dict__, local_) - File "", line 1, in - File "/home/computer/code/python/brownie/lib/components/check.py", line 18, in true + File "brownie/lib/components/check.py", line 18, in true raise AssertionError(fail_msg) AssertionError: What did you expect? + >>> check.true(1) + File "brownie/lib/components/check.py", line 16, in true + raise AssertionError(fail_msg+" (evaluated truthfully but not True)") + AssertionError: Expected statement to be True (evaluated truthfully but not True) + + + .. py:method:: check.false(statement, fail_msg = "Expected statement to be False") - Raises if ``statement`` does not evaluate to False. + Raises if ``statement`` is not ``False``. .. code-block:: python >>> check.false(0 > 1) >>> check.false(2 + 2 == 4) - File "brownie/lib/components/check.py", line 18, in true + File "brownie/lib/components/check.py", line 18, in false raise AssertionError(fail_msg) AssertionError: Expected statement to be False + >>> check.false(0) + File "brownie/lib/components/check.py", line 16, in false + raise AssertionError(fail_msg+" (evaluated falsely but not False)") + AssertionError: Expected statement to be False (evaluated falsely but not False) + .. py:method:: check.confirms(fn, args, fail_msg = "Expected transaction to confirm") Performs the given contract call ``fn`` with arguments ``args``. Raises if the call causes the EVM to revert. diff --git a/docs/conf.py b/docs/conf.py index 5216401d3..882cafe01 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,7 @@ # The short X.Y version version = '' # The full version, including alpha/beta/rc tags -release = '0.9.4' +release = '0.9.5' # -- General configuration --------------------------------------------------- diff --git a/lib/components/check.py b/lib/components/check.py index 930f5a600..dd356aa6f 100644 --- a/lib/components/check.py +++ b/lib/components/check.py @@ -6,12 +6,14 @@ from lib.components.transaction import VirtualMachineError as _VMError -def true(statement, fail_msg="Expected statement to be true"): +def true(statement, fail_msg="Expected statement to be True"): '''Expects an object or statement to evaluate True. Args: statement: The object or statement to check. fail_msg: Message to show if the check fails.''' + if statement and statement is not True: + raise AssertionError(fail_msg+" (evaluated truthfully but not True)") if not statement: raise AssertionError(fail_msg) @@ -22,6 +24,8 @@ def false(statement, fail_msg="Expected statement to be False"): Args: statement: The object or statement to check. fail_msg: Message to show if the check fails.''' + if not statement and statement is not False: + raise AssertionError(fail_msg+" (evaluated falsely but not False)") if statement: raise AssertionError(fail_msg) From d2334e3e34105eacaefc48718aac6b0b88e9b9ab Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Tue, 26 Feb 2019 18:30:54 +0200 Subject: [PATCH 02/21] allow subfolders within tests/ --- CHANGELOG | 1 + docs/tests.rst | 2 ++ lib/coverage.py | 14 ++------------ lib/test.py | 40 +++++++++++++++++++++++++--------------- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7c07f0ad8..76378e5c7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ ----- - check.true and check.false require booleans to pass + - Allow subfolders within tests/ 0.9.4 ----- diff --git a/docs/tests.rst b/docs/tests.rst index b2c6b3120..29880ed90 100644 --- a/docs/tests.rst +++ b/docs/tests.rst @@ -17,6 +17,8 @@ You can run a specific test by giving the filename without an extension, for exa $ brownie test transfer +For larger projects you can also store tests within subfolders, and point Brownie to run only a specific folder. Brownie will skip any file or folder that begins with an underscore. + Running Tests ============= diff --git a/lib/coverage.py b/lib/coverage.py index 65aa7902e..88c77764d 100644 --- a/lib/coverage.py +++ b/lib/coverage.py @@ -6,7 +6,7 @@ import sys import json -from lib.test import run_test +from lib.test import get_test_files, run_test from lib.components.network import Network from lib.components.bytecode import get_coverage_map from lib.services import color, config @@ -37,17 +37,7 @@ def main(): args = docopt(__doc__) - if args['']: - name = args[''].replace(".py", "") - if not os.path.exists("tests/{}.py".format(name)): - sys.exit( - "{0[error]}ERROR{0}: Cannot find".format(color) + - " {0[module]}tests/{1}.py{0}".format(color, name) - ) - test_files = [name] - else: - test_files = [i[:-3] for i in os.listdir("tests") if i[-3:] == ".py"] - test_files.remove('__init__') + test_files = get_test_files(args['']) compiled = deepcopy(compile_contracts()) fn_map, line_map = get_coverage_map(compiled) diff --git a/lib/test.py b/lib/test.py index 090701782..ee0527572 100644 --- a/lib/test.py +++ b/lib/test.py @@ -16,7 +16,7 @@ __doc__ = """Usage: brownie test [] [options] Arguments: - Only run tests from a specific file + Only run tests from a specific file or folder Options: --help Display this message @@ -25,10 +25,8 @@ --tb Show entire python traceback on exceptions --always-transact Perform all contract calls as transactions -By default brownie runs every script in the tests folder, and calls every -function that does not begin with an underscore. A fresh environment is created -between each new file. Test scripts can optionally specify which deployment -script to run by setting a string 'DEPLOYMENT'.""" +By default brownie runs every script found in the tests folder as well as any +subfolders. Files and folders beginning with an underscore will be skipped.""" class ExpectedFailing(Exception): pass @@ -88,12 +86,12 @@ def run_test(filename, network): network.reset() if type(CONFIG['test']['gas_limit']) is int: network.gas(CONFIG['test']['gas_limit']) - module = importlib.import_module("tests."+filename) + module = importlib.import_module(filename.replace('/','.')) test_names = [ i for i in dir(module) if i not in dir(sys.modules['brownie']) and i[0]!="_" and callable(getattr(module, i)) ] - code = open("tests/{}.py".format(filename), encoding="utf-8").read() + code = open("{}.py".format(filename), encoding="utf-8").read() test_names = re.findall('(?<=\ndef)[\s]{1,}[^(]*(?=\([^)]*\)[\s]*:)', code) test_names = [i.strip() for i in test_names if i.strip()[0] != "_"] duplicates = set([i for i in test_names if test_names.count(i)>1]) @@ -129,18 +127,30 @@ def run_test(filename, network): return history, traceback_info -def main(): - args = docopt(__doc__) - traceback_info = [] - if args['']: - name = args[''].replace(".py", "") +def get_test_files(path): + if path and path[:6] == "tests/": + path = path[6:] + if path and not os.path.isdir('tests/'+path): + name = path.replace(".py", "") if not os.path.exists("tests/{}.py".format(name)): sys.exit("{0[error]}ERROR{0}: Cannot find {0[module]}tests/{1}.py{0}".format(color, name)) - test_files = [name] + return ["tests/"+name] else: - test_files = [i[:-3] for i in os.listdir("tests") if i[-3:] == ".py"] - test_files.remove('__init__') + if path: + folder = "tests/"+path + else: + folder = "tests" + return sorted( + i[0]+"/"+x[:-3] for i in os.walk(folder) for x in i[2] if + x[0]!="_" and "/_" not in i[0] and x[-3:]==".py" + ) + + +def main(): + args = docopt(__doc__) + traceback_info = [] + test_files = get_test_files(args['']) network = Network() if args['--always-transact']: From b9f4f45f2836d1d6900b96ea851b9b02d359ee5b Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Fri, 1 Mar 2019 16:38:22 +0200 Subject: [PATCH 03/21] when tx reverts and no source mapped to opcode, show most recent source available --- lib/components/transaction.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/lib/components/transaction.py b/lib/components/transaction.py index 9cccb1c27..8e3bcd6e4 100644 --- a/lib/components/transaction.py +++ b/lib/components/transaction.py @@ -383,21 +383,34 @@ def call_trace(self): def error(self, pad=3): '''Displays the source code that caused the transaction to revert.''' try: - trace = next(i for i in self.trace if i['op'] in ("REVERT", "INVALID")) + idx = self.trace.index(next(i for i in self.trace if i['op'] in ("REVERT", "INVALID"))) except StopIteration: return "" - span = (trace['source']['start'], trace['source']['stop']) - source = open(trace['source']['filename'], encoding="utf-8").read() - newlines = [i for i in range(len(source)) if source[i] == "\n"] - start = newlines.index(next(i for i in newlines if i >= span[0])) - stop = newlines.index(next(i for i in newlines if i >= span[1])) + while True: + if idx == -1: + return "" + if not self.trace[idx]['source']: + idx -= 1 + continue + span = (self.trace[idx]['source']['start'], self.trace[idx]['source']['stop']) + source = open(self.trace[idx]['source']['filename'], encoding="utf-8").read() + if source[span[0]][:8] in ("contract","library "): + idx-=1 + continue + newlines = [i for i in range(len(source)) if source[i] == "\n"] + try: + start = newlines.index(next(i for i in newlines if i >= span[0])) + stop = newlines.index(next(i for i in newlines if i >= span[1])) + break + except StopIteration: + idx -= 1 ln = start + 1 start = newlines[max(start-(pad+1), 0)] stop = newlines[min(stop+pad, len(newlines)-1)] result = (( '{0[dull]}File {0[string]}"{1}"{0[dull]}, ' + 'line {0[value]}{2}{0[dull]}, in {0[callable]}{3}' - ).format(color, trace['source']['filename'], ln, trace['fn'])) + ).format(color, self.trace[idx]['source']['filename'], ln, self.trace[idx]['fn'])) result += ("{0[dull]}{1}{0}{2}{0[dull]}{3}{0}".format( color, source[start:span[0]], From daa3c04cc115ec587344308d78311c5b5aab0d45 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Fri, 1 Mar 2019 16:39:41 +0200 Subject: [PATCH 04/21] only run specific tests within a file --- CHANGELOG | 1 + lib/coverage.py | 22 ++++++++++++++++++---- lib/test.py | 31 ++++++++++++++++++++++++------- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 76378e5c7..70f391c04 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,7 @@ - check.true and check.false require booleans to pass - Allow subfolders within tests/ + - Only run specific tests within a file 0.9.4 ----- diff --git a/lib/coverage.py b/lib/coverage.py index 88c77764d..a8e7736a0 100644 --- a/lib/coverage.py +++ b/lib/coverage.py @@ -19,10 +19,11 @@ (1, "bright green") ] -__doc__ = """Usage: brownie coverage [] [options] +__doc__ = """Usage: brownie coverage [] [] [options] Arguments: - Only run tests from a specific file + Only run tests from a specific file or folder + Number or range of tests to run from file Options: --help Display this message @@ -38,7 +39,20 @@ def main(): args = docopt(__doc__) test_files = get_test_files(args['']) - + if len(test_files)==1 and args['']: + try: + idx = args[''] + if ':' in idx: + idx = slice(*[int(i)-1 for i in idx.split(':')]) + else: + idx = slice(int(idx)-1,int(idx)) + except: + sys.exit("{0[error]}ERROR{0}: Invalid range. Must be an integer or slice (eg. 1:4)".format(color)) + elif args['']: + sys.exit("{0[error]}ERROR:{0} Cannot specify a range when running multiple tests files.".format(color)) + else: + idx = slice(0, None) + compiled = deepcopy(compile_contracts()) fn_map, line_map = get_coverage_map(compiled) network = Network() @@ -51,7 +65,7 @@ def main(): )) for filename in test_files: - history, tb = run_test(filename, network) + history, tb = run_test(filename, network, idx) if tb: sys.exit( "\n{0[error]}ERROR{0}: Cannot ".format(color) + diff --git a/lib/test.py b/lib/test.py index ee0527572..4b32316cb 100644 --- a/lib/test.py +++ b/lib/test.py @@ -13,10 +13,11 @@ CONFIG = config.CONFIG -__doc__ = """Usage: brownie test [] [options] +__doc__ = """Usage: brownie test [] [] [options] Arguments: Only run tests from a specific file or folder + Number or range of tests to run from file Options: --help Display this message @@ -35,7 +36,7 @@ class ExpectedFailing(Exception): pass def _run_test(module, fn_name, count, total): fn = getattr(module, fn_name) desc = fn.__doc__ or fn_name - sys.stdout.write(" {} ({}/{})... ".format(desc, count, total)) + sys.stdout.write(" {1} - {0} ({1}/{2})... ".format(desc, count, total)) sys.stdout.flush() if fn.__defaults__: args = dict(zip( @@ -55,8 +56,8 @@ def _run_test(module, fn_name, count, total): fn() if 'pending' in args and args['pending']: raise ExpectedFailing("Test was expected to fail") - sys.stdout.write("\r {0[success]}\u2713{0} {1} ({2:.4f}s)\n".format( - color, desc, time.time()-stime + sys.stdout.write("\r {0[success]}\u2713{0} {3} - {1} ({2:.4f}s)\n".format( + color, desc, time.time()-stime, count )) sys.stdout.flush() return [] @@ -82,7 +83,7 @@ def _run_test(module, fn_name, count, total): return [(fn_name, color.format_tb(sys.exc_info(), filename))] -def run_test(filename, network): +def run_test(filename, network, idx): network.reset() if type(CONFIG['test']['gas_limit']) is int: network.gas(CONFIG['test']['gas_limit']) @@ -106,6 +107,7 @@ def run_test(filename, network): if not test_names: print("\n{0[error]}WARNING{0}: No test functions in {0[module]}{1}.py{0}".format(color, name)) return [], [] + print("\nRunning {0[module]}{1}.py{0} - {2} test{3}".format( color, filename, len(test_names)-1,"s" if len(test_names)!=2 else "" )) @@ -115,7 +117,7 @@ def run_test(filename, network): if traceback_info: return tx.tx_history.copy(), traceback_info network.rpc.snapshot() - for c,t in enumerate(test_names, start=1): + for c,t in enumerate(test_names[idx], start=idx.start+1): network.rpc.revert() traceback_info += _run_test(module,t,c,len(test_names)) if sys.argv[1] != "coverage": @@ -151,6 +153,21 @@ def main(): args = docopt(__doc__) traceback_info = [] test_files = get_test_files(args['']) + + if len(test_files)==1 and args['']: + try: + idx = args[''] + if ':' in idx: + idx = slice(*[int(i)-1 for i in idx.split(':')]) + else: + idx = slice(int(idx)-1,int(idx)) + except: + sys.exit("{0[error]}ERROR{0}: Invalid range. Must be an integer or slice (eg. 1:4)".format(color)) + elif args['']: + sys.exit("{0[error]}ERROR:{0} Cannot specify a range when running multiple tests files.".format(color)) + else: + idx = slice(0, None) + network = Network() if args['--always-transact']: @@ -161,7 +178,7 @@ def main(): )) for filename in test_files: - history, tb = run_test(filename, network) + history, tb = run_test(filename, network, idx) if tb: traceback_info += tb if not traceback_info: From 56c7290b2d54fb0153a5fdd1cffe12616a9826d1 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Thu, 21 Mar 2019 10:19:17 +0100 Subject: [PATCH 05/21] only modify TransactionReceipt.trace if needed --- CHANGELOG | 1 + lib/components/transaction.py | 126 +++++++++++++++++----------------- 2 files changed, 65 insertions(+), 62 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 70f391c04..5877c983b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,6 +4,7 @@ - check.true and check.false require booleans to pass - Allow subfolders within tests/ - Only run specific tests within a file + - Only modify transaction stack trace if required (speeds up return_value checks) 0.9.4 ----- diff --git a/lib/components/transaction.py b/lib/components/transaction.py index 8e3bcd6e4..c18f3791a 100644 --- a/lib/components/transaction.py +++ b/lib/components/transaction.py @@ -198,14 +198,15 @@ def __hash__(self): return hash(self.txid) def __getattr__(self, attr): - if attr not in ('events', 'return_value', 'revert_msg', 'trace'): + if attr not in ('events', 'return_value', 'revert_msg', 'trace', '_trace'): raise AttributeError( "'TransactionReceipt' object has no attribute '{}'".format(attr) ) if self.status == -1: return None - self._get_trace() - if self.trace: + if self._trace is None: + self._get_trace() + if attr == "trace": self._evaluate_trace() return self.__dict__[attr] @@ -240,19 +241,13 @@ def info(self): print() def _get_trace(self): - '''Retrieves the stack trace via debug_traceTransaction, and adds the - following attributes to each step: - - address: The address executing this contract. - contractName: The name of the contract. - fn: The name of the function. - source: Start and end offset associated source code. - jumpDepth: Number of jumps made since entering this contract. The - initial value is 1.''' - self.trace = [] + '''Retrieves the stack trace via debug_traceTransaction, and finds the + return value, revert message and event logs in the trace.''' self.return_value = None self.revert_msg = None + self._trace = [] if (self.input == "0x" and self.gas_used == 21000) or self.contract_address: + self.trace = [] return trace = web3.providers[0].make_request( 'debug_traceTransaction', @@ -260,7 +255,59 @@ def _get_trace(self): ) if 'error' in trace: raise ValueError(trace['error']['message']) - self.trace = trace = trace['result']['structLogs'] + self._trace = trace = trace['result']['structLogs'] + if self.status: + # get return value + log = trace[-1] + if log['op'] != "RETURN": + return + c = contract.find_contract(self.receiver or self.contract_address) + if not c: + return + abi = [ + i['type'] for i in + getattr(c, self.fn_name.split('.')[-1]).abi['outputs'] + ] + offset = int(log['stack'][-1], 16) * 2 + length = int(log['stack'][-2], 16) * 2 + data = HexBytes("".join(log['memory'])[offset:offset+length]) + self.return_value = eth_abi.decode_abi(abi, data) + if not self.return_value: + return + if len(self.return_value) == 1: + self.return_value = format_output(self.return_value[0]) + else: + self.return_value = KwargTuple( + self.return_value, + getattr(c, self.fn_name.split('.')[-1]).abi + ) + else: + self.events = [] + # get revert message + memory = trace[-1]['memory'] + try: + # 08c379a0 is the bytes4 signature of Error(string) + idx = memory.index(next(i for i in memory if i[:8] == "08c379a0")) + data = HexBytes("".join(memory[idx:])[8:]+"00000000") + self.revert_msg = eth_abi.decode_abi(["string"], data)[0].decode() + except StopIteration: + pass + try: + # get events from trace + self.events = eth_event.decode_trace(trace, topics()) + except: + pass + + def _evaluate_trace(self): + '''Adds the following attributes to each step of the stack trace: + + address: The address executing this contract. + contractName: The name of the contract. + fn: The name of the function. + source: Start and end offset associated source code. + jumpDepth: Number of jumps made since entering this contract. The + initial value is 1.''' + self.trace = trace = self._trace c = contract.find_contract(self.receiver or self.contract_address) last = {0: { 'address': self.receiver or self.contract_address, @@ -290,7 +337,7 @@ def _get_trace(self): last[trace[i]['depth']] = { 'address': address, 'contract': c._name, - 'fn': [next(k for k, v in c.signatures.items() if v == sig)], + 'fn': [next((k for k, v in c.signatures.items() if v == sig), "")], } trace[i].update({ 'address': last[trace[i]['depth']]['address'], @@ -317,51 +364,6 @@ def _get_trace(self): elif pc['jump'] == "o" and len(last[trace[i]['depth']]['fn']) > 1: last[trace[i]['depth']]['fn'].pop() - def _evaluate_trace(self): - '''Retrieves the return value, revert message and event lots from - a stack trace.''' - if self.status: - # get return value - log = self.trace[-1] - if log['op'] != "RETURN": - return - c = contract.find_contract(self.receiver or self.contract_address) - if not c: - return - abi = [ - i['type'] for i in - getattr(c, self.fn_name.split('.')[-1]).abi['outputs'] - ] - offset = int(log['stack'][-1], 16) * 2 - length = int(log['stack'][-2], 16) * 2 - data = HexBytes("".join(log['memory'])[offset:offset+length]) - self.return_value = eth_abi.decode_abi(abi, data) - if not self.return_value: - return - if len(self.return_value) == 1: - self.return_value = format_output(self.return_value[0]) - else: - self.return_value = KwargTuple( - self.return_value, - getattr(c, self.fn_name.split('.')[-1]).abi - ) - else: - self.events = [] - # get revert message - memory = self.trace[-1]['memory'] - try: - # 08c379a0 is the bytes4 signature of Error(string) - idx = memory.index(next(i for i in memory if i[:8] == "08c379a0")) - data = HexBytes("".join(memory[idx:])[8:]+"00000000") - self.revert_msg = eth_abi.decode_abi(["string"], data)[0].decode() - except StopIteration: - pass - try: - # get events from trace - self.events = eth_event.decode_trace(self.trace, topics()) - except: - pass - def call_trace(self): '''Displays the sequence of contracts and functions called while executing this transaction, and the structLog index where each call @@ -394,8 +396,8 @@ def error(self, pad=3): continue span = (self.trace[idx]['source']['start'], self.trace[idx]['source']['stop']) source = open(self.trace[idx]['source']['filename'], encoding="utf-8").read() - if source[span[0]][:8] in ("contract","library "): - idx-=1 + if source[span[0]][:8] in ("contract", "library "): + idx -= 1 continue newlines = [i for i in range(len(source)) if source[i] == "\n"] try: From 9e8f886515efbaff513037297f2325772c0260fd Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Thu, 28 Mar 2019 05:01:04 +0100 Subject: [PATCH 06/21] bugfix --- lib/components/transaction.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/components/transaction.py b/lib/components/transaction.py index c18f3791a..8cb5b699f 100644 --- a/lib/components/transaction.py +++ b/lib/components/transaction.py @@ -308,6 +308,8 @@ def _evaluate_trace(self): jumpDepth: Number of jumps made since entering this contract. The initial value is 1.''' self.trace = trace = self._trace + if not trace: + return c = contract.find_contract(self.receiver or self.contract_address) last = {0: { 'address': self.receiver or self.contract_address, From 6585d710be18fd6871d3e7337db3155b81f11dcb Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Fri, 29 Mar 2019 03:31:56 +0100 Subject: [PATCH 07/21] bugfix --- lib/components/transaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/components/transaction.py b/lib/components/transaction.py index 8cb5b699f..d6e4a9fc2 100644 --- a/lib/components/transaction.py +++ b/lib/components/transaction.py @@ -393,7 +393,7 @@ def error(self, pad=3): while True: if idx == -1: return "" - if not self.trace[idx]['source']: + if not self.trace[idx]['source']['filename']: idx -= 1 continue span = (self.trace[idx]['source']['start'], self.trace[idx]['source']['stop']) From c1e920fecd09872820d2aa2bfa1c5468edd89b10 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sat, 30 Mar 2019 12:38:40 +0100 Subject: [PATCH 08/21] bugfix - copy tx before modifying prior to transaction --- lib/components/contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/components/contract.py b/lib/components/contract.py index f0469c11e..7dcc29644 100644 --- a/lib/components/contract.py +++ b/lib/components/contract.py @@ -374,7 +374,7 @@ def __call__(self, *args): def _get_tx(owner, args): # seperate contract inputs from tx dict if args and type(args[-1]) is dict: - args, tx = (args[:-1], args[-1]) + args, tx = (args[:-1], args[-1].copy()) if 'from' not in tx: tx['from'] = owner for key in [i for i in ('value', 'gas', 'gasPrice') if i in tx]: From f27aa9c1bbd6bb5d015a31cebeac0986ee6ef8c4 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sun, 31 Mar 2019 03:11:20 +0200 Subject: [PATCH 09/21] update compiler wip --- lib/components/network.py | 11 ++ lib/services/compiler.py | 262 +++++++++++++++++++++++++++----------- 2 files changed, 197 insertions(+), 76 deletions(-) diff --git a/lib/components/network.py b/lib/components/network.py index 44e710d5b..ceef88b38 100644 --- a/lib/components/network.py +++ b/lib/components/network.py @@ -63,6 +63,7 @@ def setup(self): 'accounts': accounts, 'alert': alert, 'check': check, + 'compile_source': self.compile_source, 'config': CONFIG, 'gas': gas, 'history': tx.tx_history, @@ -193,6 +194,16 @@ def reset(self, network=None): self._network_dict['rpc']._kill() self.setup() return "Brownie environment is ready." + + def compile_source(self, source): + for name, build in compiler.compile_source(source).items(): + if build['type'] == "interface": + continue + if name in self._network_dict: + raise AttributeError("Namespace collision between Contract '{0}' and 'Network.{0}'".format(name)) + self._module.__dict__[name] = contract.ContractContainer(build, self._network_dict) + + def logging(**kwargs): '''Adjusts the logging verbosity. diff --git a/lib/services/compiler.py b/lib/services/compiler.py index 9372eb20a..1700387a2 100644 --- a/lib/services/compiler.py +++ b/lib/services/compiler.py @@ -15,6 +15,26 @@ _changed = {} _contracts = {} +STANDARD_JSON = { + 'language': "Solidity", + 'sources': {}, + 'settings': { + 'outputSelection': {'*': { + '*': [ + "abi", + "evm.assembly", + "evm.bytecode", + "evm.deployedBytecode" + ], + '': ["ast"] + }}, + "optimizer": { + "enabled": CONFIG['solc']['optimize'], + "runs": CONFIG['solc']['runs'] + } + } +} + def _check_changed(filename, contract, clear=None): if contract in _changed: @@ -119,7 +139,7 @@ def compile_contracts(folder = "contracts"): (k, x) for k, v in inheritance_map.copy().items() if v for x in v ]: inheritance_map[base] |= inheritance_map[inherited] - + to_compile = [] for filename in contract_files: code = open(filename).read() input_json = {} @@ -138,85 +158,175 @@ def compile_contracts(folder = "contracts"): CONFIG['solc']['optimize'] else "Disabled" )) msg = True - input_json = { - 'language': "Solidity", - 'sources': { - filename: { - 'content': open(filename, encoding="utf-8").read() - } - }, - 'settings': { - 'outputSelection': {'*': { - '*': [ - "abi", - "evm.assembly", - "evm.bytecode", - "evm.deployedBytecode" - ], - '': ["ast"]}}, - "optimizer": { - "enabled": CONFIG['solc']['optimize'], - "runs": CONFIG['solc']['runs']} - } - } + print(" - {}...".format(filename.split('/')[-1])) + to_compile.append(filename) + # input_json = { + # 'language': "Solidity", + # 'sources': { + # filename: { + # 'content': open(filename, encoding="utf-8").read() + # } + # }, + # 'settings': { + # 'outputSelection': {'*': { + # '*': [ + # "abi", + # "evm.assembly", + # "evm.bytecode", + # "evm.deployedBytecode" + # ], + # '': ["ast"]}}, + # "optimizer": { + # "enabled": CONFIG['solc']['optimize'], + # "runs": CONFIG['solc']['runs']} + # } + # } break - if not input_json: - continue - print(" - {}...".format(filename.split('/')[-1])) - try: - compiled = solcx.compile_standard( - input_json, - optimize=CONFIG['solc']['optimize'], - optimize_runs=CONFIG['solc']['runs'], - allow_paths="." + #if not input_json: + # continue + #print(" - {}...".format(filename.split('/')[-1])) + if not to_compile: + return _contracts + input_json = STANDARD_JSON.copy() + input_json['sources'] = dict((i,{'content':open(i).read()}) for i in to_compile) + try: + compiled = solcx.compile_standard( + input_json, + optimize=CONFIG['solc']['optimize'], + optimize_runs=CONFIG['solc']['runs'], + allow_paths="." + ) + except solcx.exceptions.SolcError as e: + err = json.loads(e.stdout_data) + print("\nUnable to compile {}:\n".format(filename)) + for i in err['errors']: + print(i['formattedMessage']) + sys.exit(1) +# print(compiled) + print(compiled['contracts'].keys()) + + # TODO + # iterate compiled['contracts'] and save + # integrate compile_contracts with compile_source + # expose in console + # document + + sys.exit(1) + hash_ = sha1(open(filename, 'rb').read()).hexdigest() + compiled = generate_pcMap(compiled) + for match in ( + re.findall("\n(?:contract|library|interface) [^ {]{1,}", code) + ): + type_, name = match.strip('\n').split(' ') + data = compiled['contracts'][filename][name] + json_file = "build/contracts/{}.json".format(name) + evm = data['evm'] + ref = [(k, x) for v in evm['bytecode']['linkReferences'].values() + for k, x in v.items()] + for n, loc in [(i[0],x['start']*2) for i in ref for x in i[1]]: + evm['bytecode']['object'] = "{}__{:_<36}__{}".format( + evm['bytecode']['object'][:loc], + n[:36], + evm['bytecode']['object'][loc+40:] ) - except solcx.exceptions.SolcError as e: - err = json.loads(e.stdout_data) - print("\nUnable to compile {}:\n".format(filename)) - for i in err['errors']: - print(i['formattedMessage']) - sys.exit(1) - hash_ = sha1(open(filename, 'rb').read()).hexdigest() - compiled = generate_pcMap(compiled) - for match in ( - re.findall("\n(?:contract|library|interface) [^ {]{1,}", code) - ): - type_, name = match.strip('\n').split(' ') - data = compiled['contracts'][filename][name] - json_file = "build/contracts/{}.json".format(name) - evm = data['evm'] - ref = [(k, x) for v in evm['bytecode']['linkReferences'].values() - for k, x in v.items()] - for n, loc in [(i[0],x['start']*2) for i in ref for x in i[1]]: - evm['bytecode']['object'] = "{}__{:_<36}__{}".format( - evm['bytecode']['object'][:loc], - n[:36], - evm['bytecode']['object'][loc+40:] - ) - _contracts[name] = { - 'abi': data['abi'], - 'ast': compiled['sources'][filename]['ast'], - 'bytecode': evm['bytecode']['object'], - 'compiler': compiler_info, - 'contractName': name, - 'deployedBytecode': evm['deployedBytecode']['object'], - 'deployedSourceMap': evm['deployedBytecode']['sourceMap'], - 'networks': {}, - 'opcodes': evm['deployedBytecode']['opcodes'], - 'sha1': hash_, - 'source': input_json['sources'][filename]['content'], - 'sourceMap': evm['bytecode']['sourceMap'], - 'sourcePath': filename, - 'type': type_, - 'pcMap': evm['deployedBytecode']['pcMap'] + _contracts[name] = { + 'abi': data['abi'], + 'ast': compiled['sources'][filename]['ast'], + 'bytecode': evm['bytecode']['object'], + 'compiler': compiler_info, + 'contractName': name, + 'deployedBytecode': evm['deployedBytecode']['object'], + 'deployedSourceMap': evm['deployedBytecode']['sourceMap'], + 'networks': {}, + 'opcodes': evm['deployedBytecode']['opcodes'], + 'sha1': hash_, + 'source': input_json['sources'][filename]['content'], + 'sourceMap': evm['bytecode']['sourceMap'], + 'sourcePath': filename, + 'type': type_, + 'pcMap': evm['deployedBytecode']['pcMap'] + } + json.dump( + _contracts[name], + open(json_file, 'w', encoding="utf-8"), + sort_keys=True, + indent=4 + ) + return _contracts + + +def compile_source(source, filename=""): + input_json = { + 'language': "Solidity", + 'sources': { + filename: { + 'content': source } - json.dump( - _contracts[name], - open(json_file, 'w', encoding="utf-8"), - sort_keys=True, - indent=4 + }, + 'settings': { + 'outputSelection': {'*': { + '*': [ + "abi", + "evm.assembly", + "evm.bytecode", + "evm.deployedBytecode" + ], + '': ["ast"]}}, + "optimizer": { + "enabled": CONFIG['solc']['optimize'], + "runs": CONFIG['solc']['runs']} + } + } + try: + compiled = solcx.compile_standard( + input_json, + optimize=CONFIG['solc']['optimize'], + optimize_runs=CONFIG['solc']['runs'], + allow_paths="." + ) + except solcx.exceptions.SolcError as e: + err = json.loads(e.stdout_data) + print("\nUnable to compile {}:\n".format(filename)) + for i in err['errors']: + print(i['formattedMessage']) + return {} + compiled = generate_pcMap(compiled) + result = {} + compiler_info = CONFIG['solc'].copy() + compiler_info['version'] = solcx.get_solc_version_string().strip('\n') + for match in ( + re.findall("\n(?:contract|library|interface) [^ {]{1,}", source) + ): + type_, name = match.strip('\n').split(' ') + data = compiled['contracts'][filename][name] + json_file = "build/contracts/{}.json".format(name) + evm = data['evm'] + ref = [(k, x) for v in evm['bytecode']['linkReferences'].values() + for k, x in v.items()] + for n, loc in [(i[0],x['start']*2) for i in ref for x in i[1]]: + evm['bytecode']['object'] = "{}__{:_<36}__{}".format( + evm['bytecode']['object'][:loc], + n[:36], + evm['bytecode']['object'][loc+40:] ) - return _contracts + result[name] = { + 'abi': data['abi'], + 'ast': compiled['sources'][filename]['ast'], + 'bytecode': evm['bytecode']['object'], + 'compiler': compiler_info, + 'contractName': name, + 'deployedBytecode': evm['deployedBytecode']['object'], + 'deployedSourceMap': evm['deployedBytecode']['sourceMap'], + 'networks': {}, + 'opcodes': evm['deployedBytecode']['opcodes'], + 'sha1': sha1(source.encode()).hexdigest(), + 'source': input_json['sources'][filename]['content'], + 'sourceMap': evm['bytecode']['sourceMap'], + 'sourcePath': filename, + 'type': type_, + 'pcMap': evm['deployedBytecode']['pcMap'] + } + return result def generate_pcMap(compiled): From f0c3f85fd3269904a118be6c2dcf87009010fa98 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sun, 31 Mar 2019 10:40:45 +0200 Subject: [PATCH 10/21] compiler refactor --- CHANGELOG | 4 +- lib/services/compiler.py | 231 ++++++++++++--------------------------- 2 files changed, 74 insertions(+), 161 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 5877c983b..819632ad2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,7 +4,9 @@ - check.true and check.false require booleans to pass - Allow subfolders within tests/ - Only run specific tests within a file - - Only modify transaction stack trace if required (speeds up return_value checks) + - More efficient transaction stack trace analysis + - Improvements to compiler efficiency and functionality + - Bugfixes 0.9.4 ----- diff --git a/lib/services/compiler.py b/lib/services/compiler.py index 1700387a2..49866f156 100644 --- a/lib/services/compiler.py +++ b/lib/services/compiler.py @@ -36,6 +36,13 @@ } +class CompilerError(Exception): + + def __init__(self, e): + err = [i['formattedMessage'] for i in json.loads(e.stdout_data)['errors']] + super().__init__("Compiler returned the following errors:\n\n"+"\n".join(err)) + + def _check_changed(filename, contract, clear=None): if contract in _changed: return _changed[contract] @@ -62,13 +69,14 @@ def _check_changed(filename, contract, clear=None): def _json_load(filename): try: return json.load(open("build/contracts/"+filename, encoding="utf-8")) - except json.JSONDecodeError: + except json.JSONDecodeError: raise OSError( "'build/contracts/"+filename+"' appears to be corrupted. Delete it" " and restart Brownie to fix this error. If this problem persists " "you may need to delete your entire build/contracts folder." ) + def clear_persistence(network_name): for filename in os.listdir("build/contracts"): compiled = _json_load(filename) @@ -103,7 +111,7 @@ def add_contract(name, address, txid, owner): ) -def compile_contracts(folder = "contracts"): +def compile_contracts(folder="contracts"): ''' Compiles the project with solc and saves the results in the build/contracts folder. @@ -116,7 +124,6 @@ def compile_contracts(folder = "contracts"): ] if not contract_files: sys.exit("ERROR: Cannot find any .sol files in contracts folder") - msg = False compiler_info = CONFIG['solc'].copy() compiler_info['version'] = solcx.get_solc_version_string().strip('\n') @@ -151,132 +158,37 @@ def compile_contracts(folder = "contracts"): if not check and not _check_changed(filename, name): _contracts[name] = _json_load(name+".json") continue - if not msg: - print("Compiling contracts...") - print("Optimizer: {}".format( - "Enabled Runs: "+str(CONFIG['solc']['runs']) if - CONFIG['solc']['optimize'] else "Disabled" - )) - msg = True - print(" - {}...".format(filename.split('/')[-1])) to_compile.append(filename) - # input_json = { - # 'language': "Solidity", - # 'sources': { - # filename: { - # 'content': open(filename, encoding="utf-8").read() - # } - # }, - # 'settings': { - # 'outputSelection': {'*': { - # '*': [ - # "abi", - # "evm.assembly", - # "evm.bytecode", - # "evm.deployedBytecode" - # ], - # '': ["ast"]}}, - # "optimizer": { - # "enabled": CONFIG['solc']['optimize'], - # "runs": CONFIG['solc']['runs']} - # } - # } break - #if not input_json: - # continue - #print(" - {}...".format(filename.split('/')[-1])) if not to_compile: return _contracts + print("Compiling contracts...") + print("Optimizer: {}".format( + "Enabled Runs: "+str(CONFIG['solc']['runs']) if + CONFIG['solc']['optimize'] else "Disabled" + )) + print("\n".join(" - {}...".format(i.split('/')[-1]) for i in to_compile)) input_json = STANDARD_JSON.copy() - input_json['sources'] = dict((i,{'content':open(i).read()}) for i in to_compile) - try: - compiled = solcx.compile_standard( - input_json, - optimize=CONFIG['solc']['optimize'], - optimize_runs=CONFIG['solc']['runs'], - allow_paths="." - ) - except solcx.exceptions.SolcError as e: - err = json.loads(e.stdout_data) - print("\nUnable to compile {}:\n".format(filename)) - for i in err['errors']: - print(i['formattedMessage']) - sys.exit(1) -# print(compiled) - print(compiled['contracts'].keys()) - - # TODO - # iterate compiled['contracts'] and save - # integrate compile_contracts with compile_source - # expose in console - # document - - sys.exit(1) - hash_ = sha1(open(filename, 'rb').read()).hexdigest() - compiled = generate_pcMap(compiled) - for match in ( - re.findall("\n(?:contract|library|interface) [^ {]{1,}", code) - ): - type_, name = match.strip('\n').split(' ') - data = compiled['contracts'][filename][name] - json_file = "build/contracts/{}.json".format(name) - evm = data['evm'] - ref = [(k, x) for v in evm['bytecode']['linkReferences'].values() - for k, x in v.items()] - for n, loc in [(i[0],x['start']*2) for i in ref for x in i[1]]: - evm['bytecode']['object'] = "{}__{:_<36}__{}".format( - evm['bytecode']['object'][:loc], - n[:36], - evm['bytecode']['object'][loc+40:] - ) - _contracts[name] = { - 'abi': data['abi'], - 'ast': compiled['sources'][filename]['ast'], - 'bytecode': evm['bytecode']['object'], - 'compiler': compiler_info, - 'contractName': name, - 'deployedBytecode': evm['deployedBytecode']['object'], - 'deployedSourceMap': evm['deployedBytecode']['sourceMap'], - 'networks': {}, - 'opcodes': evm['deployedBytecode']['opcodes'], - 'sha1': hash_, - 'source': input_json['sources'][filename]['content'], - 'sourceMap': evm['bytecode']['sourceMap'], - 'sourcePath': filename, - 'type': type_, - 'pcMap': evm['deployedBytecode']['pcMap'] - } + input_json['sources'] = dict((i, {'content': open(i).read()}) for i in to_compile) + build_json = _compile_and_format(input_json) + for name, data in build_json.items(): json.dump( - _contracts[name], - open(json_file, 'w', encoding="utf-8"), + data, + open("build/contracts/{}.json".format(name), 'w'), sort_keys=True, indent=4 ) + _contracts.update(build_json) return _contracts -def compile_source(source, filename=""): - input_json = { - 'language': "Solidity", - 'sources': { - filename: { - 'content': source - } - }, - 'settings': { - 'outputSelection': {'*': { - '*': [ - "abi", - "evm.assembly", - "evm.bytecode", - "evm.deployedBytecode" - ], - '': ["ast"]}}, - "optimizer": { - "enabled": CONFIG['solc']['optimize'], - "runs": CONFIG['solc']['runs']} - } - } +def compile_source(source): + input_json = STANDARD_JSON.copy() + input_json['sources'] = {"": {'content': source}} + return _compile_and_format(input_json) + + +def _compile_and_format(input_json): try: compiled = solcx.compile_standard( input_json, @@ -285,47 +197,46 @@ def compile_source(source, filename=""): allow_paths="." ) except solcx.exceptions.SolcError as e: - err = json.loads(e.stdout_data) - print("\nUnable to compile {}:\n".format(filename)) - for i in err['errors']: - print(i['formattedMessage']) - return {} + raise CompilerError(e) compiled = generate_pcMap(compiled) result = {} compiler_info = CONFIG['solc'].copy() compiler_info['version'] = solcx.get_solc_version_string().strip('\n') - for match in ( - re.findall("\n(?:contract|library|interface) [^ {]{1,}", source) - ): - type_, name = match.strip('\n').split(' ') - data = compiled['contracts'][filename][name] - json_file = "build/contracts/{}.json".format(name) - evm = data['evm'] - ref = [(k, x) for v in evm['bytecode']['linkReferences'].values() - for k, x in v.items()] - for n, loc in [(i[0],x['start']*2) for i in ref for x in i[1]]: - evm['bytecode']['object'] = "{}__{:_<36}__{}".format( - evm['bytecode']['object'][:loc], - n[:36], - evm['bytecode']['object'][loc+40:] - ) - result[name] = { - 'abi': data['abi'], - 'ast': compiled['sources'][filename]['ast'], - 'bytecode': evm['bytecode']['object'], - 'compiler': compiler_info, - 'contractName': name, - 'deployedBytecode': evm['deployedBytecode']['object'], - 'deployedSourceMap': evm['deployedBytecode']['sourceMap'], - 'networks': {}, - 'opcodes': evm['deployedBytecode']['opcodes'], - 'sha1': sha1(source.encode()).hexdigest(), - 'source': input_json['sources'][filename]['content'], - 'sourceMap': evm['bytecode']['sourceMap'], - 'sourcePath': filename, - 'type': type_, - 'pcMap': evm['deployedBytecode']['pcMap'] - } + for filename in input_json['sources']: + for match in re.findall( + "\n(?:contract|library|interface) [^ {]{1,}", + input_json['sources'][filename]['content'] + ): + type_, name = match.strip('\n').split(' ') + data = compiled['contracts'][filename][name] + evm = data['evm'] + ref = [ + (k, x) for v in evm['bytecode']['linkReferences'].values() + for k, x in v.items() + ] + for n, loc in [(i[0], x['start']*2) for i in ref for x in i[1]]: + evm['bytecode']['object'] = "{}__{:_<36}__{}".format( + evm['bytecode']['object'][:loc], + n[:36], + evm['bytecode']['object'][loc+40:] + ) + result[name] = { + 'abi': data['abi'], + 'ast': compiled['sources'][filename]['ast'], + 'bytecode': evm['bytecode']['object'], + 'compiler': compiler_info, + 'contractName': name, + 'deployedBytecode': evm['deployedBytecode']['object'], + 'deployedSourceMap': evm['deployedBytecode']['sourceMap'], + 'networks': {}, + 'opcodes': evm['deployedBytecode']['opcodes'], + 'sha1': sha1(input_json['sources'][filename]['content'].encode()).hexdigest(), + 'source': input_json['sources'][filename]['content'], + 'sourceMap': evm['bytecode']['sourceMap'], + 'sourcePath': filename, + 'type': type_, + 'pcMap': evm['deployedBytecode']['pcMap'] + } return result @@ -342,10 +253,10 @@ def generate_pcMap(compiled): 'value': value of the instruction, if any }, ... ] ''' - id_map = dict((v['id'],k) for k,v in compiled['sources'].items()) - for filename, name in [(k,x) for k,v in compiled['contracts'].items() for x in v]: + id_map = dict((v['id'], k) for k, v in compiled['sources'].items()) + for filename, name in [(k, x) for k, v in compiled['contracts'].items() for x in v]: bytecode = compiled['contracts'][filename][name]['evm']['deployedBytecode'] - + if not bytecode['object']: bytecode['pcMap'] = [] continue @@ -357,7 +268,7 @@ def generate_pcMap(compiled): i = opcodes[:-1].rindex(' STOP') except ValueError: break - if 'JUMPDEST' in opcodes[i:]: + if 'JUMPDEST' in opcodes[i:]: break opcodes = opcodes[:i+5] opcodes = opcodes.split(" ")[::-1] @@ -388,11 +299,11 @@ def generate_pcMap(compiled): 'start': last[0], 'stop': last[0]+last[1], 'op': opcodes.pop(), - 'contract': id_map[last[2]] if last[2]!=-1 else False, + 'contract': id_map[last[2]] if last[2] != -1 else False, 'jump': last[3], 'pc': pc }) if opcodes[-1][:2] == "0x": pcMap[-1]['value'] = opcodes.pop() bytecode['pcMap'] = pcMap - return compiled \ No newline at end of file + return compiled From f63a0d3016ea2a1eabdc83f28a61e240c54f38d9 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sun, 31 Mar 2019 10:59:19 +0200 Subject: [PATCH 11/21] docs --- docs/api.rst | 25 ++++++++++++++++++++++++- lib/components/network.py | 1 + 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/api.rst b/docs/api.rst index 0a2285455..74c1326b5 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -214,7 +214,30 @@ LocalAccount Contracts ========= -Contract classes are not meant to be instantiated directly. Each ``ContractContainer`` instance is created automatically during when Brownie starts. New ``Contract`` instances are created via methods in the container. + +Contract classes are not meant to be instantiated directly. When launched, Brownie automatically creates ``ContractContainer`` instances from on the files in the ``contracts/`` folder. New ``Contract`` instances are created via methods in the container. + +Temporary contracts used for testing can be created with the ``compile_source`` method. + +.. py:method:: compile_source(source) + + Compiles the given string and creates a ContractContainer instance. + + .. code-block:: python + + >>> compile_source('''pragma solidity 0.4.25; + + contract SimpleTest { + + string public name; + + constructor (string _name) public { + name = _name; + } + }''' + + >>> SimpleTest + [] ContractContainer ----------------- diff --git a/lib/components/network.py b/lib/components/network.py index ceef88b38..42b9e413a 100644 --- a/lib/components/network.py +++ b/lib/components/network.py @@ -196,6 +196,7 @@ def reset(self, network=None): return "Brownie environment is ready." def compile_source(self, source): + '''Compiles the given string and creates ContractContainer instances.''' for name, build in compiler.compile_source(source).items(): if build['type'] == "interface": continue From 974cf96481569f75df0637d005e4b6efd875f377 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sun, 31 Mar 2019 14:47:09 +0200 Subject: [PATCH 12/21] bugfix - type() in console --- lib/console.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/console.py b/lib/console.py index 23de2aec8..8dd7eca79 100644 --- a/lib/console.py +++ b/lib/console.py @@ -83,7 +83,10 @@ def _run(self): elif type(r) is str: print(r) elif hasattr(r, '_console_repr'): - print(r._console_repr()) + try: + print(r._console_repr()) + except TypeError: + print(repr(r)) else: print(repr(r)) except SyntaxError: From 261bc427152621f187a6f159859b35b49c462396 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sun, 31 Mar 2019 14:57:05 +0200 Subject: [PATCH 13/21] modify how compile_source returns ContractContainers --- docs/api.rst | 7 ++++--- lib/components/contract.py | 3 +++ lib/components/network.py | 15 ++++++++++++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 74c1326b5..6b35ae52a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -221,11 +221,11 @@ Temporary contracts used for testing can be created with the ``compile_source`` .. py:method:: compile_source(source) - Compiles the given string and creates a ContractContainer instance. + Compiles the given string and returns a list of ContractContainer instances. .. code-block:: python - >>> compile_source('''pragma solidity 0.4.25; + >>> container = compile_source('''pragma solidity 0.4.25; contract SimpleTest { @@ -236,7 +236,8 @@ Temporary contracts used for testing can be created with the ``compile_source`` } }''' - >>> SimpleTest + [] + >>> container[0] [] ContractContainer diff --git a/lib/components/contract.py b/lib/components/contract.py index 7dcc29644..9005e508d 100644 --- a/lib/components/contract.py +++ b/lib/components/contract.py @@ -91,6 +91,9 @@ def __delitem__(self, key): def __len__(self): return len(deployed_contracts[self._name]) + def __repr__(self): + return "".format(self, color) + def _console_repr(self): return str(list(deployed_contracts[self._name].values())) diff --git a/lib/components/network.py b/lib/components/network.py index 42b9e413a..bb1936414 100644 --- a/lib/components/network.py +++ b/lib/components/network.py @@ -194,15 +194,24 @@ def reset(self, network=None): self._network_dict['rpc']._kill() self.setup() return "Brownie environment is ready." - + def compile_source(self, source): - '''Compiles the given string and creates ContractContainer instances.''' + '''Compiles the given string and returns ContractContainer objects. + + Args: + source: Solidity code to compile + + Returns: a list of ContractContainers''' + result = [] for name, build in compiler.compile_source(source).items(): if build['type'] == "interface": continue if name in self._network_dict: raise AttributeError("Namespace collision between Contract '{0}' and 'Network.{0}'".format(name)) - self._module.__dict__[name] = contract.ContractContainer(build, self._network_dict) + result.append(contract.ContractContainer(build, self._network_dict)) + if not result: + raise TypeError("String does not contain any deployable contracts") + return result From 70b4dfbed5a8a91636b05121c5033f1c3dea8731 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sun, 31 Mar 2019 21:35:00 +0200 Subject: [PATCH 14/21] allow data on account.transfer --- lib/components/account.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/components/account.py b/lib/components/account.py index dba980f21..807e94d05 100644 --- a/lib/components/account.py +++ b/lib/components/account.py @@ -192,7 +192,7 @@ class Account(_AccountBase): def _console_repr(self): return "".format(color, self.address) - def transfer(self, to, amount, gas_limit=None, gas_price=None): + def transfer(self, to, amount, gas_limit=None, gas_price=None, data=''): '''Transfers ether from this account. Args: @@ -209,7 +209,8 @@ def transfer(self, to, amount, gas_limit=None, gas_price=None): 'to': str(to), 'value': wei(amount), 'gasPrice': wei(gas_price) or self._gas_price(), - 'gas': wei(gas_limit) or self._gas_limit(to, amount) + 'gas': wei(gas_limit) or self._gas_limit(to, amount), + 'data': HexBytes(data) }) except ValueError as e: txid = raise_or_return_tx(e) @@ -249,7 +250,7 @@ def __init__(self, address, account, priv_key): def _console_repr(self): return "".format(color, self.address) - def transfer(self, to, amount, gas_limit=None, gas_price=None): + def transfer(self, to, amount, gas_limit=None, gas_price=None, data=''): '''Transfers ether from this account. Args: @@ -268,7 +269,7 @@ def transfer(self, to, amount, gas_limit=None, gas_price=None): 'gas': wei(gas_limit) or self._gas_limit(to, amount), 'to': str(to), 'value': wei(amount), - 'data': "" + 'data': HexBytes(data) }).rawTransaction txid = web3.eth.sendRawTransaction(signed_tx) except ValueError as e: From 78e25faab63c1e9575d026c81e59dbd970027364 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sun, 31 Mar 2019 21:35:36 +0200 Subject: [PATCH 15/21] set receiver and contract_address as Contract when possible --- lib/components/transaction.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/components/transaction.py b/lib/components/transaction.py index d6e4a9fc2..34ce9d63d 100644 --- a/lib/components/transaction.py +++ b/lib/components/transaction.py @@ -151,6 +151,13 @@ def _await_confirm(self, silent, callback): 'input': tx['input'], 'nonce': tx['nonce'], }) + if tx['to'] and contract.find_contract(tx['to']) is not None: + self.receiver = contract.find_contract(tx['to']) + if not self.fn_name: + self.fn_name = "{}.{}".format( + self.receiver._name, + self.receiver.get_method(tx['input']) + ) if not tx['blockNumber'] and CONFIG['logging']['tx'] and not silent: print("Waiting for confirmation...") receipt = web3.eth.waitForTransactionReceipt(self.txid, None) @@ -261,8 +268,8 @@ def _get_trace(self): log = trace[-1] if log['op'] != "RETURN": return - c = contract.find_contract(self.receiver or self.contract_address) - if not c: + c = self.contract_address or self.receiver + if type(c) is str: return abi = [ i['type'] for i in @@ -310,9 +317,9 @@ def _evaluate_trace(self): self.trace = trace = self._trace if not trace: return - c = contract.find_contract(self.receiver or self.contract_address) + c = self.contract_address or self.receiver last = {0: { - 'address': self.receiver or self.contract_address, + 'address': c.address, 'contract': c._name, 'fn': [self.fn_name.split('.')[-1]], }} From cc791f4db1da867f1a4bd50d725b26b7b510f15f Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sun, 31 Mar 2019 21:36:14 +0200 Subject: [PATCH 16/21] add get_method and encode_abi --- lib/components/contract.py | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/components/contract.py b/lib/components/contract.py index 9005e508d..4df5c669c 100644 --- a/lib/components/contract.py +++ b/lib/components/contract.py @@ -2,6 +2,7 @@ from collections import OrderedDict import eth_event +import eth_abi import re from lib.services.datatypes import KwargTuple, format_output @@ -17,7 +18,7 @@ def find_contract(address): - address = web3.toChecksumAddress(address) + address = web3.toChecksumAddress(str(address)) contracts = [x for v in deployed_contracts.values() for x in v.values()] return next((i for i in contracts if i == address), False) @@ -41,6 +42,12 @@ def __init__(self, build): )).hex()[:10] ) for i in self.abi if i['type'] == "function") + def get_method(self, calldata): + return next( + (k for k, v in self.signatures.items() if v == calldata[:10].lower()), + None + ) + class ContractContainer(_ContractBase): @@ -113,7 +120,7 @@ def at(self, address, owner=None, tx=None): address: Address string of the contract. owner: Default Account instance to send contract transactions from. tx: Transaction ID of the contract creation.''' - address = web3.toChecksumAddress(address) + address = web3.toChecksumAddress(str(address)) if address in deployed_contracts[self._name]: return deployed_contracts[self._name][address] contract = find_contract(address) @@ -189,18 +196,19 @@ def __call__(self, account, *args): [i['type'] for i in self.abi[0]['inputs']] if self.abi else [] ), tx, - self._name, + self._name+".constructor", self._callback ) if tx.status == 1: - return self._parent.at(tx.contract_address) + tx.contract_address = self._parent.at(tx.contract_address) + return tx.contract_address return tx def _callback(self, tx): # ensures the Contract instance is added to the container if the user # presses CTRL-C while deployment is still pending if tx.status == 1: - self._parent.at(tx.contract_address, tx.sender, tx) + tx.contract_address = self._parent.at(tx.contract_address, tx.sender, tx) class Contract(_ContractBase): @@ -296,7 +304,7 @@ def call(self, *args): except ValueError as e: raise VirtualMachineError(e) - if type(result) is not list or len(result)==1: + if type(result) is not list or len(result) == 1: return format_output(result) return KwargTuple(result, self.abi) @@ -322,6 +330,18 @@ def transact(self, *args): self._name ) + def encode_abi(self, *args): + '''Returns encoded ABI data to call the method with the given arguments. + + Args: + *args: Contract method inputs + + Returns: + Hexstring of encoded ABI data.''' + data = self._format_inputs(args) + types = [i['type'] for i in self.abi['inputs']] + return self.signature + eth_abi.encode_abi(types, data).hex() + class ContractTx(_ContractMethod): From 257abecee8644368cbb321f4788a01ac8bc671ed Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sun, 31 Mar 2019 21:48:15 +0200 Subject: [PATCH 17/21] include data when estimating gas --- lib/components/account.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/components/account.py b/lib/components/account.py index 807e94d05..314b64abe 100644 --- a/lib/components/account.py +++ b/lib/components/account.py @@ -168,7 +168,7 @@ def estimate_gas(self, to, amount, data=""): return web3.eth.estimateGas({ 'from': self.address, 'to': str(to), - 'data': data, + 'data': HexBytes(data), 'value': wei(amount) }) @@ -209,7 +209,7 @@ def transfer(self, to, amount, gas_limit=None, gas_price=None, data=''): 'to': str(to), 'value': wei(amount), 'gasPrice': wei(gas_price) or self._gas_price(), - 'gas': wei(gas_limit) or self._gas_limit(to, amount), + 'gas': wei(gas_limit) or self._gas_limit(to, amount, data), 'data': HexBytes(data) }) except ValueError as e: @@ -266,7 +266,7 @@ def transfer(self, to, amount, gas_limit=None, gas_price=None, data=''): 'from': self.address, 'nonce': self.nonce, 'gasPrice': wei(gas_price) or self._gas_price(), - 'gas': wei(gas_limit) or self._gas_limit(to, amount), + 'gas': wei(gas_limit) or self._gas_limit(to, amount, data), 'to': str(to), 'value': wei(amount), 'data': HexBytes(data) From 66e84ea1818467e7a45c3d53b5c7713102170e08 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sun, 31 Mar 2019 21:48:25 +0200 Subject: [PATCH 18/21] update docs --- docs/api.rst | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/api.rst b/docs/api.rst index 6b35ae52a..4f0a900b8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -274,6 +274,8 @@ ContractContainer Attributes A dictionary of bytes4 signatures for each contract method. + If you have a signature and need to find the method name, use ``ContractContainer.get_method``. + .. code-block:: python >>> Token.signatures @@ -381,6 +383,22 @@ ContractContainer Methods >>> Token [] +.. py:classmethod:: ContractContainer.get_method(calldata) + + Given the call data of a transaction, returns the name of the contract method as a string. + + .. code-block:: python + + >>> tx = Token[0].transfer(accounts[1], 1000) + + Transaction sent: 0xc1fe0c7c8fd08736718aa9106662a635102604ea6db4b63a319e43474de0b420 + Token.transfer confirmed - block: 3 gas used: 35985 (26.46%) + + >>> tx.input + 0xa9059cbb00000000000000000000000066ace0365c25329a407002d22908e25adeacb9bb00000000000000000000000000000000000000000000000000000000000003e8 + >>> Token.get_method(tx.input) + transfer + Contract -------- @@ -550,6 +568,20 @@ ContractTx Methods >>> Token[0].transfer.call(accounts[2], 10000, {'from': accounts[0]}) True +.. py:classmethod:: ContractTx.encode_abi(*args) + + Returns a hexstring of ABI calldata, to call the method with the given arguments. + + .. code-block:: python + + >>> calldata = Token[0].transfer.encode_abi(accounts[1], 1000) + 0xa9059cbb0000000000000000000000000d36bdba474b5b442310a5bfb989903020249bba00000000000000000000000000000000000000000000000000000000000003e8 + >>> accounts[0].transfer(Token[0], 0, data=calldata) + + Transaction sent: 0x8dbf15878104571669f9843c18afc40529305ddb842f94522094454dcde22186 + Token.transfer confirmed - block: 2 gas used: 50985 (100.00%) + + Transactions ============ From ac7dbaea1fc155f00b7cb0099ef76dabcfcd3bac Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Sun, 31 Mar 2019 21:50:23 +0200 Subject: [PATCH 19/21] update changelog --- CHANGELOG | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 819632ad2..f012ba94d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,4 @@ -0.9.5 (unreleased) +0.9.5 ----- - check.true and check.false require booleans to pass @@ -6,8 +6,12 @@ - Only run specific tests within a file - More efficient transaction stack trace analysis - Improvements to compiler efficiency and functionality + - account.transfer accepts data + - add ContractTx.encode_abi + - add ContractContainer.get_method - Bugfixes + 0.9.4 ----- From 9978c45d03dbf727f7534dc2c78397861f73eaec Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Mon, 1 Apr 2019 01:13:40 +0300 Subject: [PATCH 20/21] minor bugfixes --- lib/components/contract.py | 2 +- lib/test.py | 40 +++++++++++++++++++------------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/components/contract.py b/lib/components/contract.py index 4df5c669c..0f12cc6ed 100644 --- a/lib/components/contract.py +++ b/lib/components/contract.py @@ -20,7 +20,7 @@ def find_contract(address): address = web3.toChecksumAddress(str(address)) contracts = [x for v in deployed_contracts.values() for x in v.values()] - return next((i for i in contracts if i == address), False) + return next((i for i in contracts if i == address), None) class _ContractBase: diff --git a/lib/test.py b/lib/test.py index 4b32316cb..49dcebd5e 100644 --- a/lib/test.py +++ b/lib/test.py @@ -30,7 +30,8 @@ subfolders. Files and folders beginning with an underscore will be skipped.""" -class ExpectedFailing(Exception): pass +class ExpectedFailing(Exception): + pass def _run_test(module, fn_name, count, total): @@ -63,11 +64,11 @@ def _run_test(module, fn_name, count, total): return [] except Exception as e: if type(e) != ExpectedFailing and 'pending' in args and args['pending']: - c = [color('success'),color('dull'),color()] + c = [color('success'), color('dull'), color()] else: - c = [color('error'),color('dull'),color()] + c = [color('error'), color('dull'), color()] sys.stdout.write("\r {0[0]}{1}{0[1]} {2} ({0[0]}{3}{0[1]}){0[2]}\n".format( - c, + c, '\u2717' if type(e) in ( AssertionError, tx.VirtualMachineError @@ -87,15 +88,15 @@ def run_test(filename, network, idx): network.reset() if type(CONFIG['test']['gas_limit']) is int: network.gas(CONFIG['test']['gas_limit']) - module = importlib.import_module(filename.replace('/','.')) + module = importlib.import_module(filename.replace('/', '.')) test_names = [ - i for i in dir(module) if i not in dir(sys.modules['brownie']) - and i[0]!="_" and callable(getattr(module, i)) + i for i in dir(module) if i not in dir(sys.modules['brownie']) and + i[0] != "_" and callable(getattr(module, i)) ] code = open("{}.py".format(filename), encoding="utf-8").read() test_names = re.findall('(?<=\ndef)[\s]{1,}[^(]*(?=\([^)]*\)[\s]*:)', code) test_names = [i.strip() for i in test_names if i.strip()[0] != "_"] - duplicates = set([i for i in test_names if test_names.count(i)>1]) + duplicates = set([i for i in test_names if test_names.count(i) > 1]) if duplicates: raise ValueError( "tests/{}.py contains multiple tests of the same name: {}".format( @@ -105,11 +106,11 @@ def run_test(filename, network, idx): traceback_info = [] history = set() if not test_names: - print("\n{0[error]}WARNING{0}: No test functions in {0[module]}{1}.py{0}".format(color, name)) + print("\n{0[error]}WARNING{0}: No test functions in {0[module]}{1}.py{0}".format(color, filename)) return [], [] print("\nRunning {0[module]}{1}.py{0} - {2} test{3}".format( - color, filename, len(test_names)-1,"s" if len(test_names)!=2 else "" + color, filename, len(test_names)-1, "s" if len(test_names) != 2 else "" )) if 'setup' in test_names: test_names.remove('setup') @@ -117,9 +118,9 @@ def run_test(filename, network, idx): if traceback_info: return tx.tx_history.copy(), traceback_info network.rpc.snapshot() - for c,t in enumerate(test_names[idx], start=idx.start+1): + for c, t in enumerate(test_names[idx], start=idx.start + 1): network.rpc.revert() - traceback_info += _run_test(module,t,c,len(test_names)) + traceback_info += _run_test(module, t, c, len(test_names)) if sys.argv[1] != "coverage": continue # need to retrieve stack trace before reverting the EVM @@ -144,30 +145,29 @@ def get_test_files(path): folder = "tests" return sorted( i[0]+"/"+x[:-3] for i in os.walk(folder) for x in i[2] if - x[0]!="_" and "/_" not in i[0] and x[-3:]==".py" + x[0] != "_" and "/_" not in i[0] and x[-3:] == ".py" ) - def main(): args = docopt(__doc__) traceback_info = [] test_files = get_test_files(args['']) - - if len(test_files)==1 and args['']: + + if len(test_files) == 1 and args['']: try: idx = args[''] if ':' in idx: idx = slice(*[int(i)-1 for i in idx.split(':')]) else: - idx = slice(int(idx)-1,int(idx)) + idx = slice(int(idx)-1, int(idx)) except: sys.exit("{0[error]}ERROR{0}: Invalid range. Must be an integer or slice (eg. 1:4)".format(color)) elif args['']: sys.exit("{0[error]}ERROR:{0} Cannot specify a range when running multiple tests files.".format(color)) else: idx = slice(0, None) - + network = Network() if args['--always-transact']: @@ -190,8 +190,8 @@ def main(): sys.exit() print("\n{0[error]}WARNING{0}: {1} test{2} failed.{0}".format( - color, len(traceback_info), "s" if len(traceback_info)>1 else "" + color, len(traceback_info), "s" if len(traceback_info) > 1 else "" )) for err in traceback_info: - print("\nException info for {0[0]}:\n{0[1]}".format(err)) \ No newline at end of file + print("\nException info for {0[0]}:\n{0[1]}".format(err)) From d70ae46fa0b4c5a8f91e0c17341830cc01a605a8 Mon Sep 17 00:00:00 2001 From: iamdefinitelyahuman Date: Mon, 1 Apr 2019 19:16:59 +0300 Subject: [PATCH 21/21] bugfix - TransactionReceipt.revert_msg sometimes incorrect --- lib/components/transaction.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/components/transaction.py b/lib/components/transaction.py index 34ce9d63d..0e7d46ef7 100644 --- a/lib/components/transaction.py +++ b/lib/components/transaction.py @@ -289,18 +289,17 @@ def _get_trace(self): getattr(c, self.fn_name.split('.')[-1]).abi ) else: - self.events = [] # get revert message - memory = trace[-1]['memory'] - try: - # 08c379a0 is the bytes4 signature of Error(string) - idx = memory.index(next(i for i in memory if i[:8] == "08c379a0")) - data = HexBytes("".join(memory[idx:])[8:]+"00000000") + offset = int(trace[-1]['stack'][-1], 16) * 2 + length = int(trace[-1]['stack'][-2], 16) * 2 + if length: + data = HexBytes("".join(trace[-1]['memory'])[offset+8:offset+length]) self.revert_msg = eth_abi.decode_abi(["string"], data)[0].decode() - except StopIteration: - pass + else: + self.revert_msg = "" try: # get events from trace + self.events = [] self.events = eth_event.decode_trace(trace, topics()) except: pass