Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Hardhat Network #1043

Merged
merged 17 commits into from
Aug 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
iamdefinitelyahuman marked this conversation as resolved.
Show resolved Hide resolved
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"]

iamdefinitelyahuman marked this conversation as resolved.
Show resolved Hide resolved
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

iamdefinitelyahuman marked this conversation as resolved.
Show resolved Hide resolved
# 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