diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af6e3cb9..f8ecc49be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/iamdefinitelyahuman/brownie) ### Added - Project folder structure is now configurable ([#581](https://github.com/eth-brownie/brownie/pull/581)) +- Deployment artifacts can now be saved via project setting `dev_deployment_artifacts: true` ([#590](https://github.com/eth-brownie/brownie/pull/590)) +- All deployment artifacts are tracked in `deployments/map.json` ([#590](https://github.com/eth-brownie/brownie/pull/590)) ### Changed - `tx.call_trace()` now displays internal and total gas usage ([#564](https://github.com/iamdefinitelyahuman/brownie/pull/564)) diff --git a/brownie/_config.py b/brownie/_config.py index 621c96522..70c4c9c53 100644 --- a/brownie/_config.py +++ b/brownie/_config.py @@ -44,6 +44,11 @@ def __init__(self): raise ValueError(f"Multiple networks using ID '{key}'") self.networks[key] = value + # make sure chainids are always strings + for network, values in self.networks.items(): + if "chainid" in values: + self.networks[network]["chainid"] = str(values["chainid"]) + self.argv = defaultdict(lambda: None) self.settings = _Singleton("settings", (ConfigDict,), {})(base_config) self._active_network = None diff --git a/brownie/data/default-config.yaml b/brownie/data/default-config.yaml index c6a3e42ff..6a188076b 100644 --- a/brownie/data/default-config.yaml +++ b/brownie/data/default-config.yaml @@ -49,3 +49,4 @@ hypothesis: autofetch_sources: false dependencies: null +dev_deployment_artifacts: false diff --git a/brownie/network/contract.py b/brownie/network/contract.py index 663cfa06b..2c4c5536d 100644 --- a/brownie/network/contract.py +++ b/brownie/network/contract.py @@ -386,23 +386,38 @@ def balance(self) -> Wei: return Wei(balance) def _deployment_path(self) -> Optional[Path]: - if CONFIG.network_type != "live" or not self._project._path: + if not self._project._path or ( + CONFIG.network_type != "live" and not CONFIG.settings["dev_deployment_artifacts"] + ): return None - chainid = CONFIG.active_network["chainid"] + + chainid = CONFIG.active_network["chainid"] if CONFIG.network_type == "live" else "dev" path = self._project._build_path.joinpath(f"deployments/{chainid}") path.mkdir(exist_ok=True) return path.joinpath(f"{self.address}.json") def _save_deployment(self) -> None: path = self._deployment_path() - if path and not path.exists(): - with path.open("w") as fp: - json.dump(self._build, fp) + chainid = CONFIG.active_network["chainid"] if CONFIG.network_type == "live" else "dev" + deployment_build = self._build.copy() + + deployment_build["deployment"] = { + "address": self.address, + "chainid": chainid, + "blockHeight": web3.eth.blockNumber, + } + if path: + self._project._add_to_deployment_map(self) + if not path.exists(): + with path.open("w") as fp: + json.dump(deployment_build, fp) def _delete_deployment(self) -> None: path = self._deployment_path() - if path and path.exists(): - path.unlink() + if path: + self._project._remove_from_deployment_map(self) + if path.exists(): + path.unlink() class Contract(_DeployedContractBase): diff --git a/brownie/network/main.py b/brownie/network/main.py index a4d2925b8..513957ff3 100644 --- a/brownie/network/main.py +++ b/brownie/network/main.py @@ -47,7 +47,7 @@ def connect(network: str = None, launch_rpc: bool = True) -> None: rpc.launch(active["cmd"], **active["cmd_settings"]) else: Accounts()._reset() - if CONFIG.network_type == "live": + if CONFIG.network_type == "live" or CONFIG.settings["dev_deployment_artifacts"]: for p in project.get_loaded_projects(): p._load_deployments() diff --git a/brownie/project/main.py b/brownie/project/main.py index 699ffa2e7..b8a897e2e 100644 --- a/brownie/project/main.py +++ b/brownie/project/main.py @@ -40,6 +40,7 @@ InterfaceContainer, ProjectContract, ) +from brownie.network.rpc import _revert_register from brownie.network.state import _add_contract, _remove_contract from brownie.project import compiler, ethpm from brownie.project.build import BUILD_KEYS, INTERFACE_KEYS, Build @@ -223,6 +224,10 @@ def load(self) -> None: sys.modules["__main__"].__dict__, sys.modules["brownie.project"].__dict__, ] + + # register project for revert and reset + _revert_register(self) + self._active = True _loaded_projects.append(self) @@ -296,30 +301,84 @@ def _compile_interfaces(self, compiled_hashes: Dict) -> None: self._build._add(abi) def _load_deployments(self) -> None: - if CONFIG.network_type != "live": + if CONFIG.network_type != "live" and not CONFIG.settings["dev_deployment_artifacts"]: return - chainid = CONFIG.active_network["chainid"] + chainid = CONFIG.active_network["chainid"] if CONFIG.network_type == "live" else "dev" path = self._build_path.joinpath(f"deployments/{chainid}") path.mkdir(exist_ok=True) deployments = list(path.glob("*.json")) deployments.sort(key=lambda k: k.stat().st_mtime) + deployment_map = self._load_deployment_map() for build_json in deployments: with build_json.open() as fp: build = json.load(fp) - if build["contractName"] not in self._containers: + + contract_name = build["contractName"] + if contract_name not in self._containers: build_json.unlink() continue if "pcMap" in build: contract = ProjectContract(self, build, build_json.stem) else: contract = Contract( # type: ignore - build["contractName"], build_json.stem, build["abi"] + contract_name, build_json.stem, build["abi"] ) contract._project = self - container = self._containers[build["contractName"]] + container = self._containers[contract_name] _add_contract(contract) container._contracts.append(contract) + # update deployment map for the current chain + instances = deployment_map.setdefault(chainid, {}).setdefault(contract_name, []) + if build_json.stem in instances: + instances.remove(build_json.stem) + instances.insert(0, build_json.stem) + + self._save_deployment_map(deployment_map) + + def _load_deployment_map(self) -> Dict: + deployment_map: Dict = {} + map_path = self._build_path.joinpath("deployments/map.json") + if map_path.exists(): + with map_path.open("r") as fp: + deployment_map = json.load(fp) + return deployment_map + + def _save_deployment_map(self, deployment_map: Dict) -> None: + with self._build_path.joinpath("deployments/map.json").open("w") as fp: + json.dump(deployment_map, fp, sort_keys=True, indent=2, default=sorted) + + def _remove_from_deployment_map(self, contract: ProjectContract) -> None: + if CONFIG.network_type != "live" and not CONFIG.settings["dev_deployment_artifacts"]: + return + chainid = CONFIG.active_network["chainid"] if CONFIG.network_type == "live" else "dev" + deployment_map = self._load_deployment_map() + try: + deployment_map[chainid][contract._name].remove(contract.address) + if not deployment_map[chainid][contract._name]: + del deployment_map[chainid][contract._name] + if not deployment_map[chainid]: + del deployment_map[chainid] + except (KeyError, ValueError): + pass + + self._save_deployment_map(deployment_map) + + def _add_to_deployment_map(self, contract: ProjectContract) -> None: + if CONFIG.network_type != "live" and not CONFIG.settings["dev_deployment_artifacts"]: + return + + chainid = CONFIG.active_network["chainid"] if CONFIG.network_type == "live" else "dev" + deployment_map = self._load_deployment_map() + try: + deployment_map[chainid][contract._name].remove(contract.address) + except (ValueError, KeyError): + pass + deployment_map.setdefault(chainid, {}).setdefault(contract._name, []).insert( + 0, contract.address + ) + self._save_deployment_map(deployment_map) + def _update_and_register(self, dict_: Any) -> None: dict_.update(self) if "interface" not in dict_: @@ -391,6 +450,37 @@ def close(self, raises: bool = True) -> None: except ValueError: pass + def _clear_dev_deployments(self, height: int) -> None: + path = self._build_path.joinpath(f"deployments/dev") + if path.exists(): + deployment_map = self._load_deployment_map() + for deployment in path.glob("*.json"): + if height == 0: + deployment.unlink() + else: + with deployment.open("r") as fp: + deployment_artifact = json.load(fp) + block_height = deployment_artifact["deployment"]["blockHeight"] + address = deployment_artifact["deployment"]["address"] + contract_name = deployment_artifact["contractName"] + if block_height > height: + deployment.unlink() + try: + deployment_map["dev"][contract_name].remove(address) + except (KeyError, ValueError): + pass + if "dev" in deployment_map and (height == 0 or not deployment_map["dev"]): + del deployment_map["dev"] + shutil.rmtree(path) + + self._save_deployment_map(deployment_map) + + def _revert(self, height: int) -> None: + self._clear_dev_deployments(height) + + def _reset(self) -> None: + self._clear_dev_deployments(0) + class TempProject(_ProjectBase): @@ -427,7 +517,7 @@ def check_for_project(path: Union[Path, str] = ".") -> Optional[Path]: return None -def get_loaded_projects() -> List: +def get_loaded_projects() -> List["Project"]: """Returns a list of currently loaded Project objects.""" return _loaded_projects.copy() diff --git a/docs/config.rst b/docs/config.rst index f4b0e6076..86fad10c1 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -240,3 +240,13 @@ Other Settings - defi.snakecharmers.eth/compound@1.1.0 See the :ref:`Brownie Package Manager` to learn more about package dependencies. + +.. _dev_artifacts: + +.. py:attribute:: dev_deployment_artifacts + + If enabled, Brownie will save deployment artifacts for contracts deployed on development networks and will include the "dev" network on the deployment map. + + This is useful if another application, such as a front end framework, needs access to deployment artifacts while you are on a development network. + + default value: ``false`` \ No newline at end of file diff --git a/docs/deploy.rst b/docs/deploy.rst index 0a66f8781..6d7f2e296 100644 --- a/docs/deploy.rst +++ b/docs/deploy.rst @@ -48,6 +48,27 @@ See the documentation on :ref:`network management` for more .. _persistence: +The Deployment Map +================== + +Brownie will maintain a ``map.json`` file in your ``build/deployment/`` folder that lists all deployed contracts on live networks, sorted by chain and contract name. + +:: + + { + "1": { + "SolidityStorage": [ + "0x73B74F5f1d1f7A00d8c33bFbD09744eD90220D12", + "0x189a7fBB0038D4b55Bd03840be0B0a38De034089" + ], + "VyperStorage": [ + "0xF104A50668c3b1026E8f9B0d9D404faF8E42e642" + ] + } + } + +The list for each contract is sorted by the block number of the deployment with the most recent deployment first. + Interacting with Deployed Contracts =================================== @@ -67,3 +88,14 @@ The following actions WILL remove locally stored deployment data within your pro * Deleting the ``build/deployments/`` directory will erase all information about deployed contracts. To restore a deleted :func:`ProjectContract ` instance, or generate one for a deployment that was handled outside of Brownie, use the :func:`ContractContainer.at ` method. + + +Saving Deployments on Development Networks +========================================== + +If you need deployment artifacts on a development network, set :attr:`dev_deployment_artifacts` to true in the in the project's ``brownie-config.yaml`` file. + +These temporary deployment artifacts and the corresponding entries in :ref:`the deployment map` will be removed whenever you (re-) load a project or connect, disconnect, revert or reset your local network. + +If you use a development network that is not started by brownie - for example an external instance of ganache - the deployment artifacts will not be deleted when disconnecting from that network. +However, the network will be reset and the deployment artifacts deleted when you connect to such a network with brownie. \ No newline at end of file diff --git a/tests/project/main/test_deployment_map.py b/tests/project/main/test_deployment_map.py new file mode 100644 index 000000000..5708a878c --- /dev/null +++ b/tests/project/main/test_deployment_map.py @@ -0,0 +1,68 @@ +import json + + +def get_map(project) -> dict: + with project._build_path.joinpath("deployments/map.json").open("r") as fp: + content = json.load(fp) + return content + + +def get_dev_artifacts(project) -> list: + return list(project._build_path.joinpath("deployments/dev/").glob("*.json")) + + +def test_dev_deployment_map_content(testproject, BrownieTester, config, accounts): + config.settings["dev_deployment_artifacts"] = True + + # deploy and verify deployment of first contract + BrownieTester.deploy(True, {"from": accounts[0]}) + content = get_map(testproject) + assert isinstance(content, dict) + + assert len(content["dev"]["BrownieTester"]) == 1 + assert len(get_dev_artifacts(testproject)) == 1 + + # deploy and verify deployment of second contract + BrownieTester.deploy(True, {"from": accounts[0]}) + address = BrownieTester[-1].address + + content = get_map(testproject) + assert len(content["dev"]["BrownieTester"]) == 2 + assert content["dev"]["BrownieTester"][0] == address + + assert len(get_dev_artifacts(testproject)) == 2 + + +def test_dev_deployment_map_clear_on_disconnect( + devnetwork, testproject, BrownieTester, config, accounts +): + config.settings["dev_deployment_artifacts"] = True + + BrownieTester.deploy(True, {"from": accounts[0]}) + devnetwork.disconnect() + content = get_map(testproject) + assert not content + + +def test_dev_deployment_map_clear_on_remove(testproject, BrownieTester, config, accounts): + config.settings["dev_deployment_artifacts"] = True + + BrownieTester.deploy(True, {"from": accounts[0]}) + BrownieTester.remove(BrownieTester[-1]) + + assert len(get_dev_artifacts(testproject)) == 0 + content = get_map(testproject) + assert not content + + +def test_dev_deployment_map_revert(testproject, BrownieTester, config, accounts, rpc): + config.settings["dev_deployment_artifacts"] = True + + BrownieTester.deploy(True, {"from": accounts[0]}) + rpc.snapshot() + BrownieTester.deploy(True, {"from": accounts[0]}) + assert len(get_dev_artifacts(testproject)) == 2 + rpc.revert() + assert len(get_dev_artifacts(testproject)) == 1 + content = get_map(testproject) + assert len(content["dev"]["BrownieTester"]) == 1