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.", )