Skip to content

Commit

Permalink
feat: work with JsON approach too
Browse files Browse the repository at this point in the history
  • Loading branch information
antazoey committed Feb 1, 2024
1 parent c6e28b6 commit 895c690
Show file tree
Hide file tree
Showing 10 changed files with 165 additions and 50 deletions.
2 changes: 1 addition & 1 deletion ape_etherscan/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ def get_source_code(self) -> SourceCodeResponse:
}
result = self._get(params=params)

if not (result_list := result.value or []):
if not (result_list := result.value):
return SourceCodeResponse()

elif len(result_list) > 1:
Expand Down
38 changes: 11 additions & 27 deletions ape_etherscan/dependency.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import tempfile
from pathlib import Path

from ape.api.projects import DependencyAPI
from ape.types import AddressType
from ethpm_types import Compiler, PackageManifest
from pydantic import AnyUrl, HttpUrl
from ethpm_types import PackageManifest
from hexbytes import HexBytes
from pydantic import AnyUrl, HttpUrl, field_validator

from .explorer import Etherscan

Expand All @@ -14,6 +12,11 @@ class EtherscanDependency(DependencyAPI):
ecosystem: str = "ethereum"
network: str = "mainnet"

@field_validator("etherscan", mode="before")
@classmethod
def handle_int(cls, value):
return value if isinstance(value, str) else HexBytes(value).hex()

@property
def version_id(self) -> str:
return "etherscan" # Only 1 version
Expand Down Expand Up @@ -49,28 +52,9 @@ def extract_manifest(self, use_cache: bool = True) -> PackageManifest:
ctx.__enter__()

try:
with tempfile.TemporaryDirectory() as temp_dir:
project_path = Path(temp_dir).resolve()
contracts_folder = project_path / "contracts"
contracts_folder.mkdir()
response = self.explorer._get_source_code(self.address)
compiler = Compiler(
name="Solidity",
version=response.compiler_version,
settings={
"optimizer": {
"enabled": response.optimization_used,
"runs": response.optimization_runs,
},
},
contractTypes=[response.name],
)
new_path = contracts_folder / f"{response.name}.sol"
new_path.write_text(response.source_code)
manifest = self._extract_local_manifest(project_path, use_cache=use_cache)
manifest.compilers = [compiler]
return manifest

manifest = self.explorer.get_manifest(self.address)
finally:
if ctx:
ctx.__exit__(None)

return manifest
42 changes: 39 additions & 3 deletions ape_etherscan/explorer.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import json
from typing import Optional

from ape.api import ExplorerAPI, PluginConfig
from ape.contracts import ContractInstance
from ape.exceptions import ProviderNotConnectedError
from ape.types import AddressType, ContractType
from ethpm_types import Compiler, PackageManifest
from ethpm_types.source import Source

from ape_etherscan.client import (
Expand Down Expand Up @@ -50,9 +52,43 @@ def _client_factory(self) -> ClientFactory:
)
)

def get_source(self, address: AddressType) -> Source:
code = self._get_source_code(address)
return Source(content=code.source_code)
def get_manifest(self, address: AddressType) -> PackageManifest:
response = self._get_source_code(address)
settings = {
"optimizer": {
"enabled": response.optimization_used,
"runs": response.optimization_runs,
},
}

code = response.source_code
if code.startswith("{"):
# JSON verified.
data = json.loads(code)
compiler = Compiler(
name=data.get("language", "Solidity"),
version=response.compiler_version,
settings=data.get("settings", settings),
contractTypes=[response.name],
)
source_data = data.get("sources", {})
sources = {
src_id: Source(content=cont.get("content", ""))
for src_id, cont in source_data.items()
}

else:
# A flattened source.
source_id = f"{response.name}.sol"
compiler = Compiler(
name="Solidity",
version=response.compiler_version,
settings=settings,
contractTypes=[response.name],
)
sources = {source_id: Source(content=response.source_code)}

return PackageManifest(compilers=[compiler], sources=sources)

