From 6298a5a9ef58d94977a84f0986d6462542db3aca Mon Sep 17 00:00:00 2001 From: Manuel Wedler Date: Tue, 28 Jul 2026 12:40:27 +0200 Subject: [PATCH] feat(client): fetch contract ABIs from Sourcify, fall back to Etherscan Sourcify is now tried first when fetching a reference ABI, and Etherscan is only used when Sourcify has nothing for the contract. Sourcify needs no API key, so ABI validation and descriptor generation now work out of the box, including on chains Etherscan does not cover. Sourcify returning 404 (contract not verified) or 400 (chain not supported) falls through to Etherscan. If both sources fail, the raised error names both causes, and linters keep skipping ABI validation with a "Could not fetch ABI" warning as before. --- docs/pages/lint.md | 2 +- docs/pages/usage_cli.md | 8 ++- src/erc7730/common/client.py | 59 ++++++++++++++++++- src/erc7730/generate/generate.py | 3 +- .../v2/lint_transaction_type_classifier.py | 4 +- .../lint/v2/lint_validate_display_fields.py | 10 ++-- src/erc7730/main.py | 2 +- tests/common/test_client.py | 22 +++++++ tests/conftest.py | 2 +- 9 files changed, 96 insertions(+), 16 deletions(-) diff --git a/docs/pages/lint.md b/docs/pages/lint.md index 90a899fe..39708fe2 100644 --- a/docs/pages/lint.md +++ b/docs/pages/lint.md @@ -3,7 +3,7 @@ ### Could not fetch ABI - **Level**: ⚠️ Warning - **Message**: `Fetching reference ABI for chain id failed, descriptor ABIs will not be validated: ` -- **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 diff --git a/docs/pages/usage_cli.md b/docs/pages/usage_cli.md index 431776fe..e70790c6 100644 --- a/docs/pages/usage_cli.md +++ b/docs/pages/usage_cli.md @@ -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 @@ -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 ``` diff --git a/src/erc7730/common/client.py b/src/erc7730/common/client.py index 00116dbc..b99ca062 100644 --- a/src/erc7730/common/client.py +++ b/src/erc7730/common/client.py @@ -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 @@ -21,6 +21,7 @@ # ruff: noqa: UP047 ETHERSCAN = "api.etherscan.io" +SOURCIFY = "sourcify.dev" _T = TypeVar("_T") @@ -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]: """ @@ -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( diff --git a/src/erc7730/generate/generate.py b/src/erc7730/generate/generate.py index 3e733c13..6b1c0149 100644 --- a/src/erc7730/generate/generate.py +++ b/src/erc7730/generate/generate.py @@ -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 diff --git a/src/erc7730/lint/v2/lint_transaction_type_classifier.py b/src/erc7730/lint/v2/lint_transaction_type_classifier.py index 3486b79a..bcab4e9b 100644 --- a/src/erc7730/lint/v2/lint_transaction_type_classifier.py +++ b/src/erc7730/lint/v2/lint_transaction_type_classifier.py @@ -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 @@ -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 diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index 21a15c79..150d8265 100644 --- a/src/erc7730/lint/v2/lint_validate_display_fields.py +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -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). """ @@ -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 @@ -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: diff --git a/src/erc7730/main.py b/src/erc7730/main.py index 14c51c1d..13c04979 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -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)") diff --git a/tests/common/test_client.py b/tests/common/test_client.py index 0dcf4791..229b45b9 100644 --- a/tests/common/test_client.py +++ b/tests/common/test_client.py @@ -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( diff --git a/tests/conftest.py b/tests/conftest.py index ac50d406..e1ba5e94 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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.", )