Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/pages/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
### Could not fetch ABI
- **Level**: ⚠️ Warning
- **Message**: `Fetching reference ABI for chain id <chain_id> failed, descriptor ABIs will not be validated: <error>`
- **Description**: ABI fetch from external source (Etherscan or equivalent) has failed. Subsequents checks are skipped for the current deployment.
- **Description**: ABI fetch from external sources (Sourcify, then Etherscan as a fallback) has failed. Subsequents checks are skipped for the current deployment.

### Proxy Contract
- **Level**: ⚠️ Warning
Expand Down
8 changes: 5 additions & 3 deletions docs/pages/usage_cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,13 @@ checked 61 descriptor files, some errors found ❌

It can be called with single files or directories, in which case all descriptors will be checked.

Use `--skip-abi-validation` to disable external ABI comparisons against Etherscan (useful for offline runs or faster local checks).
Use `--skip-abi-validation` to disable external ABI comparisons against Sourcify/Etherscan (useful for offline runs or faster local checks).

### `erc7730 generate`

The `generate` command bootstraps a new descriptor file from ABIs or message schemas:
```shell
# fetch ABIs from etherscan and generate a new calldata descriptor
# fetch ABIs from sourcify/etherscan and generate a new calldata descriptor
erc7730 generate --chain-id=1 --address=0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45

# generate a new calldata descriptor using given ABI file
Expand All @@ -89,7 +89,9 @@ erc7730 generate --chain-id=1 --address=0x00000000000000000000000000000000000000
erc7730 generate --chain-id=1 --address=0x0000000000000000000000000000000000000000 --schema schemas.json
```