def _get_source_code(self, address: AddressType) -> SourceCodeResponse:
if not self.conversion_manager.is_type(address, AddressType):
Expand Down
18 changes: 17 additions & 1 deletion ape_etherscan/types.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import re
from dataclasses import dataclass
from typing import Dict, List, Union

Expand Down Expand Up @@ -43,6 +44,21 @@ def validate_bools(cls, value):
def validate_abi(cls, value):
return json.loads(value)

@field_validator("source_code", mode="before")
@classmethod
def validate_source_code(cls, value):
if value.startswith("{"):
# NOTE: Have to deal with very poor JSON
# response from Etherscan.
fixed = re.sub(r"\r\n\s*", "", value)
fixed = re.sub(r"\r\n\s*", "", fixed)
if fixed.startswith("{{"):
fixed = fixed[1:-1]

return fixed

return value


@dataclass
class ContractCreationResponse:
Expand Down Expand Up @@ -70,7 +86,7 @@ def value(self) -> ResponseValue:

message = response_data.get("message", "")
is_error = response_data.get("isError", 0) or message == "NOTOK"
if is_error and self.raise_on_exceptions:
if is_error is True and self.raise_on_exceptions:
raise get_request_error(self.response, self.ecosystem)

result = response_data.get("result", message)
Expand Down
47 changes: 37 additions & 10 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ def address(contract_to_verify):
@pytest.fixture(scope="session")
def contract_address_map(address):
return {
"get_contract_response": address,
"get_contract_response_flattened": address,
"get_contract_response_json": "0x000075Dc60EdE898f11b0d5C6cA31D7A6D050eeD",
"get_proxy_contract_response": "0x55A8a39bc9694714E2874c1ce77aa1E599461E18",
"get_vyper_contract_response": "0xdA816459F1AB5631232FE5e97a05BBBb94970c95",
}
Expand Down Expand Up @@ -419,8 +420,20 @@ def side_effect(self):

def _get_contract_type_response(self, file_name: str) -> Any:
test_data_path = MOCK_RESPONSES_PATH / f"{file_name}.json"
with open(test_data_path) as response_data_file:
return self.get_mock_response(response_data_file, file_name=file_name)
assert test_data_path.is_file(), f"Setup failed - missing test data {file_name}"
if "flattened" in file_name:
with open(test_data_path) as response_data_file:
return self.get_mock_response(response_data_file, file_name=file_name)

else:
# NOTE: Since the JSON is messed up for these, we can' load the mocks
# even without a weird hack.
content = (
MOCK_RESPONSES_PATH / "get_contract_response_json_source_code.json"
).read_text()
data = json.loads(test_data_path.read_text())
data["SourceCode"] = content
return self.get_mock_response(data, file_name=file_name)

def _expected_get_ct_params(self, address: str) -> Dict:
return {"module": "contract", "action": "getsourcecode", "address": address}
Expand Down Expand Up @@ -462,23 +475,37 @@ def get_mock_response(
self, response_data: Optional[Union[IO, Dict, str, MagicMock]] = None, **kwargs
):
if isinstance(response_data, str):
return self.get_mock_response({"result": response_data})
return self.get_mock_response({"result": response_data, **kwargs})

elif isinstance(response_data, _io.TextIOWrapper):
return self.get_mock_response(json.load(response_data), **kwargs)

elif isinstance(response_data, MagicMock):
# Mock wasn't set.
response_data = {}
response_data = {**kwargs}

assert isinstance(response_data, dict)
return self._get_mock_response(response_data=response_data, **kwargs)

def _get_mock_response(
self,
response_data: Optional[Dict] = None,
response_text: Optional[str] = None,
*args,
**kwargs,
):
response = self.mocker.MagicMock(spec=Response)
assert isinstance(response_data, dict) # For mypy
overrides: Dict = kwargs.get("response_overrides", {})
response.json.return_value = {**response_data, **overrides}
response.text = json.dumps(response_data or {})
if response_data:
assert isinstance(response_data, dict) # For mypy
overrides: Dict = kwargs.get("response_overrides", {})
response.json.return_value = {**response_data, **overrides}
if not response_text:
response_text = json.dumps(response_data or {})

