Skip to content

Commit

Permalink
Merge pull request #1043 from eth-brownie/feat-hardhat
Browse files Browse the repository at this point in the history
Support for Hardhat Network
  • Loading branch information
iamdefinitelyahuman committed Aug 7, 2021
2 parents 6953d3f + 6eb8b40 commit c723b88
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 23 deletions.
13 changes: 13 additions & 0 deletions brownie/data/network-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion brownie/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
40 changes: 40 additions & 0 deletions brownie/network/middlewares/hardhat.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 3 additions & 5 deletions brownie/network/rpc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand Down
102 changes: 102 additions & 0 deletions brownie/network/rpc/hardhat.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion brownie/network/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions brownie/test/managers/master.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions brownie/test/managers/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
20 changes: 7 additions & 13 deletions tests/cli/test_cli_networks.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import copy

import pytest
import yaml

Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit c723b88

Please sign in to comment.