To fetch ABIs automatically, you will need to [setup an Etherscan API key](https://docs.etherscan.io/getting-started/viewing-api-usage-statistics):
ABIs are fetched from [Sourcify](https://sourcify.dev) first, which requires no API key. If the contract is not
verified on Sourcify, Etherscan is used as a fallback, which requires
[setting up an Etherscan API key](https://docs.etherscan.io/getting-started/viewing-api-usage-statistics):
```shell
export ETHERSCAN_API_KEY=XXXXXX
```
Expand Down
59 changes: 57 additions & 2 deletions src/erc7730/common/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import Any, TypeVar, final, override

from hishel import CacheTransport, FileStorage
from httpx import URL, BaseTransport, Client, HTTPTransport, Request, Response
from httpx import URL, BaseTransport, Client, HTTPStatusError, HTTPTransport, Request, Response, codes
from httpx._content import IteratorByteStream
from httpx_file import FileTransport
from httpx_retries import RetryTransport
Expand All @@ -21,6 +21,7 @@
# ruff: noqa: UP047

ETHERSCAN = "api.etherscan.io"
SOURCIFY = "sourcify.dev"

_T = TypeVar("_T")

Expand All @@ -34,6 +35,13 @@ class EtherscanChain(Model):
blockexplorer: HttpUrl


class SourcifyContract(Model):
"""Sourcify verified contract, restricted to the fields used by this library."""

model_config = ConfigDict(strict=False, frozen=True, extra="ignore")
abi: list[ABI] | None = None


@cache
def get_supported_chains() -> list[EtherscanChain]:
"""
Expand All @@ -45,13 +53,60 @@ def get_supported_chains() -> list[EtherscanChain]:


def get_contract_abis(chain_id: int, contract_address: Address) -> list[ABI]:
"""
Get contract ABIs from Sourcify, falling back to Etherscan if the contract is not available on Sourcify.

:param chain_id: EIP-155 chain ID
:param contract_address: EVM contract address
:return: deserialized list of ABIs
:raises Exception: if contract source is not available, API key not setup, or unexpected response
"""
try:
if (abis := get_contract_abis_from_sourcify(chain_id, contract_address)) is not None:
return abis
sourcify_error = "contract source is not available on Sourcify"
except Exception as e:
sourcify_error = str(e)

try:
return get_contract_abis_from_etherscan(chain_id, contract_address)
except Exception as e:
raise Exception(f"{sourcify_error}, and fetching from Etherscan failed: {e}") from e


def get_contract_abis_from_sourcify(chain_id: int, contract_address: Address) -> list[ABI] | None:
"""
Get contract ABIs from Sourcify.

:param chain_id: EIP-155 chain ID
:param contract_address: EVM contract address
:return: deserialized list of ABIs, or None if chain is not supported or contract source is not available
:raises Exception: if unexpected response
"""
try:
return get(
url=HttpUrl(f"https://{SOURCIFY}/server/v2/contract/{chain_id}/{contract_address}"),
fields="abi",
model=SourcifyContract,
).abi
except HTTPStatusError as e:
if e.response.status_code == codes.NOT_FOUND:
return None # contract source is not available on Sourcify
if e.response.status_code == codes.BAD_REQUEST:
return None # chain id is not supported by Sourcify
if e.response.status_code == codes.TOO_MANY_REQUESTS:
raise Exception("Sourcify rate limit exceeded, please retry") from e
raise e


def get_contract_abis_from_etherscan(chain_id: int, contract_address: Address) -> list[ABI]:
"""
Get contract ABIs from Etherscan.

:param chain_id: EIP-155 chain ID
:param contract_address: EVM contract address
:return: deserialized list of ABIs
:raises NotImplementedError: if chain id not supported, API key not setup, or unexpected response
:raises Exception: if chain id not supported, API key not setup, or unexpected response
"""
try:
return get(
Expand Down
3 changes: 2 additions & 1 deletion src/erc7730/generate/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ def generate_descriptor(
Generate an ERC-7730 descriptor.
If an EIP-712 schema is provided, an EIP-712 descriptor is generated for this schema, otherwise a calldata
descriptor. If no ABI is supplied, the ABIs are fetched from Etherscan using the chain id / contract address.
descriptor. If no ABI is supplied, the ABIs are fetched from Sourcify or Etherscan using the chain id / contract
address.
:param chain_id: contract chain id
:param contract_address: contract address
Expand Down
4 changes: 2 additions & 2 deletions src/erc7730/lint/v2/lint_transaction_type_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

In v2, classification relies on:
- For EIP-712 context: the format key (primaryType) — e.g., "Permit*" → PERMIT
- For contract context: the fetched Etherscan ABI (via ABIClassifier, currently unimplemented)
- For contract context: the fetched reference ABI (via ABIClassifier, currently unimplemented)
"""

from typing import final, override
Expand All @@ -24,7 +24,7 @@ class ClassifyTransactionTypeLinter(ERC7730Linter):
Classifies transaction type from context/format and validates expected display fields.

For EIP-712: classifies by format key (primaryType). If "permit" found in format key, classifies as PERMIT.
For contract: classifies from fetched Etherscan ABI using ABIClassifier.
For contract: classifies from fetched reference ABI using ABIClassifier.
"""

@override
Expand Down
10 changes: 5 additions & 5 deletions src/erc7730/lint/v2/lint_validate_display_fields.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""
V2 linter that validates display fields against reference ABIs fetched from Etherscan.
V2 linter that validates display fields against reference ABIs fetched from Sourcify or Etherscan.

In v2, ABI and EIP-712 schemas are NOT embedded in the descriptor. Instead:
- For contract context: fetch ABI from Etherscan, validate display field paths match ABI params,
- For contract context: fetch ABI from Sourcify or Etherscan, validate display field paths match ABI params,
and check selector exhaustiveness.
- For EIP-712 context: no schema to validate against (no-op).
"""
Expand All @@ -24,10 +24,10 @@
@final
class ValidateDisplayFieldsLinter(ERC7730Linter):
"""
Validates display fields against reference ABIs fetched from Etherscan.
Validates display fields against reference ABIs fetched from Sourcify or Etherscan.

For contract context:
- Fetches ABI from Etherscan for each deployment
- Fetches ABI from Sourcify or Etherscan for each deployment
- Validates that display field paths exist in the ABI
- Validates that all ABI function params have display fields
- Checks that all selectors in the ABI have corresponding display formats
Expand All @@ -53,7 +53,7 @@ def _validate_contract_display_fields(cls, descriptor: ResolvedERC7730Descriptor
if (deployments := context.contract.deployments) is None:
return

# Try to fetch ABI from Etherscan for the first deployment that succeeds
# Try to fetch ABI from Sourcify or Etherscan for the first deployment that succeeds
reference_abis = None
explorer_url = None
for deployment in deployments:
Expand Down
2 changes: 1 addition & 1 deletion src/erc7730/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def command_lint(
paths: Annotated[list[Path], Argument(help="The files or directory paths to lint")],
gha: Annotated[bool, Option(help="Enable Github annotations output")] = False,
skip_abi_validation: Annotated[
bool, Option("--skip-abi-validation", help="Skip ABI comparison with Etherscan explorer data")
bool, Option("--skip-abi-validation", help="Skip ABI comparison with Sourcify/Etherscan reference data")
] = False,
v2: Annotated[
bool, Option("--v2", help="Use v2 model for validation (auto-detected from $schema if not set)")
Expand Down
22 changes: 22 additions & 0 deletions tests/common/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ def test_get_contract_abis() -> None:
assert len(result) > 0


def test_get_contract_abis_from_sourcify() -> None:
result = client.get_contract_abis_from_sourcify(
chain_id=1, contract_address="0x06012c8cf97bead5deae237070f9587f8e7a266d"
)
assert result is not None
assert len(result) > 0


def test_get_contract_abis_from_sourcify_unverified_contract() -> None:
result = client.get_contract_abis_from_sourcify(
chain_id=1, contract_address="0x0000000000000000000000000000000000000001"
)
assert result is None


def test_get_contract_abis_from_sourcify_unsupported_chain() -> None:
result = client.get_contract_abis_from_sourcify(
chain_id=99999999, contract_address="0x06012c8cf97bead5deae237070f9587f8e7a266d"
)
assert result is None


def test_get_from_github() -> None:
result1 = client.get(
url=HttpUrl(
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ def pytest_addoption(parser: pytest.Parser) -> None:
"--skip-abi-validation",
action="store_true",
default=False,
help="Skip Etherscan ABI validation in lint-related tests.",
help="Skip reference ABI validation in lint-related tests.",
)


Expand Down
Loading