response.status_code = 200
if response_text:
response.text = response_text

response.status_code = 200
for key, val in kwargs.items():
setattr(response, key, val)

Expand Down

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions tests/mock_responses/get_contract_response_json.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"status": "1", "message": "OK", "result": [{"ABI": "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Approval\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"previousOwner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"OwnershipTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"}],\"name\":\"allowance\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"approve\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"account\",\"type\":\"address\"}],\"name\":\"balanceOf\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"internalType\":\"uint8\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"subtractedValue\",\"type\":\"uint256\"}],\"name\":\"decreaseAllowance\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"addedValue\",\"type\":\"uint256\"}],\"name\":\"increaseAllowance\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"name\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"owner\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"renounceOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"symbol\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"totalSupply\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transfer\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transferFrom\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"transferOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", "ContractName": "LOVEYOU", "CompilerVersion": "v0.8.9+commit.e5eed63a", "OptimizationUsed": "0", "Runs": "200", "ConstructorArguments": "", "EVMVersion": "Default", "Library": "", "LicenseType": "", "Proxy": "0", "Implementation": "", "SwarmSource": ""}]}
41 changes: 41 additions & 0 deletions tests/mock_responses/get_contract_response_json_source_code.json

Large diffs are not rendered by default.

19 changes: 14 additions & 5 deletions tests/test_dependency.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import pytest

from ape_etherscan.dependency import EtherscanDependency


def test_dependency(mock_backend):
@pytest.mark.parametrize(
"verification_type,contract_address,expected_name",
[
("flattened", "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", "BoredApeYachtClub"),
("json", "0x000075Dc60EdE898f11b0d5C6cA31D7A6D050eeD", "LOVEYOU"),
],
)
def test_dependency(mock_backend, verification_type, expected_name, contract_address):
mock_backend.set_network("ethereum", "mainnet")
mock_backend.setup_mock_get_contract_type_response("get_contract_response")
mock_backend.setup_mock_get_contract_type_response(f"get_contract_response_{verification_type}")

dependency = EtherscanDependency(
name="Apes",
etherscan="0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512",
etherscan=contract_address,
ecosystem="ethereum",
network="mainnet",
)
actual = dependency.extract_manifest()
assert "BoredApeYachtClub.sol" in actual.sources
assert f"{expected_name}.sol" in actual.sources
assert actual.compilers[0].name == "Solidity"
assert not actual.compilers[0].settings["optimizer"]["enabled"]
assert actual.compilers[0].contractTypes == ["BoredApeYachtClub"]
assert actual.compilers[0].contractTypes == [expected_name]
5 changes: 3 additions & 2 deletions tests/test_etherscan.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

# A map of each mock response to its contract name for testing `get_contract_type()`.
EXPECTED_CONTRACT_NAME_MAP = {
"get_contract_response": "BoredApeYachtClub",
"get_contract_response_flattened": "BoredApeYachtClub",
"get_contract_response_json": "BoredApeYachtClub",
"get_proxy_contract_response": "MIM-UST-f",
"get_vyper_contract_response": "yvDAI",
}
Expand Down Expand Up @@ -155,7 +156,7 @@ def test_get_contract_type_ecosystems_and_networks(
):
# This test parametrizes getting contract types across ecosystem / network combos
mock_backend.set_network(ecosystem, network)
response = mock_backend.setup_mock_get_contract_type_response("get_contract_response")
response = mock_backend.setup_mock_get_contract_type_response("get_contract_response_flattened")
explorer = get_explorer(ecosystem, network)
actual = explorer.get_contract_type(response.expected_address)
contract_type_from_lowered_address = explorer.get_contract_type(
Expand Down

0 comments on commit 895c690

Please sign in to comment.