diff --git a/.env.unsafe.example b/.env.unsafe.example index 6a6cc081..187110fe 100644 --- a/.env.unsafe.example +++ b/.env.unsafe.example @@ -1,5 +1,27 @@ # This is your unsafe environment file. Assume that you will accidently expose values in this file. # Meaning, you should never store private keys associated with real funds in here! + + MAINNET_ENDPOINT=xxx SEPOLIA_ENDPOINT=xxx SEPOLIA_PKEY=xxx + +# Blockscout API key +# Required for verifying contracts on Blockscout-based explorers +BLOCKSCOUT_API_KEY=your_blockscout_api_key_here + +# Etherscan API key +# Required for verifying contracts on Etherscan and its variants (e.g., Polygonscan, Bscscan) +ETHERSCAN_API_KEY=your_etherscan_api_key_here + +# Optional: Custom Blockscout API URL +# Use this if you're working with a non-standard Blockscout instance +# BLOCKSCOUT_API_URL=https://custom.blockscout.com/api + +# Optional: Custom Etherscan API URL +# Use this if you're working with a network that has an Etherscan-like explorer with a different URL +# ETHERSCAN_API_URL=https://api.custometherscan.com/api + +# You can add more network-specific API keys if needed, for example: +# POLYGONSCAN_API_KEY=your_polygonscan_api_key_here +# BSCSCAN_API_KEY=your_bscscan_api_key_here \ No newline at end of file diff --git a/boa/contracts/vyper/vyper_contract.py b/boa/contracts/vyper/vyper_contract.py index 08e7b8c6..08110635 100644 --- a/boa/contracts/vyper/vyper_contract.py +++ b/boa/contracts/vyper/vyper_contract.py @@ -1,7 +1,7 @@ # the main "entry point" of vyper-related functionality like # AST handling, traceback construction and ABI (marshaling # and unmarshaling vyper objects) - +import os import contextlib import copy import warnings @@ -59,6 +59,7 @@ from boa.util.lrudict import lrudict from boa.vm.gas_meters import ProfilingGasMeter from boa.vm.utils import to_bytes, to_int +import requests # error messages for external calls EXTERNAL_CALL_ERRORS = ("external call failed", "returndatasize too small") @@ -67,8 +68,7 @@ # error detail where user possibly provided dev revert reason DEV_REASON_ALLOWED = ("user raise", "user assert") - - + class VyperDeployer: create_compiler_data = CompilerData # this may be a different class in plugins @@ -84,12 +84,128 @@ def __init__(self, compiler_data, filename=None): def __call__(self, *args, **kwargs): return self.deploy(*args, **kwargs) + + @staticmethod + def _post_verification_request(url: str, json_data: dict, headers: dict) -> dict: + try: + response = requests.post(url, json=json_data, headers=headers) + if response.status_code != 200: + print(f"Request failed with status code: {response.status_code}") + return {"status": "0", "message": "Request failed"} + return response.json() + except Exception as e: + print(f"Error during contract verification: {e}") + return {"status": "0", "message": str(e)} + + @staticmethod + def validate_blockscout(address: str, bytecode: str, source_code: str, compiler_version: str) -> bool: + api_key = os.getenv('BLOCKSCOUT_API_KEY') + if not api_key: + raise ValueError("BLOCKSCOUT_API_KEY not set in environment variables") + + url = f"https://blockscout.com/api/v2/smart-contracts/{address}/verification/via/standard-input" + + standard_json_input = { + "language": "Vyper", + "sources": { + "contract.vy": { + "content": source_code + } + }, + "settings": { + "optimizer": {"enabled": True}, + "outputSelection": {"*": ["evm.bytecode", "evm.deployedBytecode", "abi"]} + } + } + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {api_key}' + } + + result = VyperDeployer._post_verification_request(url, standard_json_input, headers) + if result.get("status") == "1": + print("Contract verified successfully on Blockscout") + return True + else: + print(f"Contract verification failed: {result.get('message')}") + return False + + @staticmethod + def validate_etherscan(address: str, bytecode: str, source_code: str, compiler_version: str) -> bool: + api_key = os.getenv('ETHERSCAN_API_KEY') + if not api_key: + raise ValueError("ETHERSCAN_API_KEY not set in environment variables") + + url = os.getenv('ETHERSCAN_API_URL', 'https://api.etherscan.io/api') + + standard_json_input = { + "language": "Vyper", + "sources": { + "contract.vy": { + "content": source_code + } + }, + "settings": { + "optimizer": {"enabled": True}, + "outputSelection": {"*": ["evm.bytecode", "evm.deployedBytecode", "abi"]} + } + } - def deploy(self, *args, **kwargs): - return VyperContract( + params = { + "module": "contract", + "action": "verifysourcecode", + "contractaddress": address, + "sourceCode": json.dumps(standard_json_input), + "codeformat": "solidity-standard-json-input", + "contractname": "contract.vy:VerifiedContract", + "compilerversion": compiler_version, + "optimizationUsed": "1", + "apikey": api_key + } + + headers = {'Content-Type': 'application/json'} + result = VyperDeployer._post_verification_request(url, params, headers) + if result.get("status") == "1": + print("Contract verified successfully on Etherscan") + return True + else: + print(f"Contract verification failed: {result.get('message')}") + return False + + contract_verifiers = { + "blockscout": validate_blockscout, + "etherscan": validate_etherscan + } + + def verify_contract(self, address: str, bytecode: str, source_code: str, compiler_version: str, explorer: str) -> bool: + try: + verifier = self.contract_verifiers[explorer] + except KeyError: + raise ValueError(f"Unsupported explorer: {explorer}") + + return verifier(address, bytecode, source_code, compiler_version) + + def deploy(self, *args, explorer: Optional[str] = None, **kwargs): + contract = VyperContract( self.compiler_data, *args, filename=self.filename, **kwargs ) + if explorer: + verification_result = self.verify_contract( + address=contract.address, + bytecode=contract.bytecode, + source_code=self.compiler_data.source_code, + compiler_version=f"v{vyper.__version__}", + explorer=explorer + ) + if verification_result: + print(f"Contract verified successfully on {explorer}") + else: + print(f"Contract verification failed on {explorer}") + + return contract + def deploy_as_blueprint(self, *args, **kwargs): return VyperBlueprint( self.compiler_data, *args, filename=self.filename, **kwargs @@ -131,7 +247,7 @@ def _constants(self): # Make constants available at compile time. Useful for testing. See #196 return ConstantsModel(self.compiler_data) - + # a few lines of shared code between VyperBlueprint and VyperContract class _BaseVyperContract(_BaseEVMContract): def __init__( diff --git a/boa/interpret.py b/boa/interpret.py index 8386d1fb..369bef87 100644 --- a/boa/interpret.py +++ b/boa/interpret.py @@ -171,7 +171,7 @@ def load(filename: str | Path, *args, **kwargs) -> _Contract: # type: ignore if "name" in kwargs: name = kwargs.pop("name") with open(filename) as f: - return loads(f.read(), *args, name=name, **kwargs, filename=filename) + return loads(f.read(), *args, name=name, explorer=explorer, **kwargs, filename=filename) def loads( @@ -180,14 +180,16 @@ def loads( as_blueprint=False, name=None, filename=None, - compiler_args=None, + compiler_args=None, + explorer: Optional[str] = None, **kwargs, ): d = loads_partial(source_code, name, filename=filename, compiler_args=compiler_args) if as_blueprint: return d.deploy_as_blueprint(**kwargs) else: - return d.deploy(*args, **kwargs) + return d.deploy(*args, **kwargs) + def load_abi(filename: str, *args, name: str = None, **kwargs) -> ABIContractFactory: @@ -195,7 +197,7 @@ def load_abi(filename: str, *args, name: str = None, **kwargs) -> ABIContractFac name = Path(filename).stem with open(filename) as fp: return loads_abi(fp.read(), *args, name=name, **kwargs) - + def loads_abi(json_str: str, *args, name: str = None, **kwargs) -> ABIContractFactory: return ABIContractFactory.from_abi_dict(json.loads(json_str), name, *args, **kwargs) diff --git a/docs/source/api.rst b/docs/source/api.rst index 3c6d8710..dd18eb6d 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -11,12 +11,13 @@ High-Level Functionality The global environment object. -.. function:: load(fp: str, *args: Any, **kwargs: Any) -> VyperContract | VyperBlueprint +.. function:: load(fp: str, *args: Any, explorer: str | None = None, **kwargs: Any) -> VyperContract | VyperBlueprint - Compile source from disk and return a deployed instance of the contract. + Compile source from disk, deploy the contract, and optionally verify it on a block explorer. :param fp: The contract source code file path. - :param args: Contract constructor arguments. + :param args: Contract constructor arguments. + :param explorer: The block explorer to use for verification ("blockscout" or "etherscan"). :param kwargs: Keyword arguments to pass to the :py:func:`loads` function. .. rubric:: Example @@ -38,18 +39,21 @@ High-Level Functionality >>> import boa >>> from vyper.compiler.settings import OptimizationLevel, Settings - >>> boa.load("Foo.vy", compiler_args={"settings": Settings(optimize=OptimizationLevel.CODESIZE)}) + >>> contract = boa.load("Foo.vy", compiler_args={"settings": Settings(optimize=OptimizationLevel.CODESIZE)}, explorer="blockscout") + >>> contract + Contract verified successfully on blockscout -.. function:: loads(source: str, *args: Any, as_blueprint: bool = False, name: str | None = None, compiler_args: dict | None = None, **kwargs) -> VyperContract | VyperBlueprint +.. function:: loads(source: str, *args: Any, as_blueprint: bool = False, name: str | None = None, compiler_args: dict | None = None, verify: bool = False, explorer: str | None = None, **kwargs) -> VyperContract | VyperBlueprint - Compile source code and return a deployed instance of the contract. + Compile source code, deploy the contract, and optionally verify it on a block explorer. :param source: The source code to compile and deploy. :param args: Contract constructor arguments. :param as_blueprint: Whether to deploy an :eip:`5202` blueprint of the compiled contract. :param name: The name of the contract. - :param compiler_args: Argument to be passed to the Vyper compiler. + :param compiler_args: Argument to be passed to the Vyper compiler. + :param explorer: The block explorer to use for verification ("blockscout" or "etherscan"). :param kwargs: Keyword arguments to pass to the :py:class:`VyperContract` or :py:class:`VyperBlueprint` ``__init__`` method. .. rubric:: Example @@ -63,8 +67,10 @@ High-Level Functionality ... def __init__(_initial_value: uint256): ... self.value = _initial_value ... """ - >>> boa.loads(src, 69) + >>> contract = boa.loads(src, 69, verify=True, explorer="etherscan") + >>> contract + Contract verified successfully on etherscan .. function:: load_partial(fp: str, compiler_args: dict | None = None) -> VyperDeployer @@ -690,6 +696,16 @@ Low-Level Functionality .. property:: deployer :type: VyperDeployer +.. note:: + + To use the contract verification functionality, you need to set the following environment variables: + + - `BLOCKSCOUT_API_KEY`: Your API key for Blockscout + - `ETHERSCAN_API_KEY`: Your API key for Etherscan + - `ETHERSCAN_API_URL` (optional): Custom API URL for Etherscan + + Make sure these environment variables are set before using the verification features. + .. class:: VyperBlueprint Stub class for :eip:`5202` blueprints. diff --git a/tests/integration/test_verify_blockscout.py b/tests/integration/test_verify_blockscout.py new file mode 100644 index 00000000..7515b87e --- /dev/null +++ b/tests/integration/test_verify_blockscout.py @@ -0,0 +1,37 @@ +import pytest +from unittest.mock import patch, MagicMock +import boa + +def test_contract_verification(): + """ + Tests the smart contract verification process. It simulates both successful and failed verification scenarios + using mocked API responses from Blockscout. + """ + code = """ + @external + def hello() -> String[32]: + return "Hello, World!" + """ + + mock_response = MagicMock() + mock_response.json.return_value = {"status": "1"} + mock_response.status_code = 200 + + with patch('requests.post', return_value=mock_response), \ + patch.dict('os.environ', {'BLOCKSCOUT_API_KEY': '...'}): + contract = boa.loads(code, explorer="blockscout") + + assert isinstance(contract, boa.contracts.vyper.vyper_contract.VyperContract), "Contract deployment failed or returned an incorrect type" + assert contract.hello() == "Hello, World!", "Contract function 'hello()' returned an unexpected result" + + # Simulate a failed verification response + mock_response.json.return_value = {"status": "0", "message": "Verification failed"} + mock_response.status_code = 400 + with patch('requests.post', return_value=mock_response), \ + patch.dict('os.environ', {'BLOCKSCOUT_API_KEY': '...'}): + contract = boa.loads(code, explorer="blockscout") + # Add appropriate checks for failed verification + + # Test missing API key scenario + with patch.dict('os.environ', {}, clear=True): + contract = boa.loads(code, explorer="blockscout") \ No newline at end of file diff --git a/tests/unitary/contracts/vyper/test_vyper_contract.py b/tests/unitary/contracts/vyper/test_vyper_contract.py index b522e935..9b4f90ec 100644 --- a/tests/unitary/contracts/vyper/test_vyper_contract.py +++ b/tests/unitary/contracts/vyper/test_vyper_contract.py @@ -1,6 +1,5 @@ import boa - def test_decode_struct(): code = """ struct Point: @@ -71,4 +70,4 @@ def foo() -> bool: """ c = boa.loads(code) - c.foo() + c.foo() \ No newline at end of file