Skip to content

Commit

Permalink
feat: etherscan dependencies
Browse files Browse the repository at this point in the history
  • Loading branch information
antazoey committed Feb 1, 2024
1 parent 89b7d26 commit 52b4582
Show file tree
Hide file tree
Showing 7 changed files with 152 additions and 10 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,24 @@ etherscan:
uri: https://custom.scan
api_uri: https://api.custom.scan/api
```
## Dependencies
You can use dependencies from Etherscan in your projects.
Configure them like this:
```yaml
dependencies:
- name: Spork
etherscan: "0xb624FdE1a972B1C89eC1dAD691442d5E8E891469"
ecosystem: ethereum
network: mainnet
```
Then, access contract types from the dependency in your code:
```python
from ape import project

spork_contract_type = project.dependencies["Spork"]["etherscan"].Spork
```
6 changes: 6 additions & 0 deletions ape_etherscan/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from ape import plugins

from .config import EtherscanConfig
from .dependency import EtherscanDependency
from .explorer import Etherscan
from .query import EtherscanQueryEngine
from .utils import NETWORKS
Expand All @@ -22,3 +23,8 @@ def query_engines():
@plugins.register(plugins.Config)
def config_class():
return EtherscanConfig


@plugins.register(plugins.DependencyPlugin)
def dependencies():
yield "etherscan", EtherscanDependency
4 changes: 1 addition & 3 deletions ape_etherscan/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,7 @@ def get_source_code(self) -> SourceCodeResponse:
if not isinstance(data, dict):
raise UnhandledResultError(result, data)

abi = data.get("ABI") or ""
name = data.get("ContractName") or "unknown"
return SourceCodeResponse(abi, name)
return SourceCodeResponse.model_validate(data)

def verify_source_code(
self,
Expand Down
73 changes: 73 additions & 0 deletions ape_etherscan/dependency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import tempfile
from pathlib import Path

import yaml
from ape.api.projects import DependencyAPI
from ape.types import AddressType
from ethpm_types import PackageManifest
from pydantic import AnyUrl, HttpUrl

from .explorer import Etherscan


class EtherscanDependency(DependencyAPI):
etherscan: str
ecosystem: str = "ethereum"
network: str = "mainnet"

@property
def version_id(self) -> str:
return "etherscan" # Only 1 version

@property
def address(self) -> AddressType:
return self.network_manager.ethereum.decode_address(self.etherscan)

@property
def uri(self) -> AnyUrl:
return HttpUrl(f"{self.explorer.get_address_url(self.address)}#code")

@property
def explorer(self) -> Etherscan:
if self.network_manager.active_provider:
explorer = self.provider.network.explorer
if isinstance(explorer, Etherscan):
# Could be using a different network.
return explorer
else:
return self.network_manager.ethereum.mainnet.explorer

# Assume Ethereum
return self.network_manager.ethereum.mainnet.explorer

def extract_manifest(self, use_cache: bool = True) -> PackageManifest:
ecosystem = self.network_manager.get_ecosystem(self.ecosystem)
network = ecosystem.get_network(self.network)

ctx = None
if self.network_manager.active_provider is None:
ctx = network.use_default_provider()
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)

# Ensure compiler settings match.
if response.evm_version and response.evm_version != "Default":
data = {"solidity": {"evm_version": response.evm_version}}
config_file = project_path / "ape-config.yaml"
with open(config_file, "w") as file:
yaml.safe_dump(data, file)

new_path = contracts_folder / f"{response.name}.sol"
new_path.write_text(response.source_code)
return self._extract_local_manifest(project_path, use_cache=use_cache)

finally:
if ctx:
ctx.__exit__(None)
19 changes: 16 additions & 3 deletions ape_etherscan/explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@
from ape.exceptions import ProviderNotConnectedError
from ape.logging import logger
from ape.types import AddressType, ContractType
from ethpm_types.source import Source

from ape_etherscan.client import ClientFactory, get_etherscan_api_uri, get_etherscan_uri
from ape_etherscan.client import (
ClientFactory,
SourceCodeResponse,
get_etherscan_api_uri,
get_etherscan_uri,
)
from ape_etherscan.types import EtherscanInstance
from ape_etherscan.verify import SourceVerifier

Expand Down Expand Up @@ -47,13 +53,20 @@ def _client_factory(self) -> ClientFactory:
)
)

def get_contract_type(self, address: AddressType) -> Optional[ContractType]:
def get_source(self, address: AddressType) -> Source:
code = self._get_source_code(address)
return Source(content=code.source_code)

def _get_source_code(self, address: AddressType) -> SourceCodeResponse:
if not self.conversion_manager.is_type(address, AddressType):
# Handle non-checksummed addresses
address = self.conversion_manager.convert(str(address), AddressType)

client = self._client_factory.get_contract_client(address)
source_code = client.get_source_code()
return client.get_source_code()

def get_contract_type(self, address: AddressType) -> Optional[ContractType]:
source_code = self._get_source_code(address)
if not (abi_string := source_code.abi):
return None

Expand Down
24 changes: 20 additions & 4 deletions ape_etherscan/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from typing import Dict, List, Union

from ape.utils import cached_property
from ethpm_types import BaseModel
from pydantic import Field, field_validator

from ape_etherscan.exceptions import EtherscanResponseError, get_request_error

Expand All @@ -17,10 +19,24 @@ class EtherscanInstance:
api_uri: str


@dataclass
class SourceCodeResponse:
abi: str = ""
name: str = "unknown"
class SourceCodeResponse(BaseModel):
abi: str = Field("", alias="ABI")
name: str = Field("unknown", alias="ContractName")
source_code: str = Field("", alias="SourceCode")
compiler_version: str = Field("", alias="CompilerVersion")
optimization_used: bool = Field(True, alias="OptimizationUsed")
optimization_runs: int = Field(200, alias="Runs")
evm_version: str = Field("Default", alias="EVMVersion")
library: str = Field("", alias="Library")
license_type: str = Field("", alias="LicenseType")
proxy: bool = Field(False, alias="Proxy")
implementation: str = Field("", alias="Implementation")
swarm_source: str = Field("", alias="SwarmSource")

@field_validator("optimization_used", "proxy", mode="before")
@classmethod
def validate_bools(cls, value):
return bool(int(value))


@dataclass
Expand Down
15 changes: 15 additions & 0 deletions tests/test_dependency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from ape_etherscan.dependency import EtherscanDependency


def test_dependency(mock_backend):
mock_backend.set_network("ethereum", "mainnet")
mock_backend.setup_mock_get_contract_type_response("get_contract_response")

dependency = EtherscanDependency(
name="Apes",
etherscan="0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512",
ecosystem="ethereum",
network="mainnet",
)
actual = dependency.extract_manifest()
assert "BoredApeYachtClub.sol" in actual.sources

0 comments on commit 52b4582

Please sign in to comment.