diff --git a/.gitignore b/.gitignore index 827f5025a..06ec5c202 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ __pycache__ .vscode .history -.coverage +.coverage* build/ brownie/data/*/ brownie/data/*.json diff --git a/.travis.yml b/.travis.yml index 92f02c614..bfef7d6a8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ # Based on https://github.com/cclauss/Travis-CI-Python-on-three-OSes matrix: include: - - name: "Standard Tests, Parametrized EVM Tests - Python 3.7.1 on Xenial Linux" + - name: "EVM Tests - Python 3.7 on Xenial Linux" language: python python: 3.7 dist: xenial @@ -13,21 +13,8 @@ matrix: - sudo apt-get install -y python3.7-dev npm solc - npm -g install ganache-cli@6.7.0 - python -m pip install coveralls==1.9.2 tox==3.14.0 - script: tox -e py37,evmtests - - name: "Standard Tests, Linting, Docs - Python 3.6.8 on Xenial Linux" - language: python - python: 3.6 - dist: xenial - sudo: true - install: - - sudo add-apt-repository -y ppa:ethereum/ethereum - - sudo add-apt-repository -y ppa:deadsnakes/ppa - - sudo apt-get update - - sudo apt-get install -y python3.6-dev npm solc - - npm -g install ganache-cli@6.7.0 - - python -m pip install coveralls==1.9.2 tox==3.14.0 - script: tox -e lint,doctest,py36 - - name: "Standard Tests - Python 3.7.4 on Windows" + script: tox -e evmtests + - name: "Standard Tests - Python 3.7 on Windows" os: windows language: node_js node_js: '10' @@ -39,22 +26,22 @@ matrix: - python setup.py install env: PATH=/c/Python37:/c/Python37/Scripts:$PATH script: python -m pytest tests/ --cov=brownie/ -p no:pytest-brownie -n auto --dist=loadscope - - name: "Standard Tests - Python 3.8.0 on Xenial Linux" + - name: "Standard Tests, Linting, Docs - Python 3.6 on Xenial Linux" language: python - python: 3.8 + python: 3.6 dist: xenial sudo: true install: - sudo add-apt-repository -y ppa:ethereum/ethereum - sudo add-apt-repository -y ppa:deadsnakes/ppa - sudo apt-get update - - sudo apt-get install -y python3.8-dev npm solc + - sudo apt-get install -y python3.6-dev npm solc - npm -g install ganache-cli@6.7.0 - python -m pip install coveralls==1.9.2 tox==3.14.0 - script: tox -e py38 - - name: "Brownie Mix Tests - Python 3.6.8 on Xenial Linux" + script: tox -e lint,doctest,py36 + - name: "Standard Tests, Brownie Mix Tests - Python 3.7 on Xenial Linux" language: python - python: 3.6 + python: 3.7 dist: xenial sudo: true install: @@ -64,7 +51,20 @@ matrix: - sudo apt-get install -y python3.6-dev npm solc - npm -g install ganache-cli@6.7.0 - python -m pip install coveralls==1.9.2 tox==3.14.0 - script: tox -e mixtests + script: tox -e py37,mixtests + - name: "Standard Tests - Python 3.8 on Xenial Linux" + language: python + python: 3.8 + dist: xenial + sudo: true + install: + - sudo add-apt-repository -y ppa:ethereum/ethereum + - sudo add-apt-repository -y ppa:deadsnakes/ppa + - sudo apt-get update + - sudo apt-get install -y python3.8-dev npm solc + - npm -g install ganache-cli@6.7.0 + - python -m pip install coveralls==1.9.2 tox==3.14.0 + script: tox -e py38 env: global: COVERALLS_PARALLEL=true diff --git a/CHANGELOG.md b/CHANGELOG.md index db2f01385..a6181ae43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,10 @@ This changelog format is based on [Keep a Changelog](https://keepachangelog.com/ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased](https://github.com/iamdefinitelyahuman/brownie) + +## [1.4.0](https://github.com/iamdefinitelyahuman/brownie/tree/v1.4.0) - 2020-01-07 ### Added +- support for Vyper smart contracts ([v0.1.0-beta15](https://github.com/vyperlang/vyper/releases/tag/v0.1.0-beta.15)) - `brownie accounts` commandline interface ## [1.3.2](https://github.com/iamdefinitelyahuman/brownie/tree/v1.3.2) - 2020-01-01 diff --git a/README.md b/README.md index 6dcc04d83..7802d2b46 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Brownie is a Python-based development and testing framework for smart contracts ## Features +* Full support for [Solidity](https://github.com/ethereum/solidity) (`>=0.4.22`) and [Vyper](https://github.com/vyperlang/vyper) (`0.1.0b-15`) * Contract testing via [pytest](https://github.com/pytest-dev/pytest), including trace-based coverage evaluation * Powerful debugging tools, including python-style tracebacks and custom error strings * Built-in console for quick project interaction diff --git a/brownie/__init__.py b/brownie/__init__.py index e615f2310..b0d918f1e 100644 --- a/brownie/__init__.py +++ b/brownie/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 from brownie._config import CONFIG as config -from brownie.convert import Wei +from brownie.convert import Fixed, Wei from brownie.project import compile_source, run from brownie.network import accounts, alert, history, rpc, web3 from brownie.network.contract import Contract # NOQA: F401 @@ -16,6 +16,7 @@ "project", "compile_source", "run", + "Fixed", "Wei", "config", ] diff --git a/brownie/_cli/__main__.py b/brownie/_cli/__main__.py index e410d8151..1201c6cd0 100644 --- a/brownie/_cli/__main__.py +++ b/brownie/_cli/__main__.py @@ -11,7 +11,7 @@ from brownie.exceptions import ProjectNotFound from brownie.utils import color, notify -__version__ = "1.3.2" +__version__ = "1.4.0" __doc__ = """Usage: brownie [...] [options ] diff --git a/brownie/_gui/source.py b/brownie/_gui/source.py index 68cb80e48..fe1957813 100755 --- a/brownie/_gui/source.py +++ b/brownie/_gui/source.py @@ -23,8 +23,9 @@ def __init__(self, parent): self.root.bind("", self.key_left) self.root.bind("", self.key_right) base_path = self.root.active_project._path.joinpath("contracts") - for path in base_path.glob("**/*.sol"): - self.add(path) + for path in base_path.glob("**/*"): + if path.suffix in (".sol", ".vy"): + self.add(path) self.set_visible([]) def add(self, path): @@ -33,7 +34,7 @@ def add(self, path): if label in [i._label for i in self._frames]: return with path.open() as fp: - frame = SourceFrame(self, fp.read()) + frame = SourceFrame(self, fp.read(), path.suffix) super().add(frame, text=f" {label} ") frame._id = len(self._frames) frame._label = label @@ -168,7 +169,7 @@ def key(k): class SourceFrame(tk.Frame): - def __init__(self, root, text): + def __init__(self, root, text, suffix): super().__init__(root) self._text = tk.Text(self, width=90, yscrollcommand=self._text_scroll) self._scroll = ttk.Scrollbar(self) @@ -183,7 +184,10 @@ def __init__(self, root, text): for k, v in TEXT_COLORS.items(): self._text.tag_config(k, **v) - pattern = r"((?:\s*\/\/[^\n]*)|(?:\/\*[\s\S]*?\*\/))" + if suffix == ".sol": + pattern = r"((?:\s*\/\/[^\n]*)|(?:\/\*[\s\S]*?\*\/))" + else: + pattern = r"((#[^\n]*\n)|(\"\"\"[\s\S]*?\"\"\")|('''[\s\S]*?'''))" for match in re.finditer(pattern, text): self.tag_add("comment", match.start(), match.end()) diff --git a/brownie/convert.py b/brownie/convert.py index d186e095f..7491d653d 100644 --- a/brownie/convert.py +++ b/brownie/convert.py @@ -1,6 +1,7 @@ #!/usr/bin/python3 from copy import deepcopy +from decimal import Decimal from typing import Any, Dict, ItemsView, KeysView, List, Tuple, TypeVar, Union import eth_utils @@ -101,7 +102,7 @@ def _return_int(original: Any, value: Any) -> int: try: return int(value) except ValueError: - raise TypeError(f"Could not convert {type(original)} '{original}' to wei.") + raise TypeError(f"Cannot convert {type(original)} '{original}' to wei.") def to_uint(value: Any, type_: str = "uint256") -> "Wei": @@ -129,6 +130,83 @@ def _check_int_size(type_: Any) -> int: return size +class Fixed(Decimal): + + """ + Decimal subclass that allows comparison against strings, integers and Wei. + + Raises TypeError when operations are attempted against floats. + """ + + # Known typing error: https://github.com/python/mypy/issues/4290 + def __new__(cls, value: Any) -> Any: # type: ignore + return super().__new__(cls, _to_fixed(value)) # type: ignore + + def __repr__(self): + return f"Fixed('{str(self)}')" + + def __hash__(self) -> int: + return super().__hash__() + + def __lt__(self, other: Any) -> bool: # type: ignore + return super().__lt__(_to_fixed(other)) + + def __le__(self, other: Any) -> bool: # type: ignore + return super().__le__(_to_fixed(other)) + + def __eq__(self, other: Any) -> bool: # type: ignore + if isinstance(other, float): + raise TypeError("Cannot compare to floating point - use a string instead") + try: + return super().__eq__(_to_fixed(other)) + except TypeError: + return False + + def __ne__(self, other: Any) -> bool: + if isinstance(other, float): + raise TypeError("Cannot compare to floating point - use a string instead") + try: + return super().__ne__(_to_fixed(other)) + except TypeError: + return True + + def __ge__(self, other: Any) -> bool: # type: ignore + return super().__ge__(_to_fixed(other)) + + def __gt__(self, other: Any) -> bool: # type: ignore + return super().__gt__(_to_fixed(other)) + + def __add__(self, other: Any) -> "Fixed": # type: ignore + return Fixed(super().__add__(_to_fixed(other))) + + def __sub__(self, other: Any) -> "Fixed": # type: ignore + return Fixed(super().__sub__(_to_fixed(other))) + + +def _to_fixed(value: Any) -> Decimal: + if isinstance(value, float): + raise TypeError("Cannot convert float to decimal - use a string instead") + elif isinstance(value, (str, bytes)): + try: + value = Wei(value) + except TypeError: + pass + try: + return Decimal(value) + except Exception: + raise TypeError(f"Cannot convert {type(value)} '{value}' to decimal.") + + +def to_decimal(value: Any) -> Fixed: + """Convert a value to a fixed point decimal""" + d: Fixed = Fixed(value) + if d < -2 ** 127 or d >= 2 ** 127: + raise OverflowError(f"{value} is outside allowable range for decimal") + if d.quantize(Decimal("1.0000000000")) != d: + raise ValueError("Maximum of 10 decimal points allowed") + return d + + class EthAddress(str): """String subclass that raises TypeError when compared to a non-address.""" @@ -313,6 +391,8 @@ def _format_single(type_: str, value: Any) -> Any: return to_uint(value, type_) elif "int" in type_: return to_int(value, type_) + elif type_ == "fixed168x10": + return to_decimal(value) elif type_ == "bool": return to_bool(value) elif type_ == "address": diff --git a/brownie/exceptions.py b/brownie/exceptions.py index c271b4b6e..0a97a4637 100644 --- a/brownie/exceptions.py +++ b/brownie/exceptions.py @@ -117,3 +117,7 @@ class PragmaError(Exception): class InvalidManifest(Exception): pass + + +class UnsupportedLanguage(Exception): + pass diff --git a/brownie/network/contract.py b/brownie/network/contract.py index 211eedbdd..b5be8a2c7 100644 --- a/brownie/network/contract.py +++ b/brownie/network/contract.py @@ -374,7 +374,11 @@ def __init__(self, address: str, abi: Dict, name: str, owner: Optional[AccountsT self.signature = _signature(abi) def __repr__(self) -> str: - pay = "payable " if self.abi["stateMutability"] == "payable" else "" + if "payable" in self.abi: + pay_bool = self.abi["payable"] + else: + pay_bool = self.abi["stateMutability"] == "payable" + pay = "payable " if pay_bool else "" return f"<{type(self).__name__} {pay}object '{self.abi['name']}({_inputs(self.abi)})'>" def call(self, *args: Tuple) -> Any: @@ -515,23 +519,36 @@ def _get_tx(owner: Optional[AccountsType], args: Tuple) -> Tuple: def _get_method_object( address: str, abi: Dict, name: str, owner: Optional[AccountsType] ) -> Union["ContractCall", "ContractTx"]: - if abi["stateMutability"] in ("view", "pure"): + + if "constant" in abi: + constant = abi["constant"] + else: + constant = abi["stateMutability"] in ("view", "pure") + + if constant: return ContractCall(address, abi, name, owner) return ContractTx(address, abi, name, owner) -def _params(abi_params: List) -> List: +def _params(abi_params: List, substitutions: Optional[Dict] = None) -> List: types = [] + if substitutions is None: + substitutions = {} for i in abi_params: if i["type"] != "tuple": - types.append((i["name"], i["type"])) + type_ = i["type"] + for orig, sub in substitutions.items(): + if type_.startswith(orig): + type_ = type_.replace(orig, sub) + types.append((i["name"], type_)) continue - types.append((i["name"], f"({','.join(x[1] for x in _params(i['components']))})")) + params = [i[1] for i in _params(i["components"], substitutions)] + types.append((i["name"], f"({','.join(params)})")) return types def _inputs(abi: Dict) -> str: - params = _params(abi["inputs"]) + params = _params(abi["inputs"], {"fixed168x10": "decimal"}) return ", ".join(f"{i[1]}{' '+i[0] if i[0] else ''}" for i in params) diff --git a/brownie/network/transaction.py b/brownie/network/transaction.py index c8e315c35..25516084d 100644 --- a/brownie/network/transaction.py +++ b/brownie/network/transaction.py @@ -440,7 +440,7 @@ def _expand_trace(self) -> None: continue # calculate coverage - if "active_branches" in last: + if last["coverage"]: if pc["path"] not in coverage_eval[last["name"]]: coverage_eval[last["name"]][pc["path"]] = [set(), set(), set()] if "statement" in pc: @@ -448,11 +448,12 @@ def _expand_trace(self) -> None: if "branch" in pc: if pc["op"] != "JUMPI": last["active_branches"].add(pc["branch"]) - elif pc["branch"] in last["active_branches"]: + elif "active_branches" not in last or pc["branch"] in last["active_branches"]: # false, true key = 1 if trace[i + 1]["pc"] == trace[i]["pc"] + 1 else 2 coverage_eval[last["name"]][pc["path"]][key].add(pc["branch"]) - last["active_branches"].remove(pc["branch"]) + if "active_branches" in last: + last["active_branches"].remove(pc["branch"]) # ignore jumps with no function - they are compiler optimizations if "jump" in pc: @@ -733,7 +734,7 @@ def _raise(msg: str, source: str) -> None: def _get_last_map(address: EthAddress, sig: str) -> Dict: contract = _find_contract(address) - last_map = {"address": EthAddress(address), "jumpDepth": 0, "name": None} + last_map = {"address": EthAddress(address), "jumpDepth": 0, "name": None, "coverage": False} if contract: last_map.update( contract=contract, @@ -741,7 +742,9 @@ def _get_last_map(address: EthAddress, sig: str) -> Dict: fn=[f"{contract._name}.{contract.get_method(sig)}"], ) if contract._build: - last_map.update(pc_map=contract._build["pcMap"], active_branches=set()) + last_map.update(pc_map=contract._build["pcMap"], coverage=True) + if contract._build["language"] == "Solidity": + last_map["active_branches"] = set() else: last_map.update(contract=None, fn=[f".{sig}"]) return last_map diff --git a/brownie/project/build.py b/brownie/project/build.py index 92a4fe93b..8d1995ee0 100644 --- a/brownie/project/build.py +++ b/brownie/project/build.py @@ -16,6 +16,7 @@ "deployedBytecode", "deployedSourceMap", "dependencies", + "language", "offset", "opcodes", "pcMap", @@ -44,14 +45,15 @@ def _add(self, build_json: Dict) -> None: if build_json["compiler"]["minify_source"]: build_json = self.expand_build_offsets(build_json) self._build[contract_name] = build_json - self._generate_revert_map(build_json["pcMap"]) + self._generate_revert_map(build_json["pcMap"], build_json["language"]) - def _generate_revert_map(self, pcMap: Dict) -> None: + def _generate_revert_map(self, pcMap: Dict, language: str) -> None: # Adds a contract's dev revert strings to the revert map and it's pcMap + marker = "//" if language == "Solidity" else "#" for pc, data in ( (k, v) for k, v in pcMap.items() - if v["op"] in {"REVERT", "INVALID"} or "jump_revert" in v + if v["op"] in ("REVERT", "INVALID") or "jump_revert" in v ): if "dev" not in data: if "fn" not in data or "first_revert" in data: @@ -60,7 +62,7 @@ def _generate_revert_map(self, pcMap: Dict) -> None: try: revert_str = self._sources.get(data["path"])[data["offset"][1] :] revert_str = revert_str[: revert_str.index("\n")] - revert_str = revert_str[revert_str.index("//") + 2 :].strip() + revert_str = revert_str[revert_str.index(marker) + len(marker) :].strip() if revert_str.startswith("dev:"): data["dev"] = revert_str except (KeyError, ValueError): diff --git a/brownie/project/compiler/__init__.py b/brownie/project/compiler/__init__.py new file mode 100644 index 000000000..7e633f8b8 --- /dev/null +++ b/brownie/project/compiler/__init__.py @@ -0,0 +1,261 @@ +#!/usr/bin/python3 + +from copy import deepcopy +from typing import Dict, Optional, Union + +from semantic_version import Version + +from brownie.exceptions import UnsupportedLanguage +from brownie.project import sources +from brownie.project.compiler.solidity import ( # NOQA: F401 + find_best_solc_version, + find_solc_versions, + install_solc, + set_solc_version, +) + +from . import solidity, vyper + +STANDARD_JSON: Dict = { + "language": None, + "sources": {}, + "settings": { + "outputSelection": { + "*": {"*": ["abi", "evm.bytecode", "evm.deployedBytecode"], "": ["ast"]} + }, + "evmVersion": None, + "remappings": [], + }, +} +EVM_SOLC_VERSIONS = [ + # ("istanbul", Version("0.5.13")), # TODO enable when ganache istanbul support is out of beta + ("petersburg", Version("0.5.5")), + ("byzantium", Version("0.4.0")), +] + + +def compile_and_format( + contract_sources: Dict[str, str], + solc_version: Optional[str] = None, + optimize: bool = True, + runs: int = 200, + evm_version: int = None, + minify: bool = False, + silent: bool = True, + allow_paths: Optional[str] = None, +) -> Dict: + """Compiles contracts and returns build data. + + Args: + contracts: a dictionary in the form of {'path': "source code"} + solc_version: solc version to compile with (use None to set via pragmas) + optimize: enable solc optimizer + runs: optimizer runs + evm_version: evm version to compile for + minify: minify source files + silent: verbose reporting + allow_paths: compiler allowed filesystem import path + + Returns: + build data dict + """ + if not contract_sources: + return {} + + if [i for i in contract_sources if not i.endswith(".sol") and not i.endswith(".vy")]: + raise UnsupportedLanguage("Source filenames must end in .sol or .vy") + + build_json: Dict = {} + compiler_targets = {} + + vyper_paths = [i for i in contract_sources if i.endswith(".vy")] + if vyper_paths: + compiler_targets["vyper"] = vyper_paths + + solc_sources = dict((k, v) for k, v in contract_sources.items() if k.endswith(".sol")) + if solc_sources: + if solc_version is None: + compiler_targets.update( + find_solc_versions(solc_sources, install_needed=True, silent=silent) + ) + else: + compiler_targets[solc_version] = list(solc_sources) + + compiler_data: Dict = {"minify_source": minify} + for version, path_list in compiler_targets.items(): + if version == "vyper": + language = "Vyper" + compiler_data["version"] = str(vyper.get_version()) + else: + set_solc_version(version) + language = "Solidity" + compiler_data["version"] = str(solidity.get_version()) + + to_compile = dict((k, v) for k, v in contract_sources.items() if k in path_list) + + input_json = generate_input_json(to_compile, optimize, runs, evm_version, minify, language) + output_json = compile_from_input_json(input_json, silent, allow_paths) + build_json.update(generate_build_json(input_json, output_json, compiler_data, silent)) + return build_json + + +def generate_input_json( + contract_sources: Dict[str, str], + optimize: bool = True, + runs: int = 200, + evm_version: Union[int, str, None] = None, + minify: bool = False, + language: str = "Solidity", +) -> Dict: + + """Formats contracts to the standard solc input json. + + Args: + contract_sources: a dictionary in the form of {path: 'source code'} + optimize: enable solc optimizer + runs: optimizer runs + evm_version: evm version to compile for + minify: should source code be minified? + language: source language (Solidity or Vyper) + + Returns: dict + """ + + if language not in ("Solidity", "Vyper"): + raise UnsupportedLanguage(f"{language}") + + if evm_version is None: + if language == "Solidity": + evm_version = next(i[0] for i in EVM_SOLC_VERSIONS if solidity.get_version() >= i[1]) + else: + evm_version = "byzantium" + + input_json: Dict = deepcopy(STANDARD_JSON) + input_json["language"] = language + input_json["settings"]["evmVersion"] = evm_version + if language == "Solidity": + input_json["settings"]["optimizer"] = {"enabled": optimize, "runs": runs if optimize else 0} + input_json["sources"] = dict( + (k, {"content": sources.minify(v, language)[0] if minify else v}) + for k, v in contract_sources.items() + ) + return input_json + + +def compile_from_input_json( + input_json: Dict, silent: bool = True, allow_paths: Optional[str] = None +) -> Dict: + + """ + Compiles contracts from a standard input json. + + Args: + input_json: solc input json + silent: verbose reporting + allow_paths: compiler allowed filesystem import path + + Returns: standard compiler output json + """ + + if input_json["language"] == "Vyper": + return vyper.compile_from_input_json(input_json, silent, allow_paths) + if input_json["language"] == "Solidity": + return solidity.compile_from_input_json(input_json, silent, allow_paths) + raise UnsupportedLanguage(f"{input_json['language']}") + + +def generate_build_json( + input_json: Dict, output_json: Dict, compiler_data: Optional[Dict] = None, silent: bool = True +) -> Dict: + """Formats standard compiler output to the brownie build json. + + Args: + input_json: solc input json used to compile + output_json: output json returned by compiler + compiler_data: additonal data to include under 'compiler' in build json + silent: verbose reporting + + Returns: build json dict""" + + if input_json["language"] not in ("Solidity", "Vyper"): + raise UnsupportedLanguage(f"{input_json['language']}") + + if not silent: + print("Generating build data...") + + if compiler_data is None: + compiler_data = {} + compiler_data["evm_version"] = input_json["settings"]["evmVersion"] + minified = compiler_data.get("minify_source", False) + build_json = {} + path_list = list(input_json["sources"]) + + if input_json["language"] == "Solidity": + compiler_data.update( + { + "optimize": input_json["settings"]["optimizer"]["enabled"], + "runs": input_json["settings"]["optimizer"]["runs"], + } + ) + source_nodes, statement_nodes, branch_nodes = solidity._get_nodes(output_json) + + for path_str, contract_name in [ + (k, v) for k in path_list for v in output_json["contracts"].get(k, {}) + ]: + + if not silent: + print(f" - {contract_name}...") + + abi = output_json["contracts"][path_str][contract_name]["abi"] + output_evm = output_json["contracts"][path_str][contract_name]["evm"] + hash_ = sources.get_hash( + input_json["sources"][path_str]["content"], + contract_name, + minified, + input_json["language"], + ) + + if input_json["language"] == "Solidity": + contract_node = next( + i[contract_name] for i in source_nodes if i.absolutePath == path_str + ) + build_json[contract_name] = solidity._get_unique_build_json( + output_evm, + contract_node, + statement_nodes, + branch_nodes, + next((True for i in abi if i["type"] == "fallback"), False), + ) + + else: + if contract_name == "": + contract_name = "Vyper" + build_json[contract_name] = vyper._get_unique_build_json( + output_evm, + path_str, + contract_name, + output_json["sources"][path_str]["ast"], + (0, len(input_json["sources"][path_str]["content"])), + ) + + build_json[contract_name].update( + { + "abi": abi, + "ast": output_json["sources"][path_str]["ast"], + "compiler": compiler_data, + "contractName": contract_name, + "deployedBytecode": output_evm["deployedBytecode"]["object"], + "deployedSourceMap": output_evm["deployedBytecode"]["sourceMap"], + "language": input_json["language"], + "opcodes": output_evm["deployedBytecode"]["opcodes"], + "sha1": hash_, + "source": input_json["sources"][path_str]["content"], + "sourceMap": output_evm["bytecode"].get("sourceMap", ""), + "sourcePath": path_str, + } + ) + + if not silent: + print("") + + return build_json diff --git a/brownie/project/compiler.py b/brownie/project/compiler/solidity.py similarity index 71% rename from brownie/project/compiler.py rename to brownie/project/compiler/solidity.py index d4c11ae1b..9ba2d7ae5 100644 --- a/brownie/project/compiler.py +++ b/brownie/project/compiler/solidity.py @@ -3,9 +3,8 @@ import logging import re from collections import deque -from copy import deepcopy from hashlib import sha1 -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from typing import Any, Dict, List, Optional, Set, Tuple import solcast import solcx @@ -14,6 +13,7 @@ from solcast.nodes import NodeBase from brownie.exceptions import CompilerError, IncompatibleSolcVersion, PragmaError +from brownie.project.compiler.utils import expand_source_map from . import sources @@ -24,25 +24,53 @@ sh.setFormatter(logging.Formatter("%(message)s")) solcx_logger.addHandler(sh) -STANDARD_JSON = { - "language": "Solidity", - "sources": {}, - "settings": { - "outputSelection": { - "*": {"*": ["abi", "evm.assembly", "evm.bytecode", "evm.deployedBytecode"], "": ["ast"]} - }, - "optimizer": {"enabled": True, "runs": 200}, - "evmVersion": None, - "remappings": [], - }, -} PRAGMA_REGEX = re.compile(r"pragma +solidity([^;]*);") AVAILABLE_SOLC_VERSIONS = None -EVM_SOLC_VERSIONS = [ - # ("istanbul", Version("0.5.13")), # TODO enable when ganache istanbul support is out of beta - ("petersburg", Version("0.5.5")), - ("byzantium", Version("0.4.0")), -] + + +def get_version() -> Version: + return solcx.get_solc_version() + + +def compile_from_input_json( + input_json: Dict, silent: bool = True, allow_paths: Optional[str] = None +) -> Dict: + + """ + Compiles contracts from a standard input json. + + Args: + input_json: solc input json + silent: verbose reporting + allow_paths: compiler allowed filesystem import path + + Returns: standard compiler output json + """ + + optimizer = input_json["settings"]["optimizer"] + input_json["settings"].setdefault("evmVersion", None) + + if not silent: + print("Compiling contracts...") + print(f" Solc version: {str(solcx.get_solc_version())}") + + print( + " Optimizer: " + + (f"Enabled Runs: {optimizer['runs']}" if optimizer["enabled"] else "Disabled") + ) + if input_json["settings"]["evmVersion"]: + print(f" EVM Version: {input_json['settings']['evmVersion'].capitalize()}") + + try: + return solcx.compile_standard( + input_json, + optimize=optimizer["enabled"], + optimize_runs=optimizer["runs"], + evm_version=input_json["settings"]["evmVersion"], + allow_paths=allow_paths, + ) + except solcx.exceptions.SolcError as e: + raise CompilerError(e) def set_solc_version(version: str) -> str: @@ -63,51 +91,6 @@ def install_solc(*versions: str) -> None: solcx.install_solc(str(version), show_progress=True) -def compile_and_format( - contract_sources: Dict[str, str], - solc_version: Optional[str] = None, - optimize: bool = True, - runs: int = 200, - evm_version: int = None, - minify: bool = False, - silent: bool = True, - allow_paths: Optional[str] = None, -) -> Dict: - """Compiles contracts and returns build data. - - Args: - contracts: a dictionary in the form of {'path': "source code"} - solc_version: solc version to compile with (use None to set via pragmas) - optimize: enable solc optimizer - runs: optimizer runs - evm_version: evm version to compile for - minify: minify source files - silent: verbose reporting - allow_paths: compiler allowed filesystem import path - - Returns: - build data dict - """ - if not contract_sources: - return {} - - if solc_version is not None: - path_versions = {solc_version: list(contract_sources)} - else: - path_versions = find_solc_versions(contract_sources, install_needed=True, silent=silent) - - build_json: Dict = {} - for version, path_list in path_versions.items(): - set_solc_version(version) - compiler_data = {"minify_source": minify, "version": str(solcx.get_solc_version())} - to_compile = dict((k, v) for k, v in contract_sources.items() if k in path_list) - - input_json = generate_input_json(to_compile, optimize, runs, evm_version, minify) - output_json = compile_from_input_json(input_json, silent, allow_paths) - build_json.update(generate_build_json(input_json, output_json, compiler_data, silent)) - return build_json - - def find_solc_versions( contract_sources: Dict[str, str], install_needed: bool = False, @@ -158,7 +141,7 @@ def find_solc_versions( if not version or (install_latest and latest > version): to_install.add(latest) - elif latest > version: + elif latest and latest > version: new_versions.add(str(version)) # install new versions if needed @@ -241,172 +224,48 @@ def _get_solc_version_list() -> Tuple[List, List]: return AVAILABLE_SOLC_VERSIONS, installed_versions -def generate_input_json( - contract_sources: Dict[str, str], - optimize: bool = True, - runs: int = 200, - evm_version: Union[int, str, None] = None, - minify: bool = False, -) -> Dict: - """Formats contracts to the standard solc input json. - - Args: - contract_sources: a dictionary in the form of {path: 'source code'} - optimize: enable solc optimizer - runs: optimizer runs - evm_version: evm version to compile for - minify: should source code be minified? - - Returns: dict - """ - if evm_version is None: - evm_version = next(i[0] for i in EVM_SOLC_VERSIONS if solcx.get_solc_version() >= i[1]) - input_json: Dict = deepcopy(STANDARD_JSON) - input_json["settings"]["optimizer"]["enabled"] = optimize - input_json["settings"]["optimizer"]["runs"] = runs if optimize else 0 - input_json["settings"]["evmVersion"] = evm_version - input_json["sources"] = dict( - (k, {"content": sources.minify(v)[0] if minify else v}) for k, v in contract_sources.items() - ) - return input_json - - -def compile_from_input_json( - input_json: Dict, silent: bool = True, allow_paths: Optional[str] = None +def _get_unique_build_json( + output_evm: Dict, contract_node: Any, stmt_nodes: Dict, branch_nodes: Dict, has_fallback: bool ) -> Dict: - - """ - Compiles contracts from a standard input json. - - Args: - input_json: solc input json - silent: verbose reporting - allow_paths: compiler allowed filesystem import path - - Returns: standard compiler output json - """ - - optimizer = input_json["settings"]["optimizer"] - input_json["settings"].setdefault("evmVersion", None) - if not silent: - print("Compiling contracts...") - print(f" Solc version: {str(solcx.get_solc_version())}") - print( - " Optimizer: " - + (f"Enabled Runs: {optimizer['runs']}" if optimizer["enabled"] else "Disabled") - ) - if input_json["settings"]["evmVersion"]: - print(f" EVM Version: {input_json['settings']['evmVersion'].capitalize()}") - try: - return solcx.compile_standard( - input_json, - optimize=optimizer["enabled"], - optimize_runs=optimizer["runs"], - evm_version=input_json["settings"]["evmVersion"], - allow_paths=allow_paths, + paths = sorted( + set( + [contract_node.parent().absolutePath] + + [i.parent().absolutePath for i in contract_node.dependencies] ) - except solcx.exceptions.SolcError as e: - raise CompilerError(e) - - -def generate_build_json( - input_json: Dict, output_json: Dict, compiler_data: Optional[Dict] = None, silent: bool = True -) -> Dict: - """Formats standard compiler output to the brownie build json. - - Args: - input_json: solc input json used to compile - output_json: output json returned by compiler - compiler_data: additonal data to include under 'compiler' in build json - silent: verbose reporting - - Returns: build json dict""" - if not silent: - print("Generating build data...") - - if compiler_data is None: - compiler_data = {} - compiler_data.update( - { - "optimize": input_json["settings"]["optimizer"]["enabled"], - "runs": input_json["settings"]["optimizer"]["runs"], - "evm_version": input_json["settings"]["evmVersion"], - } ) - minified = "minify_source" in compiler_data and compiler_data["minify_source"] - build_json = {} - path_list = list(input_json["sources"]) - - source_nodes = solcast.from_standard_output(output_json) - statement_nodes = _get_statement_nodes(source_nodes) - branch_nodes = _get_branch_nodes(source_nodes) - - for path, contract_name in [(k, v) for k in path_list for v in output_json["contracts"][k]]: - if not silent: - print(f" - {contract_name}...") - - abi = output_json["contracts"][path][contract_name]["abi"] - evm = output_json["contracts"][path][contract_name]["evm"] - bytecode = _format_link_references(evm) - hash_ = sources.get_hash(input_json["sources"][path]["content"], contract_name, minified) - node = next(i[contract_name] for i in source_nodes if i.absolutePath == path) - paths = sorted( - set([node.parent().absolutePath] + [i.parent().absolutePath for i in node.dependencies]) - ) - - pc_map, statement_map, branch_map = _generate_coverage_data( - evm["deployedBytecode"]["sourceMap"], - evm["deployedBytecode"]["opcodes"], - node, - statement_nodes, - branch_nodes, - next((True for i in abi if i["type"] == "fallback"), False), - ) - - build_json[contract_name] = { - "abi": abi, - "allSourcePaths": paths, - "ast": output_json["sources"][path]["ast"], - "bytecode": bytecode, - "bytecodeSha1": _get_bytecode_hash(bytecode), - "compiler": compiler_data, - "contractName": contract_name, - "coverageMap": {"statements": statement_map, "branches": branch_map}, - "deployedBytecode": evm["deployedBytecode"]["object"], - "deployedSourceMap": evm["deployedBytecode"]["sourceMap"], - "dependencies": [i.name for i in node.dependencies], - # 'networks': {}, - "offset": node.offset, - "opcodes": evm["deployedBytecode"]["opcodes"], - "pcMap": pc_map, - "sha1": hash_, - "source": input_json["sources"][path]["content"], - "sourceMap": evm["bytecode"]["sourceMap"], - "sourcePath": path, - "type": node.contractKind, - } - - if not silent: - print("") - - return build_json + bytecode = _format_link_references(output_evm) + pc_map, statement_map, branch_map = _generate_coverage_data( + output_evm["deployedBytecode"]["sourceMap"], + output_evm["deployedBytecode"]["opcodes"], + contract_node, + stmt_nodes, + branch_nodes, + has_fallback, + ) + return { + "allSourcePaths": paths, + "bytecode": bytecode, + "bytecodeSha1": sha1(bytecode[:-68].encode()).hexdigest(), + "coverageMap": {"statements": statement_map, "branches": branch_map}, + "dependencies": [i.name for i in contract_node.dependencies], + "offset": contract_node.offset, + "pcMap": pc_map, + "type": contract_node.contractKind, + } def _format_link_references(evm: Dict) -> Dict: # Standardizes formatting for unlinked libraries within bytecode bytecode = evm["bytecode"]["object"] - references = [(k, x) for v in evm["bytecode"]["linkReferences"].values() for k, x in v.items()] + references = [ + (k, x) for v in evm["bytecode"].get("linkReferences", {}).values() for k, x in v.items() + ] for n, loc in [(i[0], x["start"] * 2) for i in references for x in i[1]]: bytecode = f"{bytecode[:loc]}__{n[:36]:_<36}__{bytecode[loc+40:]}" return bytecode -def _get_bytecode_hash(bytecode: Dict) -> str: - # Returns a sha1 hash of the given bytecode without metadata - return sha1(bytecode[:-68].encode()).hexdigest() - - def _generate_coverage_data( source_map_str: str, opcodes_str: str, @@ -419,7 +278,7 @@ def _generate_coverage_data( if not opcodes_str: return {}, {}, {} - source_map = deque(_expand_source_map(source_map_str)) + source_map = deque(expand_source_map(source_map_str)) opcodes = deque(opcodes_str.split(" ")) contract_nodes = [contract_node] + contract_node.dependencies @@ -443,7 +302,6 @@ def _generate_coverage_data( fallback_hexstr: str = "unassigned" while source_map: - # format of source is [start, stop, contract_id, jump code] source = source_map.popleft() pc_list.append({"op": opcodes.popleft(), "pc": pc}) @@ -518,9 +376,11 @@ def _generate_coverage_data( pc_list[-1]["fn"] = pc_list[-2]["fn"] else: pc_list[-1]["fn"] = _get_fn_full_name(source_nodes[source[2]], offset) - statement = next(i for i in stmt_nodes[path] if sources.is_inside_offset(offset, i)) - stmt_nodes[path].discard(statement) - statement_map[path].setdefault(pc_list[-1]["fn"], {})[count] = statement + stmt_offset = next( + i for i in stmt_nodes[path] if sources.is_inside_offset(offset, i) + ) + stmt_nodes[path].discard(stmt_offset) + statement_map[path].setdefault(pc_list[-1]["fn"], {})[count] = stmt_offset pc_list[-1]["statement"] = count count += 1 except (KeyError, IndexError, StopIteration): @@ -585,26 +445,11 @@ def _get_fn_full_name(source_node: NodeBase, offset: Tuple[int, int]) -> str: return f"{node.parent().name}.{name}" -def _expand_source_map(source_map_str: str) -> List: - # Expands the compressed sourceMap supplied by solc into a list of lists - source_map: List = [_expand_row(i) if i else None for i in source_map_str.split(";")] - for i, value in enumerate(source_map[1:], 1): - if value is None: - source_map[i] = source_map[i - 1] - continue - for x in range(4): - if source_map[i][x] is None: - source_map[i][x] = source_map[i - 1][x] - return source_map - - -def _expand_row(row: str) -> List: - result: List = [None] * 4 - # ignore the new "modifier depth" value in solidity 0.6.0 - for i, value in enumerate(row.split(":")[:4]): - if value: - result[i] = value if i == 3 else int(value) - return result +def _get_nodes(output_json: Dict) -> Tuple[Dict, Dict, Dict]: + source_nodes = solcast.from_standard_output(output_json) + stmt_nodes = _get_statement_nodes(source_nodes) + branch_nodes = _get_branch_nodes(source_nodes) + return source_nodes, stmt_nodes, branch_nodes def _get_statement_nodes(source_nodes: Dict) -> Dict: diff --git a/brownie/project/compiler/utils.py b/brownie/project/compiler/utils.py new file mode 100644 index 000000000..95e3bf844 --- /dev/null +++ b/brownie/project/compiler/utils.py @@ -0,0 +1,25 @@ +#!/usr/bin/python3 + +from typing import List + + +def expand_source_map(source_map_str: str) -> List: + # Expands the compressed sourceMap supplied by solc into a list of lists + source_map: List = [_expand_row(i) if i else None for i in source_map_str.split(";")] + for i, value in enumerate(source_map[1:], 1): + if value is None: + source_map[i] = source_map[i - 1] + continue + for x in range(4): + if source_map[i][x] is None: + source_map[i][x] = source_map[i - 1][x] + return source_map + + +def _expand_row(row: str) -> List: + result: List = [None] * 4 + # ignore the new "modifier depth" value in solidity 0.6.0 + for i, value in enumerate(row.split(":")[:4]): + if value: + result[i] = value if i == 3 else int(value) + return result diff --git a/brownie/project/compiler/vyper.py b/brownie/project/compiler/vyper.py new file mode 100644 index 000000000..74f72e587 --- /dev/null +++ b/brownie/project/compiler/vyper.py @@ -0,0 +1,188 @@ +#!/usr/bin/python3 + +from collections import deque +from hashlib import sha1 +from typing import Dict, List, Optional, Tuple + +import vyper +from semantic_version import Version +from vyper.cli import vyper_json + +from brownie.project.compiler.utils import expand_source_map +from brownie.project.sources import is_inside_offset + + +def get_version() -> Version: + return Version.coerce(vyper.__version__) + + +def compile_from_input_json( + input_json: Dict, silent: bool = True, allow_paths: Optional[str] = None +) -> Dict: + + """ + Compiles contracts from a standard input json. + + Args: + input_json: solc input json + silent: verbose reporting + allow_paths: compiler allowed filesystem import path + + Returns: standard compiler output json + """ + + if not silent: + print("Compiling contracts...") + print(f" Vyper version: {get_version()}") + return vyper_json.compile_json(input_json, root_path=allow_paths) + + +def _get_unique_build_json( + output_evm: Dict, path_str: str, contract_name: str, ast_json: List, offset: Tuple +) -> Dict: + pc_map, statement_map, branch_map = _generate_coverage_data( + output_evm["deployedBytecode"]["sourceMap"], + output_evm["deployedBytecode"]["opcodes"], + path_str, + contract_name, + ast_json, + ) + return { + "allSourcePaths": [path_str], + "bytecode": output_evm["bytecode"]["object"], + "bytecodeSha1": sha1(output_evm["bytecode"]["object"].encode()).hexdigest(), + "coverageMap": {"statements": statement_map, "branches": branch_map}, + "dependencies": _get_dependencies(ast_json), + "offset": offset, + "pcMap": pc_map, + "type": "contract", + } + + +def _get_dependencies(ast_json: List) -> List: + import_nodes = [i for i in ast_json if i["ast_type"] == "Import"] + import_nodes += [ + i for i in ast_json if i["ast_type"] == "ImportFrom" if i["module"] != "vyper.interfaces" + ] + return sorted(set([i["names"][0]["name"].split(".")[-1] for i in import_nodes])) + + +def _generate_coverage_data( + source_map_str: str, opcodes_str: str, source_str: str, contract_name: str, ast_json: List +) -> Tuple: + if not opcodes_str: + return {}, {}, {} + + source_map = deque(expand_source_map(source_map_str)) + opcodes = deque(opcodes_str.split(" ")) + + fn_nodes = [i for i in ast_json if i["ast_type"] == "FunctionDef"] + fn_offsets = dict((i["name"], _convert_src(i["src"])) for i in fn_nodes) + stmt_nodes = set(_convert_src(i["src"]) for i in _get_statement_nodes(fn_nodes)) + + statement_map: Dict = {} + branch_map: Dict = {} + + pc_list: List = [] + count, pc = 0, 0 + + while opcodes: + # format of source is [start, stop, contract_id, jump code] + source = source_map.popleft() + pc_list.append({"op": opcodes.popleft(), "pc": pc}) + + if source[3] != "-": + pc_list[-1]["jump"] = source[3] + + pc += 1 + if opcodes and opcodes[0][:2] == "0x": + pc_list[-1]["value"] = opcodes.popleft() + pc += int(pc_list[-1]["op"][4:]) + + # set source offset (-1 means none) + if source[0] == -1: + continue + offset = (source[0], source[0] + source[1]) + pc_list[-1]["path"] = source_str + pc_list[-1]["offset"] = offset + + try: + if "offset" in pc_list[-2] and offset == pc_list[-2]["offset"]: + pc_list[-1]["fn"] = pc_list[-2]["fn"] + else: + # statement coverage + fn = next(k for k, v in fn_offsets.items() if is_inside_offset(offset, v)) + pc_list[-1]["fn"] = f"{contract_name}.{fn}" + stmt_offset = next(i for i in stmt_nodes if is_inside_offset(offset, i)) + stmt_nodes.remove(stmt_offset) + statement_map.setdefault(pc_list[-1]["fn"], {})[count] = stmt_offset + pc_list[-1]["statement"] = count + count += 1 + except (KeyError, IndexError, StopIteration): + pass + + if pc_list[-1]["op"] not in ("JUMPI", "REVERT"): + continue + + node = _find_node_by_offset(ast_json, offset) + if pc_list[-1]["op"] == "REVERT": + # custom revert error strings + if node["ast_type"] == "FunctionDef" and pc_list[-7]["op"] == "CALLVALUE": + pc_list[-1]["dev"] = "Cannot send ether to nonpayable function" + elif node["ast_type"] == "Subscript": + pc_list[-1]["dev"] = "Index out of range" + elif node["ast_type"] in ("AugAssign", "BinOp"): + if node["op"]["ast_type"] == "Sub": + pc_list[-1]["dev"] = "Integer underflow" + elif node["op"]["ast_type"] == "Div": + pc_list[-1]["dev"] = "Division by zero" + elif node["op"]["ast_type"] == "Mod": + pc_list[-1]["dev"] = "Modulo by zero" + else: + pc_list[-1]["dev"] = "Integer overflow" + continue + + if node["ast_type"] in ("Assert", "If") or ( + node["ast_type"] == "Expr" and node["value"]["func"]["id"] == "assert_modifiable" + ): + # branch coverage + pc_list[-1]["branch"] = count + branch_map.setdefault(pc_list[-1]["fn"], {}) + if node["ast_type"] == "If": + branch_map[pc_list[-1]["fn"]][count] = _convert_src(node["test"]["src"]) + (False,) + else: + branch_map[pc_list[-1]["fn"]][count] = offset + (True,) + count += 1 + + pc_list[0]["path"] = source_str + pc_list[0]["offset"] = [0, _convert_src(ast_json[-1]["src"])[1]] + pc_map = dict((i.pop("pc"), i) for i in pc_list) + + return pc_map, {source_str: statement_map}, {source_str: branch_map} + + +def _convert_src(src: str) -> Tuple[int, int]: + if src is None: + return -1, -1 + src_int = [int(i) for i in src.split(":")[:2]] + return src_int[0], src_int[0] + src_int[1] + + +def _find_node_by_offset(ast_json: List, offset: Tuple) -> Dict: + node = next(i for i in ast_json if is_inside_offset(offset, _convert_src(i["src"]))) + if _convert_src(node["src"]) == offset: + return node + node_list = [i for i in node.values() if isinstance(i, dict) and "ast_type" in i] + node_list.extend([x for i in node.values() if isinstance(i, list) for x in i]) + return _find_node_by_offset(node_list, offset) + + +def _get_statement_nodes(ast_json: List) -> List: + stmt_nodes: List = [] + for node in ast_json: + children = [x for v in node.values() if isinstance(v, list) for x in v] + if children: + stmt_nodes += _get_statement_nodes(children) + else: + stmt_nodes.append(node) + return stmt_nodes diff --git a/brownie/project/main.py b/brownie/project/main.py index 3f3c06983..0fbf14efe 100644 --- a/brownie/project/main.py +++ b/brownie/project/main.py @@ -113,8 +113,8 @@ class Project(_ProjectBase): def __init__(self, name: str, project_path: Path) -> None: contract_sources: Dict = {} - for path in project_path.glob("contracts/**/*.sol"): - if "/_" in path.as_posix(): + for path in project_path.glob("contracts/**/*"): + if "/_" in path.as_posix() or path.suffix not in (".sol", ".vy"): continue with path.open() as fp: source = fp.read() @@ -187,7 +187,9 @@ def _compare_build_json(self, contract_name: str) -> bool: build_json = self._build.get(contract_name) except KeyError: return True - if build_json["sha1"] != get_hash(source, contract_name, config["minify_source"]): + if build_json["sha1"] != get_hash( + source, contract_name, config["minify_source"], build_json["language"] + ): return True return next( (True for k, v in build_json["compiler"].items() if config[k] and v != config[k]), False @@ -400,7 +402,11 @@ def compile_source( "evm_version": evm_version, "minify_source": False, } - return TempProject("TempProject", {"": source}, compiler_config) + + if solc_version is not None or source.lstrip().startswith("pragma"): + return TempProject("TempSolcProject", {".sol": source}, compiler_config) + + return TempProject("TempVyperProject", {".vy": source}, compiler_config) def load(project_path: Union[Path, str, None] = None, name: Optional[str] = None) -> "Project": diff --git a/brownie/project/sources.py b/brownie/project/sources.py index af486b2ad..ce8020607 100644 --- a/brownie/project/sources.py +++ b/brownie/project/sources.py @@ -9,12 +9,19 @@ from brownie.exceptions import NamespaceCollision from brownie.utils import color -MINIFY_REGEX_PATTERNS = [ +SOLIDITY_MINIFY_REGEX = [ r"(?:\s*\/\/[^\n]*)|(?:\/\*[\s\S]*?\*\/)", # comments r"(?<=\n)\s+|[ \t]+(?=\n)", # leading / trailing whitespace r"(?<=[^\w\s])[ \t]+(?=\w)|(?<=\w)[ \t]+(?=[^\w\s])", # whitespace between expressions ] +VYPER_MINIFY_REGEX = [ + r"((\n|^)[\s]*?#[\s\S]*?)(?=\n[^#])", + r'([\s]*?"""[\s\S]*?""")(?=\n)', + r"([\s]*?'''[\s\S]*?''')(?=\n)", + r"(\n)(?=\n)", +] + _contract_data: Dict = {} @@ -26,7 +33,7 @@ def __init__(self, contract_sources: Dict) -> None: self._source: Dict = {} self._contracts: Dict = {} for path, source in contract_sources.items(): - data = _get_contract_data(source) + data = _get_contract_data(source, path) for name, values in data.items(): if name in self._contracts: raise NamespaceCollision(f"Project has multiple contracts named '{name}'") @@ -67,10 +74,13 @@ def expand_offset(self, contract_name: str, offset: Tuple) -> Tuple: ) -def minify(source: str) -> Tuple[str, List]: +def minify(source: str, language: str = "Solidity") -> Tuple[str, List]: """Given contract source as a string, returns a minified version and an offset map.""" offsets = [(0, 0)] - pattern = f"({'|'.join(MINIFY_REGEX_PATTERNS)})" + if language == "Solidity": + pattern = f"({'|'.join(SOLIDITY_MINIFY_REGEX)})" + else: + pattern = f"({'|'.join(VYPER_MINIFY_REGEX)})" for match in re.finditer(pattern, source): offsets.append( (match.start() - offsets[-1][1], match.end() - match.start() + offsets[-1][1]) @@ -89,12 +99,12 @@ def is_inside_offset(inner: Tuple, outer: Tuple) -> bool: return outer[0] <= inner[0] <= inner[1] <= outer[1] -def get_hash(source: str, contract_name: str, minified: bool) -> str: +def get_hash(source: str, contract_name: str, minified: bool, language: str) -> str: """Returns a hash of the contract source code.""" if minified: - source = minify(source)[0] + source = minify(source, language)[0] try: - data = _get_contract_data(source)[contract_name] + data = _get_contract_data(source, "")[contract_name] offset = slice(*data["offset"]) return sha1(source[offset].encode()).hexdigest() except KeyError: @@ -143,30 +153,37 @@ def highlight_source(source: str, offset: Tuple, pad: int = 3) -> Tuple: return final, ln -def _get_contract_data(full_source: str) -> Dict: +def _get_contract_data(full_source: str, path_str: str) -> Dict: key = sha1(full_source.encode()).hexdigest() if key in _contract_data: return _contract_data[key] - minified_source, offset_map = minify(full_source) + + path = Path(path_str) + language = "Vyper" if path.suffix == ".vy" else "Solidity" + + minified_source, offset_map = minify(full_source, language) minified_key = sha1(minified_source.encode()).hexdigest() if minified_key in _contract_data: return _contract_data[minified_key] - contracts = re.findall( - r"((?:contract|library|interface)\s[^;{]*{[\s\S]*?})\s*(?=(?:contract|library|interface)\s|$)", # NOQA: E501 - minified_source, - ) - data = {} - for source in contracts: - type_, name, inherited = re.findall( - r"(contract|library|interface)\s+(\S*)\s*(?:is\s+([\s\S]*?)|)(?:{)", source - )[0] - idx = minified_source.index(source) - offset = ( - idx + next(i[1] for i in offset_map if i[0] <= idx), - idx + len(source) + next(i[1] for i in offset_map if i[0] <= idx + len(source)), + if language == "Vyper": + data = {path.stem: {"offset": (0, len(full_source)), "offset_map": offset_map}} + else: + contracts = re.findall( + r"((?:contract|library|interface)\s[^;{]*{[\s\S]*?})\s*(?=(?:contract|library|interface)\s|$)", # NOQA: E501 + minified_source, ) - data[name] = {"offset_map": offset_map, "offset": offset} + data = {} + for source in contracts: + type_, name, inherited = re.findall( + r"(contract|library|interface)\s+(\S*)\s*(?:is\s+([\s\S]*?)|)(?:{)", source + )[0] + idx = minified_source.index(source) + offset = ( + idx + next(i[1] for i in offset_map if i[0] <= idx), + idx + len(source) + next(i[1] for i in offset_map if i[0] <= idx + len(source)), + ) + data[name] = {"offset_map": offset_map, "offset": offset} _contract_data[key] = data _contract_data[minified_key] = data return data diff --git a/docs/api-brownie.rst b/docs/api-brownie.rst index fe14d3826..1cbf00950 100644 --- a/docs/api-brownie.rst +++ b/docs/api-brownie.rst @@ -38,6 +38,10 @@ The following classes and methods are used to convert arguments supplied to ``Co Converts a value to a signed integer. This is equivalent to calling ``Wei`` and then applying checks for over/underflows. +.. py:method:: brownie.convert.to_decimal(value) + + Converts a value to a decimal fixed point and applies bounds according to Vyper's `decimal `_ type. + .. py:method:: brownie.convert.to_bool(value) Converts a value to a boolean. Raises ``ValueError`` if the given value does not match a value in ``(True, False, 0, 1)``. @@ -118,6 +122,32 @@ For certain types of contract data, Brownie uses subclasses to assist with conve >>> Wei("1 ether") - "0.75 ether" 250000000000000000 +.. _fixed: + +.. py:class:: brownie.convert.Fixed(value) + + `Decimal `_ subclass that allows comparisons, addition and subtraction against strings, integers and :ref:`wei`. + + ``Fixed`` is used for inputs and outputs to Vyper contracts that use the `decimal `_ type. + + Attempting comparisons or arithmetic against a float raises a ``TypeError``. + + .. code-block:: python + + >>> from brownie import Fixed + >>> Fixed(1) + Fixed('1') + >>> Fixed(3.1337) + TypeError: Cannot convert float to decimal - use a string instead + >>> Fixed("3.1337") + Fixed('3.1337') + >>> Fixed("12.49 gwei") + Fixed('12490000000') + >>> Fixed("-1.23") == -1.2300 + TypeError: Cannot compare to floating point - use a string instead + >>> Fixed("-1.23") == "-1.2300" + True + .. py:class:: brownie.convert.EthAddress(value) String subclass for address comparisons. Raises a ``TypeError`` when compared to a non-address. @@ -335,6 +365,10 @@ The ``exceptions`` module contains all Brownie ``Exception`` classes. Raised when an ENS name is unset (resolves to ``0x00``). +.. py:exception:: brownie.exceptions.UnsupportedLanguage + + Raised when attempting to compile a language that Brownie does not support. + .. py:exception:: brownie.exceptions.RPCConnectionError Raised when the RPC process is active and ``web3`` is connected, but Brownie is unable to communicate with it. diff --git a/docs/api-project.rst b/docs/api-project.rst index f46078470..ef7cf7bd4 100644 --- a/docs/api-project.rst +++ b/docs/api-project.rst @@ -145,7 +145,9 @@ Module Methods .. py:method:: main.compile_source(source, solc_version=None, optimize=True, runs=200, evm_version=None) - Compiles the given Solidity source code string and returns a ``TempProject`` object. + Compiles the given source code string and returns a ``TempProject`` object. + + If Vyper source code is given, the contract name will be ``Vyper``. .. code-block:: python @@ -353,7 +355,7 @@ Module Methods Returns a ``dict`` of ``{'version': ["path", "path", ..]}``. -.. py:method:: compiler.generate_input_json(contract_sources, optimize=True, runs=200, evm_version=None, minify=False) +.. py:method:: compiler.generate_input_json(contract_sources, optimize=True, runs=200, evm_version=None, minify=False, language="Solidity") Generates a `standard solc input JSON `_ as a dict. @@ -370,56 +372,6 @@ Module Methods * ``compiler_data``: Additional compiler data to include * ``silent``: Toggles console verbosity -Internal Methods ----------------- - -.. py:method:: compiler._format_link_references(evm) - - Standardizes formatting for unlinked library placeholders within bytecode. Used internally to ensure that unlinked libraries are represented uniformly regardless of the compiler version used. - - * ``evm``: The ``'evm'`` object from a compiler output JSON. - -.. py:method:: compiler._get_bytecode_hash(bytecode) - - Removes the final metadata from a bytecode hex string and returns a hash of the result. Used to check if a contract has changed when the source code is modified. - -.. py:method:: compiler._expand_source_map(source_map) - - Returns an uncompressed source mapping as a list of lists where no values are omitted. - - .. code-block:: python - - >>> from brownie.project.compiler import expand_source_map - >>> expand_source_map("1:2:1:-;:9;2:1:2;;;") - [[1, 2, 1, '-'], [1, 9, 1, '-'], [2, 1, 2, '-'], [2, 1, 2, '-'], [2, 1, 2, '-'], [2, 1, 2, '-']] - -.. py:method:: compiler._generate_coverage_data(source_map_str, opcodes_str, contract_node, stmt_nodes, branch_nodes, has_fallback) - - Generates the `program counter `_ and `coverage `_ maps that are used by Brownie for debugging and test coverage evaluation. - - Takes the following arguments: - - * ``source_map_str``: `deployed source mapping `_ as given by the compiler - * ``opcodes_str``: deployed bytecode opcodes string as given by the compiler - * ``contract_node``: py-solc-ast contract node object - * ``stmt_nodes``: list of statement node objects from ``compiler.get_statment_nodes`` - * ``branch_nodes``: list of branch node objects from ``compiler.get_branch_nodes`` - * ``has_fallback``: Bool, does this contract contain a fallback method? - - Returns: - - * ``pc_list``: program counter map - * ``statement_map``: statement coverage map - * ``branch_map``: branch coverage map - -.. py:method:: compiler._get_statement_nodes(source_nodes) - - Given a list of AST source node objects from `py-solc-ast `_, returns a list of statement nodes. Used to generate the statement coverage map. - -.. py:method:: compiler._get_branch_nodes(source_nodes) - - Given a list of AST source node objects from `py-solc-ast `_, returns a list of branch nodes. Used to generate the branch coverage map. - ``brownie.project.ethpm`` ========================= @@ -624,7 +576,7 @@ Module Methods .. _sources-minify: -.. py:method:: sources.minify(source) +.. py:method:: sources.minify(source, language="Solidity") Given contract source as a string, returns a minified version and an offset map used internally to translate minified offsets to the original ones. @@ -650,6 +602,6 @@ Module Methods Given a path, start and stop offset, returns highlighted source code. Called internally by ``TransactionReceipt.source``. -.. py:method:: sources.get_hash(source, contract_name, minified) +.. py:method:: sources.get_hash(source, contract_name, minified, language) Returns a sha1 hash generated from a contract's source code. diff --git a/docs/build-folder.rst b/docs/build-folder.rst index 6cb841a5c..dec0aa49b 100644 --- a/docs/build-folder.rst +++ b/docs/build-folder.rst @@ -27,6 +27,7 @@ Brownie generates compiler artifacts for each contract within a project, which a 'deployedBytecode': "0x00", // bytecode as hex string after deployment 'deployedSourceMap': "", // source mapping of the deployed bytecode 'dependencies': [], // contracts and libraries that this contract inherits from or is linked to + 'language': "", // source code language (Solidity or Vyper) 'offset': [], // source code offsets for this contract 'opcodes': "", // deployed contract opcodes list 'pcMap': [], // program counter map diff --git a/docs/compile.rst b/docs/compile.rst index 0e1e7eb90..0953cbb36 100644 --- a/docs/compile.rst +++ b/docs/compile.rst @@ -12,6 +12,11 @@ To compile a project: Each time the compiler runs, Brownie compares hashes of the contract source code against the existing compiled versions. If a contract has not changed it will not be recompiled. If you wish to force a recompile of the entire project, use ``brownie compile --all``. +Brownie supports both Solidity and Vyper. Which compiler to use is determined based on the suffix of the file: + + * Solidity: ``.sol`` + * Vyper: ``.vy`` + .. note:: All of a project's contract sources must be placed inside the ``contracts/`` folder. Attempting to import sources from outside this folder will result in a compiler error. @@ -39,7 +44,7 @@ Setting the Compiler Version .. note:: - Brownie supports Solidity versions ``>=0.4.22``. + Brownie supports Solidity versions ``>=0.4.22`` and Vyper version ``0.1.0-b15``. If a compiler version is set in the configuration file, all contracts in the project are compiled using that version. It is installed automatically if not already present. The version should be given as a string in the format ``0.x.x``. diff --git a/docs/conf.py b/docs/conf.py index e08459471..a37f89d48 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -37,7 +37,7 @@ def setup(sphinx): # The short X.Y version version = "" # The full version, including alpha/beta/rc tags -release = "v1.3.2" +release = "v1.4.0" # -- General configuration --------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index ade434cf9..a26bfd1e9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,6 +15,7 @@ Brownie is a Python-based development and testing framework for smart contracts Features ======== +* Full support for `Solidity `_ and `Vyper `_ * Contract testing via `pytest `_, including trace-based coverage evaluation * Powerful debugging tools, including python-style tracebacks and custom error strings * Built-in console for quick project interaction diff --git a/docs/init.rst b/docs/init.rst index e2b8f458e..89d3b2253 100644 --- a/docs/init.rst +++ b/docs/init.rst @@ -12,7 +12,7 @@ This will create the following project structure within the folder: * ``build/``: Compiled contracts and test data * ``contracts/``: Contract source code -* ``reports/``: JSON report files for use in the :ref:`coverage-gui` +* ``reports/``: JSON report files * ``scripts/``: Scripts for deployment and interaction * ``tests/``: Scripts for testing your project * ``brownie-config.yaml``: Configuration file for the project diff --git a/docs/tests.rst b/docs/tests.rst index ef958ba1c..860a9f52c 100644 --- a/docs/tests.rst +++ b/docs/tests.rst @@ -158,7 +158,7 @@ Each revert string adds a minimum 20000 gas to your contract deployment cost, an For this reason, Brownie allows you to include revert strings as source code comments that are not included in the bytecode but still accessible via ``TransactionReceipt.revert_msg``. You write tests that target a specific ``require`` or ``revert`` statement without increasing gas costs. -Revert string comments must begin with ``// dev:`` in order for Brownie to recognize them. Priority is always given to compiled revert strings. Some examples: +Revert string comments must begin with ``// dev:`` in Solidity, or ``# dev:`` in Vyper. Priority is always given to compiled revert strings. Some examples: .. code-block:: solidity :linenos: diff --git a/requirements-dev.txt b/requirements-dev.txt index 0150b098e..414459c6d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,6 +10,8 @@ pytest-cov==2.8.1 pytest-mock==1.12.0 pytest-xdist==1.31.0 sphinx==2.0.1 +sphinx_rtd_theme==0.4.3 +pygments_lexer_solidity==0.4.0 tox==3.14.0 twine==1.13.0 wheel==0.33.4 diff --git a/requirements.txt b/requirements.txt index 9c3b26468..c9faaade3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,5 @@ pyyaml>=5.1.0,<6.0.0 requests>=2.22.0,<3.0.0 semantic-version>=2.8.2,<3.0.0 tqdm==4.41.0 +vyper==0.1.0b15 web3==5.3.0 diff --git a/setup.cfg b/setup.cfg index 0a86ef347..ebe5a06db 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.3.2 +current_version = 1.4.0 [bumpversion:file:setup.py] @@ -20,7 +20,7 @@ ignore = E203,W503 force_grid_wrap = 0 include_trailing_comma = True known_first_party = brownie -known_third_party = _pytest,docopt,ens,eth_abi,eth_event,eth_hash,eth_keys,eth_utils,ethpm,hexbytes,mythx_models,psutil,pytest,pythx,requests,semantic_version,setuptools,solcast,solcx,web3,yaml +known_third_party = _pytest,docopt,ens,eth_abi,eth_event,eth_hash,eth_keys,eth_utils,ethpm,hexbytes,mythx_models,psutil,pytest,pythx,requests,semantic_version,setuptools,solcast,solcx,vyper,web3,yaml line_length = 100 multi_line_output = 3 use_parentheses = True diff --git a/setup.py b/setup.py index 67b27aed7..66631f836 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="eth-brownie", packages=find_packages(), - version="1.3.2", # don't change this manually, use bumpversion instead + version="1.4.0", # don't change this manually, use bumpversion instead license="MIT", description="A Python framework for Ethereum smart contract deployment, testing and interaction.", # noqa: E501 long_description=long_description, diff --git a/tests/conftest.py b/tests/conftest.py index 4c032cca7..b6348d1c7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,7 @@ def pytest_sessionstart(): monkeypatch_session = MonkeyPatch() monkeypatch_session.setattr( "solcx.get_available_solc_versions", - lambda: ["v0.5.10", "v0.5.9", "v0.5.8", "v0.5.7", "v0.4.25", "v0.4.24", "v0.4.22"], + lambda: ["v0.6.0", "v0.5.10", "v0.5.8", "v0.5.7", "v0.4.25", "v0.4.24", "v0.4.22"], ) @@ -303,6 +303,11 @@ def ext_tester(ExternalCallTester, accounts): return ExternalCallTester.deploy({"from": accounts[0]}) +@pytest.fixture +def vypertester(testproject, devnetwork, accounts): + return testproject.VyperTester.deploy({"from": accounts[0]}) + + # ipfs fixtures diff --git a/tests/data/brownie-test-project/contracts/VyperTester.vy b/tests/data/brownie-test-project/contracts/VyperTester.vy new file mode 100644 index 000000000..c1780a7e0 --- /dev/null +++ b/tests/data/brownie-test-project/contracts/VyperTester.vy @@ -0,0 +1,57 @@ + +stuff: public(uint256[4]) + + +@public +def branches(a: uint256, b: bool) -> bool: + if a > 4: + return True + elif b: + return False + if a - 2 == 3 and not b: + return True + return False + + +@public +def revertStrings(a: uint256) -> bool: + assert a != 0, "zero" + assert a != 1 # dev: one + assert a != 2, "two" # dev: error + assert a != 3 # error + assert_modifiable(a != 4) # dev: such modifiable, wow + if a != 31337: + return True + raise "awesome show" # dev: great job + + +@public +@constant +def fixedType(a: decimal, b: decimal[2]) -> decimal[3]: + return [a, b[0], b[1]] + + +@public +def outOfBounds(i: uint256, value: uint256) -> bool: + self.stuff[i] = value + return True + + +@public +def overflow(a: uint256, b: uint256) -> uint256: + return a * b + + +@public +def underflow(a: uint256, b: uint256) -> uint256: + return a - b + + +@public +def zeroDivision(a: uint256, b: uint256) -> uint256: + return a / b + + +@public +def zeroModulo(a: uint256, b: uint256) -> uint256: + return a % b diff --git a/tests/main/convert/test_fixed.py b/tests/main/convert/test_fixed.py new file mode 100755 index 000000000..3f50a60fd --- /dev/null +++ b/tests/main/convert/test_fixed.py @@ -0,0 +1,86 @@ +#!/usr/bin/python3 + +from decimal import Decimal + +import pytest + +from brownie.convert import Fixed + + +def test_nonetype(): + with pytest.raises(TypeError): + Fixed(None) + + +def test_bytes(): + assert Fixed(b"\xff") == 255 + + +def test_scientific_notation(): + assert Fixed("8.32e26") == 832000000000000000000000000 + + +def test_float(): + with pytest.raises(TypeError): + Fixed(1.99) + + +def test_int(): + assert Fixed(1000) == 1000 + + +def test_str(): + assert Fixed("1000.123456789123456789") == Decimal("1000.123456789123456789") + + +def test_hexstr(): + assert Fixed("0xff") == 255 + + +def test_string_with_unit(): + assert Fixed("3.66 ether") == 3660000000000000000 + assert Fixed("89.006 gwei") == 89006000000 + + +def test_type(): + assert type(Fixed(12)) is Fixed + + +def test_eq(): + assert Fixed("1") == 1 + assert not Fixed("123") == "obviously not a number" + with pytest.raises(TypeError): + Fixed("1.0") == 1.0 + + +def test_ne(): + assert Fixed("1") != 2 + assert Fixed("123") != "obviously not a number" + with pytest.raises(TypeError): + Fixed("1.0") != 1.1 + + +def test_lt(): + assert Fixed("1 ether") < "2 ether" + with pytest.raises(TypeError): + Fixed("1.0") < 1.1 + + +def test_le(): + assert Fixed("1 ether") <= "2 ether" + assert Fixed("1 ether") <= "1 ether" + with pytest.raises(TypeError): + Fixed("1.0") <= 1.1 + + +def test_gt(): + assert Fixed("2 ether") > "1 ether" + with pytest.raises(TypeError): + Fixed("2.0") > 1.0 + + +def test_ge(): + assert Fixed("2 ether") >= "1 ether" + assert Fixed("2 ether") >= "2 ether" + with pytest.raises(TypeError): + Fixed("2.0") >= 2.0 diff --git a/tests/main/convert/test_return_value.py b/tests/main/convert/test_return_value.py index 898bb1d1c..bb807d133 100644 --- a/tests/main/convert/test_return_value.py +++ b/tests/main/convert/test_return_value.py @@ -108,12 +108,17 @@ def test_hexstring_typeerror(): assert str(b) != "potato" -def test_hexstring_length(return_value): +def test_hexstring_length(): b = HexString("0x1234", "bytes32") assert b == "0x1234" assert b == "0x000000000000001234" -def test_hashable(return_value): +def test_hashable(): assert hash(ReturnValue([1, 2])) == hash(tuple([1, 2])) assert set(ReturnValue([3, 1, 3, 3, 7])) == set([3, 1, 3, 3, 7]) + + +def test_decimals(vypertester): + ret = vypertester.fixedType("1.234", ["-42", "3.1337"]) + assert ret == ["1.234", "-42", "3.1337"] diff --git a/tests/main/convert/test_to_decimal.py b/tests/main/convert/test_to_decimal.py new file mode 100755 index 000000000..ffe47e78a --- /dev/null +++ b/tests/main/convert/test_to_decimal.py @@ -0,0 +1,43 @@ +#!/usr/bin/python3 + +from decimal import Decimal + +import pytest + +from brownie.convert import Fixed, to_decimal + + +def test_return_type(): + assert isinstance(to_decimal(123), Decimal) + assert type(to_decimal(123)) is Fixed + + +def test_success(): + assert to_decimal(123) == 123 + assert to_decimal("-3.1337") == Decimal("-3.1337") + assert to_decimal("1 ether") == 1000000000000000000 + assert to_decimal(Decimal(42)) == 42 + assert to_decimal(Fixed(6)) == "6" + + +def test_incorrect_type(): + with pytest.raises(TypeError, match="Cannot convert float to decimal - use a string instead"): + to_decimal(3.1337) + with pytest.raises(TypeError): + to_decimal(None) + + +def test_bounds(): + to_decimal(-2 ** 127) + with pytest.raises(OverflowError): + to_decimal(-2 ** 127 - 1) + to_decimal(2 ** 127 - 1) + with pytest.raises(OverflowError): + to_decimal(2 ** 127) + + +def test_decimal_points(): + to_decimal("1.0000000001") + to_decimal("1.00000000010000000") + with pytest.raises(ValueError): + to_decimal("1.00000000001") diff --git a/tests/network/contract/test_contracttx.py b/tests/network/contract/test_contracttx.py index c73567296..1828bec52 100644 --- a/tests/network/contract/test_contracttx.py +++ b/tests/network/contract/test_contracttx.py @@ -164,6 +164,12 @@ def test_repr(tester): repr(tester.revertStrings) +def test_repr_fixedtype(vypertester): + r = repr(vypertester.fixedType) + assert "decimal" in r + assert "fixed168x10" not in r + + def test_tuples(tester, accounts): value = ["blahblah", accounts[1], ["yesyesyes", "0x1234"]] tx = tester.setTuple(value) diff --git a/tests/network/test_alert.py b/tests/network/test_alert.py index 1123fb3ab..70fb0215d 100644 --- a/tests/network/test_alert.py +++ b/tests/network/test_alert.py @@ -84,10 +84,10 @@ def test_stop(): def test_fire_msg(capfd): t = AlertTest(False) - alert.new(t, delay=0.02, msg="Fired") + alert.new(t, delay=0.03, msg="Fired") assert not capfd.readouterr()[0].strip() t.set_value(True) - time.sleep(0.04) + time.sleep(0.08) assert capfd.readouterr()[0].strip()[-5:] == "Fired" assert len(alert.show()) == 0 diff --git a/tests/network/transaction/test_revert_msg.py b/tests/network/transaction/test_revert_msg.py index 6e7658900..8dfa64521 100644 --- a/tests/network/transaction/test_revert_msg.py +++ b/tests/network/transaction/test_revert_msg.py @@ -10,7 +10,7 @@ def test_revert_msg_via_jump(ext_tester, console_mode): assert tx.revert_msg == "dev: should jump to a revert" -def test_revert_msg(evmtester, console_mode): +def test_solidity_revert_msg(evmtester, console_mode): tx = evmtester.revertStrings(0) assert tx.revert_msg == "zero" tx = evmtester.revertStrings(1) @@ -23,6 +23,21 @@ def test_revert_msg(evmtester, console_mode): assert tx.revert_msg == "dev: great job" +def test_vyper_revert_msg(vypertester, console_mode): + tx = vypertester.revertStrings(0) + assert tx.revert_msg == "zero" + tx = vypertester.revertStrings(1) + assert tx.revert_msg == "dev: one" + tx = vypertester.revertStrings(2) + assert tx.revert_msg == "two" + tx = vypertester.revertStrings(3) + assert tx.revert_msg == "" + tx = vypertester.revertStrings(4) + assert tx.revert_msg == "dev: such modifiable, wow" + tx = vypertester.revertStrings(31337) + assert tx.revert_msg == "awesome show" + + def test_nonpayable(tester, evmtester, console_mode): tx = evmtester.revertStrings(0, {"value": 100}) assert tx.revert_msg == "Cannot send ether to nonpayable function" @@ -30,7 +45,7 @@ def test_nonpayable(tester, evmtester, console_mode): assert tx.revert_msg == "Cannot send ether to nonpayable function" -def test_invalid_opcodes(evmtester): +def test_solidity_invalid_opcodes(evmtester): with pytest.raises(VirtualMachineError) as exc: evmtester.invalidOpcodes(0, 0) assert exc.value.revert_msg == "invalid opcode" @@ -46,3 +61,18 @@ def test_invalid_opcodes(evmtester): with pytest.raises(VirtualMachineError) as exc: evmtester.modulusByZero(2, 0) assert exc.value.revert_msg == "Modulus by zero" + + +def test_vyper_revert_reasons(vypertester, console_mode): + tx = vypertester.outOfBounds(6, 31337) + assert tx.revert_msg == "Index out of range" + tx = vypertester.overflow(6, 2 ** 255) + assert tx.revert_msg == "Integer overflow" + tx = vypertester.underflow(6, 8) + assert tx.revert_msg == "Integer underflow" + tx = vypertester.zeroDivision(6, 0) + assert tx.revert_msg == "Division by zero" + tx = vypertester.zeroModulo(6, 0) + assert tx.revert_msg == "Modulo by zero" + tx = vypertester.overflow(0, 0, {"value": 31337}) + assert tx.revert_msg == "Cannot send ether to nonpayable function" diff --git a/tests/project/compiler/test_main_compiler.py b/tests/project/compiler/test_main_compiler.py new file mode 100644 index 000000000..8c56b10ed --- /dev/null +++ b/tests/project/compiler/test_main_compiler.py @@ -0,0 +1,30 @@ +#!/usr/bin/python3 + +import pytest + +from brownie.exceptions import UnsupportedLanguage +from brownie.project import compiler + + +def test_multiple_compilers(solc4source, vysource): + compiler.compile_and_format( + { + "solc4.sol": solc4source, + "vyper.vy": vysource, + "solc6.sol": "pragma solidity 0.6.0; contract Foo {}", + } + ) + + +def test_wrong_suffix(): + with pytest.raises(UnsupportedLanguage): + compiler.compile_and_format({"foo.bar": ""}) + + +def test_unknown_language(): + with pytest.raises(UnsupportedLanguage): + compiler.generate_input_json({"foo": ""}, language="Bar") + with pytest.raises(UnsupportedLanguage): + compiler.compile_from_input_json({"language": "FooBar"}) + with pytest.raises(UnsupportedLanguage): + compiler.generate_build_json({"language": "BarBaz"}, {}) diff --git a/tests/project/test_compiler.py b/tests/project/compiler/test_solidity.py similarity index 62% rename from tests/project/test_compiler.py rename to tests/project/compiler/test_solidity.py index 4d4f2025b..a7496fa54 100644 --- a/tests/project/test_compiler.py +++ b/tests/project/compiler/test_solidity.py @@ -13,14 +13,21 @@ @pytest.fixture def solc4json(solc4source): compiler.set_solc_version("0.4.25") - input_json = compiler.generate_input_json({"path": solc4source}, True, 200) + input_json = compiler.generate_input_json({"path.sol": solc4source}, True, 200) yield compiler.compile_from_input_json(input_json) @pytest.fixture def solc5json(solc5source): compiler.set_solc_version("0.5.7") - input_json = compiler.generate_input_json({"path": solc5source}, True, 200) + input_json = compiler.generate_input_json({"path.sol": solc5source}, True, 200) + yield compiler.compile_from_input_json(input_json) + + +@pytest.fixture +def solc6json(solc6source): + compiler.set_solc_version("0.6.0") + input_json = compiler.generate_input_json({"path.sol": solc6source}, True, 200) yield compiler.compile_from_input_json(input_json) @@ -44,27 +51,29 @@ def msolc(monkeypatch): def test_set_solc_version(): compiler.set_solc_version("0.5.7") - assert "0.5.7" in solcx.get_solc_version_string() + assert solcx.get_solc_version() == compiler.solidity.get_version() + assert solcx.get_solc_version().truncate() == Version("0.5.7") compiler.set_solc_version("0.4.25") - assert "0.4.25" in solcx.get_solc_version_string() + assert solcx.get_solc_version() == compiler.solidity.get_version() + assert solcx.get_solc_version().truncate() == Version("0.4.25") def test_generate_input_json(solc5source): - input_json = compiler.generate_input_json({"path": solc5source}, True, 200) + input_json = compiler.generate_input_json({"path.sol": solc5source}, True, 200) assert input_json["settings"]["optimizer"]["enabled"] is True assert input_json["settings"]["optimizer"]["runs"] == 200 - assert input_json["sources"]["path"]["content"] == solc5source + assert input_json["sources"]["path.sol"]["content"] == solc5source input_json = compiler.generate_input_json( - {"path": solc5source}, optimize=False, runs=0, minify=True + {"path.sol": solc5source}, optimize=False, runs=0, minify=True ) assert input_json["settings"]["optimizer"]["enabled"] is False assert input_json["settings"]["optimizer"]["runs"] == 0 - assert input_json["sources"]["path"]["content"] != solc5source + assert input_json["sources"]["path.sol"]["content"] != solc5source def test_generate_input_json_evm(solc5source, monkeypatch): monkeypatch.setattr("solcx.get_solc_version", lambda: Version("0.5.5")) - fn = functools.partial(compiler.generate_input_json, {"path": solc5source}) + fn = functools.partial(compiler.generate_input_json, {"path.sol": solc5source}) assert fn()["settings"]["evmVersion"] == "petersburg" assert fn(evm_version="byzantium")["settings"]["evmVersion"] == "byzantium" assert fn(evm_version="petersburg")["settings"]["evmVersion"] == "petersburg" @@ -75,12 +84,12 @@ def test_generate_input_json_evm(solc5source, monkeypatch): def test_compile_input_json(solc5json): - assert "Foo" in solc5json["contracts"]["path"] - assert "Bar" in solc5json["contracts"]["path"] + assert "Foo" in solc5json["contracts"]["path.sol"] + assert "Bar" in solc5json["contracts"]["path.sol"] def test_compile_input_json_raises(): - input_json = compiler.generate_input_json({"path": "potato"}, True, 200) + input_json = compiler.generate_input_json({"path.sol": "potato"}, True, 200) with pytest.raises(CompilerError): compiler.compile_from_input_json(input_json) @@ -91,40 +100,50 @@ def _test_compiler(a, **kwargs): assert kwargs["optimize_runs"] == 666 monkeypatch.setattr("solcx.compile_standard", _test_compiler) - input_json = {"settings": {"optimizer": {"enabled": True, "runs": 666}}} + input_json = {"language": "Solidity", "settings": {"optimizer": {"enabled": True, "runs": 666}}} compiler.compile_from_input_json(input_json) - input_json = {"settings": {"optimizer": {"enabled": True, "runs": 31337}}} + input_json = { + "language": "Solidity", + "settings": {"optimizer": {"enabled": True, "runs": 31337}}, + } with pytest.raises(AssertionError): compiler.compile_from_input_json(input_json) - input_json = {"settings": {"optimizer": {"enabled": False, "runs": 666}}} + input_json = { + "language": "Solidity", + "settings": {"optimizer": {"enabled": False, "runs": 666}}, + } with pytest.raises(AssertionError): compiler.compile_from_input_json(input_json) def test_build_json_keys(solc5source): - build_json = compiler.compile_and_format({"path": solc5source}) + build_json = compiler.compile_and_format({"path.sol": solc5source}) assert set(build.BUILD_KEYS) == set(build_json["Foo"]) -def test_build_json_unlinked_libraries(solc4source, solc5source): - build_json = compiler.compile_and_format({"path": solc5source}, solc_version="0.5.7") +def test_build_json_unlinked_libraries(solc4source, solc5source, solc6source): + build_json = compiler.compile_and_format({"path.sol": solc4source}, solc_version="0.4.25") assert "__Bar__" in build_json["Foo"]["bytecode"] - build_json = compiler.compile_and_format({"path": solc4source}, solc_version="0.4.25") + build_json = compiler.compile_and_format({"path.sol": solc5source}, solc_version="0.5.7") + assert "__Bar__" in build_json["Foo"]["bytecode"] + build_json = compiler.compile_and_format({"path.sol": solc6source}, solc_version="0.6.0") assert "__Bar__" in build_json["Foo"]["bytecode"] -def test_format_link_references(solc4json, solc5json): - evm = solc5json["contracts"]["path"]["Foo"]["evm"] - assert "__Bar__" in compiler._format_link_references(evm) - evm = solc4json["contracts"]["path"]["Foo"]["evm"] - assert "__Bar__" in compiler._format_link_references(evm) +def test_format_link_references(solc4json, solc5json, solc6json): + evm = solc4json["contracts"]["path.sol"]["Foo"]["evm"] + assert "__Bar__" in compiler.solidity._format_link_references(evm) + evm = solc5json["contracts"]["path.sol"]["Foo"]["evm"] + assert "__Bar__" in compiler.solidity._format_link_references(evm) + evm = solc6json["contracts"]["path.sol"]["Foo"]["evm"] + assert "__Bar__" in compiler.solidity._format_link_references(evm) def test_compiler_errors(solc4source, solc5source): with pytest.raises(CompilerError): - compiler.compile_and_format({"path": solc4source}, solc_version="0.5.7") + compiler.compile_and_format({"path.sol": solc4source}, solc_version="0.5.7") with pytest.raises(CompilerError): - compiler.compile_and_format({"path": solc5source}, solc_version="0.4.25") + compiler.compile_and_format({"path.sol": solc5source}, solc_version="0.4.25") def test_min_version(): @@ -141,7 +160,7 @@ def test_find_solc_versions(find_version, msolc): assert "0.5.7" in find_version(">0.4.8 <0.5.8 || 0.5.11") assert "0.4.22" in find_version("0.5.9 || 0.4.22") with pytest.raises(PragmaError): - compiler.find_solc_versions({"Foo": "contract Foo {}"}) + compiler.find_solc_versions({"Foo.sol": "contract Foo {}"}) with pytest.raises(IncompatibleSolcVersion): find_version("^1.0.0", install_needed=False) with pytest.raises(IncompatibleSolcVersion): @@ -156,9 +175,9 @@ def test_find_solc_versions_install(find_version, msolc): find_version("^0.4.22", install_latest=True) assert msolc.pop() == "v0.4.25" find_version("^0.4.24 || >=0.5.10", install_needed=True) - assert msolc.pop() == "v0.5.10" + assert msolc.pop() == "v0.6.0" find_version(">=0.4.24", install_latest=True) - assert msolc.pop() == "v0.5.10" + assert msolc.pop() == "v0.6.0" def test_install_solc(msolc): @@ -174,3 +193,7 @@ def test_first_revert(BrownieTester, ExternalCallTester): assert next((i for i in pc_map.values() if "first_revert" in i), False) pc_map = BrownieTester._build["pcMap"] assert not next((i for i in pc_map.values() if "first_revert" in i), False) + + +def test_compile_empty(): + compiler.compile_and_format({"empty.sol": ""}, solc_version="0.4.25") diff --git a/tests/project/compiler/test_vyper.py b/tests/project/compiler/test_vyper.py new file mode 100644 index 000000000..385bea7e5 --- /dev/null +++ b/tests/project/compiler/test_vyper.py @@ -0,0 +1,68 @@ +#!/usr/bin/python3 + +import functools + +import pytest +import vyper +from semantic_version import Version +from vyper.exceptions import StructureException + +from brownie.project import build, compiler + + +@pytest.fixture +def vyjson(vysource): + input_json = compiler.generate_input_json({"path.vy": vysource}, language="Vyper") + yield compiler.compile_from_input_json(input_json) + + +def test_version(): + assert compiler.vyper.get_version() == Version.coerce(vyper.__version__) + + +def test_generate_input_json(vysource): + input_json = compiler.generate_input_json({"path.vy": vysource}, minify=False, language="Vyper") + assert "optimizer" not in input_json["settings"] + assert input_json["sources"]["path.vy"]["content"] == vysource + input_json = compiler.generate_input_json({"path.vy": vysource}, minify=True, language="Vyper") + assert "optimizer" not in input_json["settings"] + assert input_json["sources"]["path.vy"]["content"] != vysource + + +def test_generate_input_json_evm(vysource): + fn = functools.partial(compiler.generate_input_json, {"path.vy": vysource}, language="Vyper") + assert fn()["settings"]["evmVersion"] == "byzantium" + assert fn(evm_version="byzantium")["settings"]["evmVersion"] == "byzantium" + assert fn(evm_version="petersburg")["settings"]["evmVersion"] == "petersburg" + + +def test_compile_input_json(vyjson): + assert "path" in vyjson["contracts"]["path.vy"] + + +def test_compile_input_json_raises(): + input_json = compiler.generate_input_json({"path.vy": "potato"}, language="Vyper") + with pytest.raises(StructureException): + compiler.compile_from_input_json(input_json) + + +def test_build_json_keys(vysource): + build_json = compiler.compile_and_format({"path.vy": vysource}) + assert set(build.BUILD_KEYS) == set(build_json["path"]) + + +def test_dependencies(vysource): + code = """ +import path as foo +from vyper.interfaces import ERC20 +from foo import bar +""" + + build_json = compiler.compile_and_format( + {"path.vy": vysource, "deps.vy": code, "foo/bar.vy": vysource} + ) + assert build_json["deps"]["dependencies"] == ["bar", "path"] + + +def test_compile_empty(): + compiler.compile_and_format({"empty.vy": ""}) diff --git a/tests/project/conftest.py b/tests/project/conftest.py index 5277a1b19..726c0545b 100644 --- a/tests/project/conftest.py +++ b/tests/project/conftest.py @@ -3,7 +3,7 @@ import pytest test_source = """ -pragma solidity ^0.5.0; +pragma solidity [VERSION]; library Bar { function baz(uint a, uint b) external pure returns (uint) { @@ -29,13 +29,28 @@ def btsource(testproject): return fs.read() +@pytest.fixture +def solc6source(): + return test_source.replace("[VERSION]", "^0.6.0") + + @pytest.fixture def solc5source(): - return test_source + return test_source.replace("[VERSION]", "^0.5.0") @pytest.fixture def solc4source(): source = test_source.replace("payable ", "") - source = source.replace("^0.5.0", "^0.4.25") + source = source.replace("[VERSION]", "^0.4.25") return source + + +@pytest.fixture +def vysource(): + return """ +# comments are totally kickass +@public +def test() -> bool: + return True +""" diff --git a/tests/project/test_main_project.py b/tests/project/test_main_project.py index 7abc59e5c..b06c31366 100644 --- a/tests/project/test_main_project.py +++ b/tests/project/test_main_project.py @@ -87,15 +87,25 @@ def test_close(project, testproject): testproject.close() -def test_compile_object(project, solc5source): +def test_compile_solc_object(project, solc5source): temp = project.compile_source(solc5source) assert type(temp) is TempProject assert isinstance(temp, _ProjectBase) assert len(temp) == 2 + assert temp._name == "TempSolcProject" assert "Foo" in temp assert "Bar" in temp +def test_compile_vyper_object(project): + temp = project.compile_source("@public\ndef x() -> bool: return True") + assert type(temp) is TempProject + assert isinstance(temp, _ProjectBase) + assert len(temp) == 1 + assert temp._name == "TempVyperProject" + assert "Vyper" in temp + + def test_compile_namespace(project, solc5source): project.compile_source(solc5source) assert not hasattr(project, "TempProject")