diff --git a/brownie/data/network-config.yaml b/brownie/data/network-config.yaml index edacea6ba..3c730ca75 100644 --- a/brownie/data/network-config.yaml +++ b/brownie/data/network-config.yaml @@ -111,6 +111,19 @@ development: host: http://127.0.0.1 cmd_settings: port: 8545 + - name: Hardhat + id: hardhat + cmd: npx hardhat node + host: http://127.0.0.1 + cmd_settings: + port: 8545 + - name: Hardhat (Mainnet Fork) + id: hardhat-fork + cmd: npx hardhat node + host: http://127.0.0.1 + cmd_settings: + port: 8545 + fork: mainnet - name: Ganache-CLI (Mainnet Fork) id: mainnet-fork cmd: ganache-cli diff --git a/brownie/exceptions.py b/brownie/exceptions.py index f1c3b80bc..fc8d953e6 100644 --- a/brownie/exceptions.py +++ b/brownie/exceptions.py @@ -107,7 +107,7 @@ def __init__(self, exc: ValueError) -> None: self.source: str = "" self.revert_type: str = data["error"] self.pc: Optional[str] = data.get("program_counter") - if self.revert_type == "revert": + if self.pc and self.revert_type == "revert": self.pc -= 1 self.revert_msg: Optional[str] = data.get("reason") diff --git a/brownie/network/middlewares/hardhat.py b/brownie/network/middlewares/hardhat.py new file mode 100644 index 000000000..c57eb943d --- /dev/null +++ b/brownie/network/middlewares/hardhat.py @@ -0,0 +1,40 @@ +from typing import Callable, Dict, List, Optional + +from web3 import Web3 + +from brownie.network.middlewares import BrownieMiddlewareABC + + +class HardhatMiddleWare(BrownieMiddlewareABC): + @classmethod + def get_layer(cls, w3: Web3, network_type: str) -> Optional[int]: + if w3.clientVersion.lower().startswith("hardhat"): + return -100 + else: + return None + + def process_request(self, make_request: Callable, method: str, params: List) -> Dict: + result = make_request(method, params) + + # modify Hardhat transaction error to mimick the format that Ganache uses + if method in ("eth_call", "eth_sendTransaction") and "error" in result: + message = result["error"]["message"] + if message.startswith("Error: VM Exception") or message.startswith( + "Error: Transaction reverted" + ): + if method == "eth_call": + # ganache returns a txid even on a failed eth_call, which is weird, + # but we still mimick it here for the sake of consistency + txid = "0x" + else: + txid = result["error"]["data"]["txHash"] + data: Dict = {} + result["error"]["data"] = {txid: data} + message = message.split(": ", maxsplit=1)[-1] + if message == "Transaction reverted without a reason": + data.update({"error": "revert", "reason": None}) + elif message.startswith("revert"): + data.update({"error": "revert", "reason": message[7:]}) + else: + data["error"] = message + return result diff --git a/brownie/network/rpc/__init__.py b/brownie/network/rpc/__init__.py index d79789fb1..9b9bbffa7 100644 --- a/brownie/network/rpc/__init__.py +++ b/brownie/network/rpc/__init__.py @@ -16,19 +16,17 @@ from brownie.network.state import Chain from brownie.network.web3 import web3 -from . import ganache, geth +from . import ganache, geth, hardhat chain = Chain() -ATTACH_BACKENDS = { - "ethereumjs testrpc": ganache, - "geth": geth, -} +ATTACH_BACKENDS = {"ethereumjs testrpc": ganache, "geth": geth, "hardhat": hardhat} LAUNCH_BACKENDS = { "ganache": ganache, "ethnode": geth, "geth": geth, + "npx hardhat": hardhat, } diff --git a/brownie/network/rpc/hardhat.py b/brownie/network/rpc/hardhat.py new file mode 100644 index 000000000..4b454657b --- /dev/null +++ b/brownie/network/rpc/hardhat.py @@ -0,0 +1,102 @@ +#!/usr/bin/python3 + +import sys +import warnings +from pathlib import Path +from subprocess import DEVNULL, PIPE +from typing import Dict, List, Optional + +import psutil +from requests.exceptions import ConnectionError as RequestsConnectionError + +from brownie.exceptions import InvalidArgumentWarning, RPCRequestError +from brownie.network.web3 import web3 + +CLI_FLAGS = {"port": "--port", "fork": "--fork", "fork_block": "--fork-block-number"} +IGNORED_SETTINGS = ["chain_id"] + +HARDHAT_CONFIG = """ +// autogenerated by brownie +// do not modify the existing settings +module.exports = { + networks: { + hardhat: { + hardfork: "berlin", + throwOnTransactionFailures: true, + throwOnCallFailures: true + } + } +}""" + + +def launch(cmd: str, **kwargs: Dict) -> None: + """Launches the RPC client. + + Args: + cmd: command string to execute as subprocess""" + # if sys.platform == "win32" and not cmd.split(" ")[0].endswith(".cmd"): + # if " " in cmd: + # cmd = cmd.replace(" ", ".cmd ", 1) + # else: + # cmd += ".cmd" + cmd_list = cmd.split(" ") + for key, value in [(k, v) for k, v in kwargs.items() if v and k not in IGNORED_SETTINGS]: + try: + cmd_list.extend([CLI_FLAGS[key], str(value)]) + except KeyError: + warnings.warn( + f"Ignoring invalid commandline setting for hardhat: " + f'"{key}" with value "{value}".', + InvalidArgumentWarning, + ) + print(f"\nLaunching '{' '.join(cmd_list)}'...") + out = DEVNULL if sys.platform == "win32" else PIPE + + # check parent folders for existence of a hardhat config, so this folder is + # considered a hardhat project. if none is found, create one. + config_exists = False + for path in Path("hardhat.config.js").absolute().parents: + if path.joinpath("hardhat.config.js").exists(): + config_exists = True + break + if not config_exists: + with Path("hardhat.config.js").open("w") as fp: + fp.write(HARDHAT_CONFIG) + + return psutil.Popen(cmd_list, stdin=DEVNULL, stdout=out, stderr=out) + + +def on_connection() -> None: + gas_limit = web3.eth.getBlock("latest").gasLimit + web3.provider.make_request("evm_setBlockGasLimit", [hex(gas_limit)]) # type: ignore + + +def _request(method: str, args: List) -> int: + try: + response = web3.provider.make_request(method, args) # type: ignore + if "result" in response: + return response["result"] + except (AttributeError, RequestsConnectionError): + raise RPCRequestError("Web3 is not connected.") + raise RPCRequestError(response["error"]["message"]) + + +def sleep(seconds: int) -> int: + return _request("evm_increaseTime", [seconds]) + + +def mine(timestamp: Optional[int] = None) -> None: + params = [timestamp] if timestamp else [] + _request("evm_mine", params) + + +def snapshot() -> int: + return _request("evm_snapshot", []) + + +def revert(snapshot_id: int) -> None: + _request("evm_revert", [snapshot_id]) + + +def unlock_account(address: str) -> None: + web3.provider.make_request("hardhat_impersonateAccount", [address]) # type: ignore diff --git a/brownie/network/state.py b/brownie/network/state.py index 1b38bfa71..bb61f3c3e 100644 --- a/brownie/network/state.py +++ b/brownie/network/state.py @@ -312,6 +312,8 @@ def _add_to_undo_buffer(self, tx: Any, fn: Any, args: Tuple, kwargs: Dict) -> No else: self._redo_buffer.clear() self._current_id = rpc.Rpc().snapshot() + # ensure the local time offset is correct, in case it was modified by the transaction + self.sleep(0) def _network_connected(self) -> None: self._reset_id = None @@ -354,7 +356,7 @@ def sleep(self, seconds: int) -> None: """ if not isinstance(seconds, int): raise TypeError("seconds must be an integer value") - self._time_offset = rpc.Rpc().sleep(seconds) + self._time_offset = int(rpc.Rpc().sleep(seconds)) if seconds: self._redo_buffer.clear() diff --git a/brownie/test/managers/master.py b/brownie/test/managers/master.py index 301805af5..9d77ae9d1 100644 --- a/brownie/test/managers/master.py +++ b/brownie/test/managers/master.py @@ -18,6 +18,15 @@ class PytestBrownieMaster(PytestBrownieBase): Hooks in this class are loaded by the master process when using xdist. """ + def pytest_configure_node(self, node): + """ + Configure node information before it gets instantiated. + + Here we can pass arbitrary information to xdist workers via the + `workerinput` dict. + """ + node.workerinput["network"] = CONFIG.argv["network"] + def pytest_xdist_make_scheduler(self, config, log): """ Return a node scheduler implementation. diff --git a/brownie/test/managers/runner.py b/brownie/test/managers/runner.py index c78ce4e07..c636775eb 100644 --- a/brownie/test/managers/runner.py +++ b/brownie/test/managers/runner.py @@ -531,9 +531,11 @@ class PytestBrownieXdistRunner(PytestBrownieRunner): def __init__(self, config, project): self.workerid = int("".join(i for i in config.workerinput["workerid"] if i.isdigit())) - # TODO fix me - key = CONFIG.argv["network"] or CONFIG.settings["networks"]["default"] - CONFIG.networks[key]["cmd_settings"]["port"] += self.workerid + + # network ID is passed to the worker via `pytest_configure_node` in the master + network_id = config.workerinput["network"] or CONFIG.settings["networks"]["default"] + CONFIG.networks[network_id]["cmd_settings"]["port"] += self.workerid + super().__init__(config, project) def pytest_collection_modifyitems(self, items): diff --git a/tests/cli/test_cli_networks.py b/tests/cli/test_cli_networks.py index 64b46aaa1..d8524b18e 100644 --- a/tests/cli/test_cli_networks.py +++ b/tests/cli/test_cli_networks.py @@ -1,3 +1,5 @@ +import copy + import pytest import yaml @@ -6,10 +8,10 @@ @pytest.fixture(autouse=True) -def isolation(): +def networks_yaml(): with _get_data_folder().joinpath("network-config.yaml").open() as fp: networks = yaml.safe_load(fp) - yield + yield copy.deepcopy(networks) with _get_data_folder().joinpath("network-config.yaml").open("w") as fp: networks = yaml.dump(networks, fp) @@ -151,17 +153,9 @@ def test_delete_live(): assert "mainnet" not in [i["id"] for i in networks["live"][0]["networks"]] -def test_delete_development(): - for network_name in ( - "development", - "mainnet-fork", - "bsc-main-fork", - "ftm-main-fork", - "polygon-main-fork", - "xdai-main-fork", - "geth-dev", - ): - cli_networks._delete(network_name) +def test_delete_development(networks_yaml): + for network_name in networks_yaml["development"]: + cli_networks._delete(network_name["id"]) with _get_data_folder().joinpath("network-config.yaml").open() as fp: networks = yaml.safe_load(fp)