From dfebf13638e7584a3cfe0810fde2d491c3a4b685 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Thu, 17 Sep 2026 10:42:03 +0200 Subject: [PATCH 01/21] feat(client): follow proxies when fetching reference ABIs from Sourcify Request proxyResolution along with the ABI and append the ABIs of every implementation (following nested proxies) to the ABIs of the proxy. An implementation that is not verified on Sourcify is a failure. Linters now validate against the merged ABIs instead of guessing proxies from function names and skipping validation. Co-Authored-By: Claude Fable 5.1 --- src/erc7730/common/abi.py | 5 +- src/erc7730/common/client.py | 63 ++++++++++++++++++- src/erc7730/lint/lint_validate_abi.py | 6 -- .../lint/v2/lint_validate_display_fields.py | 6 -- tests/common/test_client.py | 30 +++++++++ 5 files changed, 91 insertions(+), 19 deletions(-) diff --git a/src/erc7730/common/abi.py b/src/erc7730/common/abi.py index 1c496725..b53a4e33 100644 --- a/src/erc7730/common/abi.py +++ b/src/erc7730/common/abi.py @@ -128,7 +128,6 @@ def function_to_selector(abi: Function) -> str: @dataclass(kw_only=True) class Functions: functions: dict[str, Function] - proxy: bool _READ_ONLY_MUTABILITIES = frozenset({StateMutability.pure, StateMutability.view}) @@ -141,14 +140,12 @@ def get_functions(abis: list[ABI], *, include_read_only: bool = False) -> Functi :param include_read_only: if False (default), filter out pure/view functions that cannot produce transactions :return: Functions dataclass with selector->Function mapping """ - functions = Functions(functions={}, proxy=False) + functions = Functions(functions={}) for abi in abis: if abi.type == "function": if not include_read_only and abi.stateMutability in _READ_ONLY_MUTABILITIES: continue functions.functions[function_to_selector(abi)] = abi - if abi.name in ("proxyType", "getImplementation", "implementation", "proxy__getImplementation"): - functions.proxy = True return functions diff --git a/src/erc7730/common/client.py b/src/erc7730/common/client.py index b99ca062..c8a9e5f7 100644 --- a/src/erc7730/common/client.py +++ b/src/erc7730/common/client.py @@ -10,7 +10,7 @@ from httpx_file import FileTransport from httpx_retries import RetryTransport from limiter import Limiter -from pydantic import ConfigDict, TypeAdapter, ValidationError +from pydantic import ConfigDict, Field, TypeAdapter, ValidationError from pydantic_string_url import FileUrl, HttpUrl from xdg_base_dirs import xdg_cache_home @@ -19,6 +19,7 @@ from erc7730.model.types import Address # ruff: noqa: UP047 +# ruff: noqa: N815 - camel case field names are tolerated to match schema ETHERSCAN = "api.etherscan.io" SOURCIFY = "sourcify.dev" @@ -35,11 +36,31 @@ class EtherscanChain(Model): blockexplorer: HttpUrl +class SourcifyImplementation(Model): + """Sourcify proxy implementation, restricted to the fields used by this library.""" + + model_config = ConfigDict(strict=False, frozen=True, extra="ignore") + address: Address + + +class SourcifyProxyResolution(Model): + """Sourcify proxy resolution, restricted to the fields used by this library.""" + + model_config = ConfigDict(strict=False, frozen=True, extra="ignore") + isProxy: bool = False + implementations: list[SourcifyImplementation] = Field(default_factory=list) + + 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 + proxyResolution: SourcifyProxyResolution | None = None + + +class ProxyImplementationError(Exception): + """The ABIs of a proxy implementation could not be fetched.""" @cache @@ -56,6 +77,8 @@ 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. + Proxies are followed on Sourcify only, see `get_contract_abis_from_sourcify`. + :param chain_id: EIP-155 chain ID :param contract_address: EVM contract address :return: deserialized list of ABIs @@ -65,6 +88,8 @@ def get_contract_abis(chain_id: int, contract_address: Address) -> list[ABI]: 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 ProxyImplementationError: + raise # no fallback, Etherscan would only return the ABIs of the proxy except Exception as e: sourcify_error = str(e) @@ -78,17 +103,49 @@ def get_contract_abis_from_sourcify(chain_id: int, contract_address: Address) -> """ Get contract ABIs from Sourcify. + If Sourcify resolves the contract as a proxy, the ABIs of its implementations are appended to the ABIs of the + proxy, following nested proxies. + :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 ProxyImplementationError: if contract is a proxy and the ABIs of an implementation could not be fetched :raises Exception: if unexpected response """ + if (contract := _get_sourcify_contract(chain_id, contract_address)) is None or contract.abi is None: + return None + + abis = list(contract.abi) + visited = {contract_address.lower()} + pending = _get_implementation_addresses(contract) + while pending: + if (address := pending.pop(0)).lower() in visited: + continue + visited.add(address.lower()) + try: + implementation = _get_sourcify_contract(chain_id, address) + except Exception as e: + raise ProxyImplementationError(f"fetching proxy implementation {address} from Sourcify failed: {e}") from e + if implementation is None or implementation.abi is None: + raise ProxyImplementationError(f"proxy implementation {address} source is not available on Sourcify") + abis.extend(implementation.abi) + pending.extend(_get_implementation_addresses(implementation)) + return abis + + +def _get_implementation_addresses(contract: SourcifyContract) -> list[Address]: + if (resolution := contract.proxyResolution) is None or not resolution.isProxy: + return [] + return [implementation.address for implementation in resolution.implementations] + + +def _get_sourcify_contract(chain_id: int, contract_address: Address) -> SourcifyContract | None: try: return get( url=HttpUrl(f"https://{SOURCIFY}/server/v2/contract/{chain_id}/{contract_address}"), - fields="abi", + fields="abi,proxyResolution", model=SourcifyContract, - ).abi + ) except HTTPStatusError as e: if e.response.status_code == codes.NOT_FOUND: return None # contract source is not available on Sourcify diff --git a/src/erc7730/lint/lint_validate_abi.py b/src/erc7730/lint/lint_validate_abi.py index 68b6c0aa..dd8402f2 100644 --- a/src/erc7730/lint/lint_validate_abi.py +++ b/src/erc7730/lint/lint_validate_abi.py @@ -54,12 +54,6 @@ def _validate_contract_abis(cls, context: ResolvedContractContext, out: OutputAd except NotImplementedError: url = f"" - if reference_abis.proxy: - return out.info( - title="Proxy contract", - message=f"Contract {url} is likely to be a proxy, validation of descriptor ABIs skipped", - ) - for selector, abi in descriptor_abis.functions.items(): if selector not in reference_abis.functions: out.warning( diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index 86d6e82e..e4a6f310 100644 --- a/src/erc7730/lint/v2/lint_validate_display_fields.py +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -84,12 +84,6 @@ def _validate_contract_display_fields( if reference_abis is None: return - if reference_abis.proxy: - return out.info( - title="Proxy contract", - message=f"Contract {explorer_url} is likely to be a proxy, validation of display fields skipped", - ) - # Build ABI paths by selector abi_paths_by_selector: dict[str, set[DataPath]] = {} for selector, abi in reference_abis.functions.items(): diff --git a/tests/common/test_client.py b/tests/common/test_client.py index 24cec886..5031aee4 100644 --- a/tests/common/test_client.py +++ b/tests/common/test_client.py @@ -1,3 +1,4 @@ +import pytest from pydantic_string_url import HttpUrl from erc7730.common import client @@ -68,6 +69,35 @@ def test_get_contract_abis_from_sourcify_unsupported_chain() -> None: assert result is None +def test_get_contract_abis_from_sourcify_proxy() -> None: + # USDC is a proxy, transfer() is only defined in the ABI of its implementation + result = client.get_contract_abis_from_sourcify( + chain_id=1, contract_address="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + ) + assert result is not None + names = {abi.name for abi in result if abi.type == "function"} + assert "upgradeTo" in names + assert "transfer" in names + + +def test_get_contract_abis_unverified_proxy_implementation(monkeypatch: pytest.MonkeyPatch) -> None: + proxy = client.SourcifyContract.model_validate( + { + "abi": [], + "proxyResolution": { + "isProxy": True, + "implementations": [{"address": "0x0000000000000000000000000000000000000001"}], + }, + } + ) + real_get = client.get + monkeypatch.setattr( + client, "get", lambda model, url, **params: proxy if url.endswith("eb48") else real_get(model, url, **params) + ) + with pytest.raises(client.ProxyImplementationError, match="0x0000000000000000000000000000000000000001"): + client.get_contract_abis(chain_id=1, contract_address="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") + + def test_get_from_github() -> None: result1 = client.get( url=HttpUrl( From 6547881ea6b80810355b16a7bd3f1e9313d0685b Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Thu, 17 Sep 2026 10:55:14 +0200 Subject: [PATCH 02/21] feat(client): use Sourcify as the only source of reference ABIs and chains Remove the Etherscan fallback when fetching reference ABIs: a contract that is not verified on Sourcify is a failure. Reference ABIs come from Sourcify, so link to the contract on the Sourcify repository in lint messages and use the Sourcify chain list instead of the Etherscan one. The Etherscan transport stays, it is still needed for descriptors whose ABI is an Etherscan URL. Co-Authored-By: Claude Fable 5.1 --- docs/pages/lint.md | 2 +- docs/pages/usage_cli.md | 16 ++-- src/erc7730/common/client.py | 85 +++++-------------- src/erc7730/generate/generate.py | 2 +- .../lint/v2/lint_validate_display_fields.py | 10 +-- src/erc7730/main.py | 2 +- tests/common/test_client.py | 11 ++- 7 files changed, 43 insertions(+), 85 deletions(-) diff --git a/docs/pages/lint.md b/docs/pages/lint.md index 39708fe2..c4396aa0 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 sources (Sourcify, then Etherscan as a fallback) has failed. Subsequents checks are skipped for the current deployment. +- **Description**: ABI fetch from Sourcify 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 adadf384..f96b6692 100644 --- a/docs/pages/usage_cli.md +++ b/docs/pages/usage_cli.md @@ -67,9 +67,9 @@ excluded, please add it to `excluded` list to avoid this warning. ➡️ checking tether/calldata-usdt.json… 🟠 warning: Function mismatch: Function approve(address,uint256) (selector: 0x095ea7b3) defined in descriptor ABIs does not match -reference ABI (see https://etherscan.io/address/0xdac17f958d2ee523a2206206994597c13d831ec7#code) +reference ABI (see https://repo.sourcify.dev/1/0xdac17f958d2ee523a2206206994597c13d831ec7) 🟠 warning: Function mismatch: Function transfer(address,uint256) (selector: 0xa9059cbb) defined in descriptor ABIs does not match -reference ABI (see https://etherscan.io/address/0xdac17f958d2ee523a2206206994597c13d831ec7#code) +reference ABI (see https://repo.sourcify.dev/1/0xdac17f958d2ee523a2206206994597c13d831ec7) 🔴 error: Invalid data path: "0xdAC17F958D2ee523a2206206994597C13D831ec7" is invalid, it must contain a data path to the address in the transaction data. It seems you are trying to use a constant address value instead, please note this feature is not supported (yet). @@ -78,13 +78,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 Sourcify/Etherscan (useful for offline runs or faster local checks). +Use `--skip-abi-validation` to disable external ABI comparisons against Sourcify (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 sourcify/etherscan and generate a new calldata descriptor +# fetch ABIs from sourcify and generate a new calldata descriptor erc7730 generate --chain-id=1 --address=0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 # generate a new calldata descriptor using given ABI file @@ -94,12 +94,8 @@ erc7730 generate --chain-id=1 --address=0x00000000000000000000000000000000000000 erc7730 generate --chain-id=1 --address=0x0000000000000000000000000000000000000000 --schema schemas.json ``` -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 -``` +ABIs are fetched from [Sourcify](https://sourcify.dev), which requires no API key. The contract (and its +implementations, if it is a proxy) must be verified on Sourcify. Please note that while the generator does its best to guess the right format based on fields name/type, the generated descriptor should be considered a starting point to refine. diff --git a/src/erc7730/common/client.py b/src/erc7730/common/client.py index c8a9e5f7..520567bb 100644 --- a/src/erc7730/common/client.py +++ b/src/erc7730/common/client.py @@ -27,13 +27,13 @@ _T = TypeVar("_T") -class EtherscanChain(Model): - """Etherscan supported chain info.""" +class SourcifyChain(Model): + """Sourcify chain info, restricted to the fields used by this library.""" model_config = ConfigDict(strict=False, frozen=True, extra="ignore") - chainname: str - chainid: int - blockexplorer: HttpUrl + name: str + chainId: int + supported: bool = False class SourcifyImplementation(Model): @@ -59,44 +59,29 @@ class SourcifyContract(Model): proxyResolution: SourcifyProxyResolution | None = None -class ProxyImplementationError(Exception): - """The ABIs of a proxy implementation could not be fetched.""" - - @cache -def get_supported_chains() -> list[EtherscanChain]: +def get_supported_chains() -> list[SourcifyChain]: """ - Get supported chains from Etherscan. + Get supported chains from Sourcify. - :return: Etherscan supported chains, with name/chain id/block explorer URL + :return: Sourcify supported chains, with name/chain id """ - return get(url=HttpUrl(f"https://{ETHERSCAN}/v2/chainlist"), model=list[EtherscanChain]) + chains = get(url=HttpUrl(f"https://{SOURCIFY}/server/chains"), model=list[SourcifyChain]) + return [chain for chain in chains if chain.supported] 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. - - Proxies are followed on Sourcify only, see `get_contract_abis_from_sourcify`. + Get contract ABIs from 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 + :raises Exception: if contract source is not available, 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 ProxyImplementationError: - raise # no fallback, Etherscan would only return the ABIs of the proxy - 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 + if (abis := get_contract_abis_from_sourcify(chain_id, contract_address)) is None: + raise Exception("contract source is not available on Sourcify") + return abis def get_contract_abis_from_sourcify(chain_id: int, contract_address: Address) -> list[ABI] | None: @@ -109,7 +94,7 @@ def get_contract_abis_from_sourcify(chain_id: int, contract_address: Address) -> :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 ProxyImplementationError: if contract is a proxy and the ABIs of an implementation could not be fetched + :raises Exception: if contract is a proxy and the ABIs of an implementation could not be fetched :raises Exception: if unexpected response """ if (contract := _get_sourcify_contract(chain_id, contract_address)) is None or contract.abi is None: @@ -125,9 +110,9 @@ def get_contract_abis_from_sourcify(chain_id: int, contract_address: Address) -> try: implementation = _get_sourcify_contract(chain_id, address) except Exception as e: - raise ProxyImplementationError(f"fetching proxy implementation {address} from Sourcify failed: {e}") from e + raise Exception(f"fetching proxy implementation {address} from Sourcify failed: {e}") from e if implementation is None or implementation.abi is None: - raise ProxyImplementationError(f"proxy implementation {address} source is not available on Sourcify") + raise Exception(f"proxy implementation {address} source is not available on Sourcify") abis.extend(implementation.abi) pending.extend(_get_implementation_addresses(implementation)) return abis @@ -156,32 +141,6 @@ def _get_sourcify_contract(chain_id: int, contract_address: Address) -> Sourcify 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 Exception: if chain id not supported, API key not setup, or unexpected response - """ - try: - return get( - url=HttpUrl(f"https://{ETHERSCAN}/v2/api"), - chainid=chain_id, - module="contract", - action="getabi", - address=contract_address, - model=list[ABI], - ) - except Exception as e: - if "Contract source code not verified" in str(e): - raise Exception("contract source is not available on Etherscan") from e - if "Max calls per sec rate limit reached" in str(e): - raise Exception("Etherscan rate limit exceeded, please retry") from e - raise e - - def get_contract_explorer_url(chain_id: int, contract_address: Address) -> HttpUrl: """ Get contract explorer site URL (for opening in a browser). @@ -192,11 +151,9 @@ def get_contract_explorer_url(chain_id: int, contract_address: Address) -> HttpU :raises NotImplementedError: if chain id not supported """ for chain in get_supported_chains(): - if chain.chainid == chain_id: - return HttpUrl(f"{chain.blockexplorer}/address/{contract_address}#code") - raise NotImplementedError( - f"Chain ID {chain_id} is not supported, please report this to authors of python-erc7730 library" - ) + if chain.chainId == chain_id: + return HttpUrl(f"https://repo.{SOURCIFY}/{chain_id}/{contract_address}") + raise NotImplementedError(f"Chain ID {chain_id} is not supported by Sourcify") def get(model: type[_T], url: HttpUrl | FileUrl, **params: Any) -> _T: diff --git a/src/erc7730/generate/generate.py b/src/erc7730/generate/generate.py index 6b1c0149..4c942165 100644 --- a/src/erc7730/generate/generate.py +++ b/src/erc7730/generate/generate.py @@ -56,7 +56,7 @@ 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 Sourcify or Etherscan using the chain id / contract + descriptor. If no ABI is supplied, the ABIs are fetched from Sourcify using the chain id / contract address. :param chain_id: contract chain id diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index e4a6f310..9a25d387 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 Sourcify or Etherscan. +V2 linter that validates display fields against reference ABIs fetched from Sourcify. In v2, ABI and EIP-712 schemas are NOT embedded in the descriptor. Instead: - - For contract context: fetch ABI from Sourcify or Etherscan, validate display field paths match ABI params, + - For contract context: fetch ABI from Sourcify, validate display field paths match ABI params, and check selector exhaustiveness. - For EIP-712 context: no schema to validate against (no-op). """ @@ -26,10 +26,10 @@ @final class ValidateDisplayFieldsLinter(ERC7730Linter): """ - Validates display fields against reference ABIs fetched from Sourcify or Etherscan. + Validates display fields against reference ABIs fetched from Sourcify. For contract context: - - Fetches ABI from Sourcify or Etherscan for each deployment + - Fetches ABI from Sourcify 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 @@ -59,7 +59,7 @@ def _validate_contract_display_fields( if (deployments := context.contract.deployments) is None: return - # Try to fetch ABI from Sourcify or Etherscan for the first deployment that succeeds + # Try to fetch ABI from Sourcify 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 13c04979..15fbb4ba 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 Sourcify/Etherscan reference data") + bool, Option("--skip-abi-validation", help="Skip ABI comparison with Sourcify 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 5031aee4..3a0cc68f 100644 --- a/tests/common/test_client.py +++ b/tests/common/test_client.py @@ -9,9 +9,9 @@ def test_get_supported_chains() -> None: result = client.get_supported_chains() assert result is not None assert len(result) >= 50 - names = {chain.chainname for chain in result} + names = {chain.name for chain in result} assert "Ethereum Mainnet" in names - assert "Sepolia Testnet" in names + assert "Ethereum Sepolia Testnet" in names assert "BNB Smart Chain Mainnet" in names assert "BNB Smart Chain Testnet" in names assert "Polygon Mainnet" in names @@ -41,6 +41,11 @@ def test_get_supported_chains() -> None: assert "Taiko Mainnet" in names +def test_get_contract_explorer_url() -> None: + result = client.get_contract_explorer_url(chain_id=1, contract_address="0x06012c8cf97bead5deae237070f9587f8e7a266d") + assert result == "https://repo.sourcify.dev/1/0x06012c8cf97bead5deae237070f9587f8e7a266d" + + def test_get_contract_abis() -> None: result = client.get_contract_abis(chain_id=1, contract_address="0x06012c8cf97bead5deae237070f9587f8e7a266d") assert result is not None @@ -94,7 +99,7 @@ def test_get_contract_abis_unverified_proxy_implementation(monkeypatch: pytest.M monkeypatch.setattr( client, "get", lambda model, url, **params: proxy if url.endswith("eb48") else real_get(model, url, **params) ) - with pytest.raises(client.ProxyImplementationError, match="0x0000000000000000000000000000000000000001"): + with pytest.raises(Exception, match="0x0000000000000000000000000000000000000001"): client.get_contract_abis(chain_id=1, contract_address="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") From 290f0e24b676b5d8c3288d798eb5bf82c73d5a07 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Thu, 17 Sep 2026 11:15:10 +0200 Subject: [PATCH 03/21] feat(client): cache Sourcify responses and reference ABIs Sourcify responses have no caching headers, so they were never stored in the HTTP cache and every lint run fetched every contract again. Force caching (responses still expire with the storage TTL, 7 days). Also memoize get_contract_abis, as reference ABIs of a deployment are fetched by several linters during the same run. Co-Authored-By: Claude Fable 5.1 --- src/erc7730/common/client.py | 7 +++++-- tests/common/test_client.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/erc7730/common/client.py b/src/erc7730/common/client.py index 520567bb..c42ef9ec 100644 --- a/src/erc7730/common/client.py +++ b/src/erc7730/common/client.py @@ -4,7 +4,7 @@ from functools import cache from typing import Any, TypeVar, final, override -from hishel import CacheTransport, FileStorage +from hishel import CacheTransport, Controller, FileStorage from httpx import URL, BaseTransport, Client, HTTPStatusError, HTTPTransport, Request, Response, codes from httpx._content import IteratorByteStream from httpx_file import FileTransport @@ -70,6 +70,7 @@ def get_supported_chains() -> list[SourcifyChain]: return [chain for chain in chains if chain.supported] +@cache def get_contract_abis(chain_id: int, contract_address: Address) -> list[ABI]: """ Get contract ABIs from Sourcify. @@ -183,11 +184,13 @@ def _client() -> Client: :return: """ cache_storage = FileStorage(base_path=xdg_cache_home() / "erc7730", ttl=7 * 24 * 3600, check_ttl_every=24 * 3600) + # Sourcify responses have no caching headers, force caching so that they are stored (until storage TTL expires) + cache_controller = Controller(force_cache=True) http_transport = HTTPTransport() http_transport = GithubTransport(http_transport) http_transport = EtherscanTransport(http_transport) http_transport = RetryTransport(transport=http_transport) - http_transport = CacheTransport(transport=http_transport, storage=cache_storage) + http_transport = CacheTransport(transport=http_transport, storage=cache_storage, controller=cache_controller) file_transport = FileTransport() # TODO file storage: authorize relative paths only transports = {"https://": http_transport, "file://": file_transport} diff --git a/tests/common/test_client.py b/tests/common/test_client.py index 3a0cc68f..9af896c1 100644 --- a/tests/common/test_client.py +++ b/tests/common/test_client.py @@ -96,6 +96,7 @@ def test_get_contract_abis_unverified_proxy_implementation(monkeypatch: pytest.M } ) real_get = client.get + client.get_contract_abis.cache_clear() monkeypatch.setattr( client, "get", lambda model, url, **params: proxy if url.endswith("eb48") else real_get(model, url, **params) ) From 0b8a867d6dbab6c8ea10da6ef6721915ef43d963 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Mon, 21 Sep 2026 08:53:15 +0200 Subject: [PATCH 04/21] fix(client): fail on Sourcify proxy resolution errors Sourcify computes proxy resolution at request time and reports a failure in the response body with HTTP 200. Such a response was parsed as "not a proxy", validating the descriptor against the proxy ABI alone. Co-Authored-By: Claude Fable 5.1 --- src/erc7730/common/client.py | 15 ++++++++++++++- tests/common/test_client.py | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/erc7730/common/client.py b/src/erc7730/common/client.py index c42ef9ec..88c9d381 100644 --- a/src/erc7730/common/client.py +++ b/src/erc7730/common/client.py @@ -43,12 +43,21 @@ class SourcifyImplementation(Model): address: Address +class SourcifyError(Model): + """Sourcify error, restricted to the fields used by this library.""" + + model_config = ConfigDict(strict=False, frozen=True, extra="ignore") + customCode: str + message: str + + class SourcifyProxyResolution(Model): """Sourcify proxy resolution, restricted to the fields used by this library.""" model_config = ConfigDict(strict=False, frozen=True, extra="ignore") isProxy: bool = False implementations: list[SourcifyImplementation] = Field(default_factory=list) + proxyResolutionError: SourcifyError | None = None class SourcifyContract(Model): @@ -127,7 +136,7 @@ def _get_implementation_addresses(contract: SourcifyContract) -> list[Address]: def _get_sourcify_contract(chain_id: int, contract_address: Address) -> SourcifyContract | None: try: - return get( + contract = get( url=HttpUrl(f"https://{SOURCIFY}/server/v2/contract/{chain_id}/{contract_address}"), fields="abi,proxyResolution", model=SourcifyContract, @@ -140,6 +149,10 @@ def _get_sourcify_contract(chain_id: int, contract_address: Address) -> Sourcify if e.response.status_code == codes.TOO_MANY_REQUESTS: raise Exception("Sourcify rate limit exceeded, please retry") from e raise e + # proxy resolution is computed at request time, a failure must not be mistaken for a regular contract + if (resolution := contract.proxyResolution) is not None and (error := resolution.proxyResolutionError) is not None: + raise Exception(f"Sourcify could not resolve whether {contract_address} is a proxy: {error.message}") + return contract def get_contract_explorer_url(chain_id: int, contract_address: Address) -> HttpUrl: diff --git a/tests/common/test_client.py b/tests/common/test_client.py index 9af896c1..2e678056 100644 --- a/tests/common/test_client.py +++ b/tests/common/test_client.py @@ -104,6 +104,24 @@ def test_get_contract_abis_unverified_proxy_implementation(monkeypatch: pytest.M client.get_contract_abis(chain_id=1, contract_address="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") +def test_get_contract_abis_proxy_resolution_error(monkeypatch: pytest.MonkeyPatch) -> None: + contract = client.SourcifyContract.model_validate( + { + "abi": [], + "proxyResolution": { + "proxyResolutionError": { + "customCode": "proxy_resolution_error", + "message": "Error while running proxy detection and implementation resolution", + } + }, + } + ) + client.get_contract_abis.cache_clear() + monkeypatch.setattr(client, "get", lambda model, url, **params: contract) + with pytest.raises(Exception, match="could not resolve whether"): + client.get_contract_abis(chain_id=1, contract_address="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") + + def test_get_from_github() -> None: result1 = client.get( url=HttpUrl( From efc7914f2a49f66ef169c34b5c0d0f292c68710f Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Mon, 21 Sep 2026 09:06:12 +0200 Subject: [PATCH 05/21] feat(client): distinguish reference ABI failure modes Raise ContractNotVerifiedError, ProxyImplementationNotVerifiedError and ChainNotSupportedError from the client, reading Sourcify's customCode on HTTP 400, and give each its own title in the linters. "Could not fetch ABI" is kept for transient failures. Remove the unreachable "no ABI" branches, and document the new checks in place of the removed proxy one. Co-Authored-By: Claude Fable 5.1 --- docs/pages/lint.md | 22 +++++-- src/erc7730/common/client.py | 61 +++++++++++++------ src/erc7730/generate/generate.py | 4 +- src/erc7730/lint/lint_validate_abi.py | 16 +++-- .../lint/v2/lint_validate_display_fields.py | 16 +++-- tests/common/test_client.py | 18 +++--- 6 files changed, 94 insertions(+), 43 deletions(-) diff --git a/docs/pages/lint.md b/docs/pages/lint.md index c4396aa0..f45ca9e2 100644 --- a/docs/pages/lint.md +++ b/docs/pages/lint.md @@ -1,14 +1,24 @@ # Linter checks list ## ABI checks -### Could not fetch ABI +### Contract not verified - **Level**: ⚠️ Warning -- **Message**: `Fetching reference ABI for chain id failed, descriptor ABIs will not be validated: ` -- **Description**: ABI fetch from Sourcify has failed. Subsequents checks are skipped for the current deployment. +- **Message**: `contract
on chain is not verified on Sourcify, descriptor ABIs will not be validated` +- **Description**: The contract is not verified on Sourcify, so there is no reference ABI. Subsequent checks are skipped for the current deployment. -### Proxy Contract +### Proxy implementation not verified - **Level**: ⚠️ Warning -- **Message**: `Contract is likely to be a proxy, validation of descriptor ABIs skipped` -- **Description**: Contract detected as a potential proxy contract based on a simple heuristic. Subsequents checks are skipped. +- **Message**: `contract
on chain is a proxy, and its implementation
is not verified on Sourcify, descriptor ABIs will not be validated` +- **Description**: Sourcify resolved the contract as a proxy, but one of its implementations is not verified, so the reference ABI would be incomplete. Subsequent checks are skipped for the current deployment. + +### Chain not supported +- **Level**: ℹ️ Info +- **Message**: `chain is not supported by Sourcify, descriptor ABIs will not be validated` +- **Description**: Sourcify does not support the chain of the deployment, so no reference ABI can be fetched. Subsequent checks are skipped for the current deployment. + +### Could not fetch ABI +- **Level**: ⚠️ Warning +- **Message**: `Fetching reference ABI for chain id failed, descriptor ABIs will not be validated: ` +- **Description**: ABI fetch from Sourcify has failed for another reason, such as a rate limit, a network error or a proxy resolution error. Subsequent checks are skipped for the current deployment. ### Extra function - **Level**: ⚠️ Warning diff --git a/src/erc7730/common/client.py b/src/erc7730/common/client.py index 88c9d381..beb393e7 100644 --- a/src/erc7730/common/client.py +++ b/src/erc7730/common/client.py @@ -64,10 +64,22 @@ 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 + abi: list[ABI] proxyResolution: SourcifyProxyResolution | None = None +class ContractNotVerifiedError(Exception): + """Contract is not verified on Sourcify.""" + + +class ProxyImplementationNotVerifiedError(ContractNotVerifiedError): + """Contract is a proxy, and one of its implementations is not verified on Sourcify.""" + + +class ChainNotSupportedError(Exception): + """Chain is not supported by Sourcify.""" + + @cache def get_supported_chains() -> list[SourcifyChain]: """ @@ -87,14 +99,14 @@ def get_contract_abis(chain_id: int, contract_address: Address) -> list[ABI]: :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, or unexpected response + :raises ContractNotVerifiedError: if contract, or one of its proxy implementations, is not verified on Sourcify + :raises ChainNotSupportedError: if chain is not supported by Sourcify + :raises Exception: if unexpected response """ - if (abis := get_contract_abis_from_sourcify(chain_id, contract_address)) is None: - raise Exception("contract source is not available on Sourcify") - return abis + return get_contract_abis_from_sourcify(chain_id, contract_address) -def get_contract_abis_from_sourcify(chain_id: int, contract_address: Address) -> list[ABI] | None: +def get_contract_abis_from_sourcify(chain_id: int, contract_address: Address) -> list[ABI]: """ Get contract ABIs from Sourcify. @@ -103,13 +115,13 @@ def get_contract_abis_from_sourcify(chain_id: int, contract_address: Address) -> :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 contract is a proxy and the ABIs of an implementation could not be fetched + :return: deserialized list of ABIs + :raises ContractNotVerifiedError: if contract is not verified on Sourcify + :raises ProxyImplementationNotVerifiedError: if contract is a proxy and an implementation is not verified + :raises ChainNotSupportedError: if chain is not supported by Sourcify :raises Exception: if unexpected response """ - if (contract := _get_sourcify_contract(chain_id, contract_address)) is None or contract.abi is None: - return None - + contract = _get_sourcify_contract(chain_id, contract_address) abis = list(contract.abi) visited = {contract_address.lower()} pending = _get_implementation_addresses(contract) @@ -119,10 +131,11 @@ def get_contract_abis_from_sourcify(chain_id: int, contract_address: Address) -> visited.add(address.lower()) try: implementation = _get_sourcify_contract(chain_id, address) - except Exception as e: - raise Exception(f"fetching proxy implementation {address} from Sourcify failed: {e}") from e - if implementation is None or implementation.abi is None: - raise Exception(f"proxy implementation {address} source is not available on Sourcify") + except ContractNotVerifiedError as e: + raise ProxyImplementationNotVerifiedError( + f"contract {contract_address} on chain {chain_id} is a proxy, and its implementation {address} is not " + f"verified on Sourcify" + ) from e abis.extend(implementation.abi) pending.extend(_get_implementation_addresses(implementation)) return abis @@ -134,7 +147,7 @@ def _get_implementation_addresses(contract: SourcifyContract) -> list[Address]: return [implementation.address for implementation in resolution.implementations] -def _get_sourcify_contract(chain_id: int, contract_address: Address) -> SourcifyContract | None: +def _get_sourcify_contract(chain_id: int, contract_address: Address) -> SourcifyContract: try: contract = get( url=HttpUrl(f"https://{SOURCIFY}/server/v2/contract/{chain_id}/{contract_address}"), @@ -143,9 +156,14 @@ def _get_sourcify_contract(chain_id: int, contract_address: Address) -> Sourcify ) except HTTPStatusError as e: if e.response.status_code == codes.NOT_FOUND: - return None # contract source is not available on Sourcify + raise ContractNotVerifiedError( + f"contract {contract_address} on chain {chain_id} is not verified on Sourcify" + ) from e if e.response.status_code == codes.BAD_REQUEST: - return None # chain id is not supported by Sourcify + error = _parse_sourcify_error(e.response) + if error is not None and error.customCode == "unsupported_chain": + raise ChainNotSupportedError(f"chain {chain_id} is not supported by Sourcify") from e + raise Exception(f"Sourcify rejected the request: {error.message if error else e}") from e if e.response.status_code == codes.TOO_MANY_REQUESTS: raise Exception("Sourcify rate limit exceeded, please retry") from e raise e @@ -155,6 +173,13 @@ def _get_sourcify_contract(chain_id: int, contract_address: Address) -> Sourcify return contract +def _parse_sourcify_error(response: Response) -> SourcifyError | None: + try: + return SourcifyError.model_validate_json(response.read()) + except ValidationError: + return None + + def get_contract_explorer_url(chain_id: int, contract_address: Address) -> HttpUrl: """ Get contract explorer site URL (for opening in a browser). diff --git a/src/erc7730/generate/generate.py b/src/erc7730/generate/generate.py index 4c942165..cad03638 100644 --- a/src/erc7730/generate/generate.py +++ b/src/erc7730/generate/generate.py @@ -109,8 +109,8 @@ def _generate_context_calldata( if abi is not None: abis = TypeAdapter(list[ABI]).validate_json(abi) - elif (abis := get_contract_abis(chain_id, contract_address)) is None: - raise Exception("Failed to fetch contract ABIs") + else: + abis = get_contract_abis(chain_id, contract_address) functions = list(get_functions(abis, include_read_only=True).functions.values()) diff --git a/src/erc7730/lint/lint_validate_abi.py b/src/erc7730/lint/lint_validate_abi.py index dd8402f2..26c0b748 100644 --- a/src/erc7730/lint/lint_validate_abi.py +++ b/src/erc7730/lint/lint_validate_abi.py @@ -36,14 +36,22 @@ def _validate_contract_abis(cls, context: ResolvedContractContext, out: OutputAd if (deployments := context.contract.deployments) is None: return for deployment in deployments: + skipped = "descriptor ABIs will not be validated" try: - if (abis := client.get_contract_abis(deployment.chainId, deployment.address)) is None: - continue + abis = client.get_contract_abis(deployment.chainId, deployment.address) + except client.ProxyImplementationNotVerifiedError as e: + out.warning(title="Proxy implementation not verified", message=f"{e}, {skipped}") + continue + except client.ContractNotVerifiedError as e: + out.warning(title="Contract not verified", message=f"{e}, {skipped}") + continue + except client.ChainNotSupportedError as e: + out.info(title="Chain not supported", message=f"{e}, {skipped}") + continue except Exception as e: out.warning( title="Could not fetch ABI", - message=f"Fetching reference ABI for chain id {deployment.chainId} failed, descriptor ABIs will " - f"not be validated: {e}", + message=f"Fetching reference ABI for chain id {deployment.chainId} failed, {skipped}: {e}", ) continue diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index 9a25d387..46c01336 100644 --- a/src/erc7730/lint/v2/lint_validate_display_fields.py +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -63,14 +63,22 @@ def _validate_contract_display_fields( reference_abis = None explorer_url = None for deployment in deployments: + skipped = "display fields will not be validated against ABI" try: - if (abis := client.get_contract_abis(deployment.chainId, deployment.address)) is None: - continue + abis = client.get_contract_abis(deployment.chainId, deployment.address) + except client.ProxyImplementationNotVerifiedError as e: + out.warning(title="Proxy implementation not verified", message=f"{e}, {skipped}") + continue + except client.ContractNotVerifiedError as e: + out.warning(title="Contract not verified", message=f"{e}, {skipped}") + continue + except client.ChainNotSupportedError as e: + out.info(title="Chain not supported", message=f"{e}, {skipped}") + continue except Exception as e: out.warning( title="Could not fetch ABI", - message=f"Fetching reference ABI for chain id {deployment.chainId} failed, display fields will " - f"not be validated against ABI: {e}", + message=f"Fetching reference ABI for chain id {deployment.chainId} failed, {skipped}: {e}", ) continue diff --git a/tests/common/test_client.py b/tests/common/test_client.py index 2e678056..21c5f35c 100644 --- a/tests/common/test_client.py +++ b/tests/common/test_client.py @@ -61,17 +61,17 @@ def test_get_contract_abis_from_sourcify() -> None: 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 + with pytest.raises(client.ContractNotVerifiedError): + client.get_contract_abis_from_sourcify( + chain_id=1, contract_address="0x0000000000000000000000000000000000000001" + ) 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 + with pytest.raises(client.ChainNotSupportedError): + client.get_contract_abis_from_sourcify( + chain_id=99999999, contract_address="0x06012c8cf97bead5deae237070f9587f8e7a266d" + ) def test_get_contract_abis_from_sourcify_proxy() -> None: @@ -100,7 +100,7 @@ def test_get_contract_abis_unverified_proxy_implementation(monkeypatch: pytest.M monkeypatch.setattr( client, "get", lambda model, url, **params: proxy if url.endswith("eb48") else real_get(model, url, **params) ) - with pytest.raises(Exception, match="0x0000000000000000000000000000000000000001"): + with pytest.raises(client.ProxyImplementationNotVerifiedError, match="0x0000000000000000000000000000000000000001"): client.get_contract_abis(chain_id=1, contract_address="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") From c7c42739359cf7bb3f399d30a725800e7db37618 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Mon, 21 Sep 2026 09:11:58 +0200 Subject: [PATCH 06/21] fix(client): build the contract URL without a chain lookup The URL no longer depends on the chain list, and the lookup only added a network failure mode that the linters did not handle. Co-Authored-By: Claude Fable 5.1 --- src/erc7730/common/client.py | 8 ++------ src/erc7730/lint/lint_validate_abi.py | 5 +---- src/erc7730/lint/v2/lint_validate_display_fields.py | 5 +---- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/erc7730/common/client.py b/src/erc7730/common/client.py index beb393e7..9117761b 100644 --- a/src/erc7730/common/client.py +++ b/src/erc7730/common/client.py @@ -186,13 +186,9 @@ def get_contract_explorer_url(chain_id: int, contract_address: Address) -> HttpU :param chain_id: EIP-155 chain ID :param contract_address: EVM contract address - :return: URL to the contract explorer site - :raises NotImplementedError: if chain id not supported + :return: URL to the contract on the Sourcify repository """ - for chain in get_supported_chains(): - if chain.chainId == chain_id: - return HttpUrl(f"https://repo.{SOURCIFY}/{chain_id}/{contract_address}") - raise NotImplementedError(f"Chain ID {chain_id} is not supported by Sourcify") + return HttpUrl(f"https://repo.{SOURCIFY}/{chain_id}/{contract_address}") def get(model: type[_T], url: HttpUrl | FileUrl, **params: Any) -> _T: diff --git a/src/erc7730/lint/lint_validate_abi.py b/src/erc7730/lint/lint_validate_abi.py index 26c0b748..545e7b87 100644 --- a/src/erc7730/lint/lint_validate_abi.py +++ b/src/erc7730/lint/lint_validate_abi.py @@ -57,10 +57,7 @@ def _validate_contract_abis(cls, context: ResolvedContractContext, out: OutputAd reference_abis = get_functions(abis) descriptor_abis = get_functions(context.contract.abi) - try: - url = client.get_contract_explorer_url(deployment.chainId, deployment.address) - except NotImplementedError: - url = f"" + url = client.get_contract_explorer_url(deployment.chainId, deployment.address) for selector, abi in descriptor_abis.functions.items(): if selector not in reference_abis.functions: diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index 46c01336..3e77332c 100644 --- a/src/erc7730/lint/v2/lint_validate_display_fields.py +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -83,10 +83,7 @@ def _validate_contract_display_fields( continue reference_abis = get_functions(abis) - try: - explorer_url = client.get_contract_explorer_url(deployment.chainId, deployment.address) - except NotImplementedError: - explorer_url = f"" + explorer_url = client.get_contract_explorer_url(deployment.chainId, deployment.address) break if reference_abis is None: From f23b95feae8af1f3d93461e717ac9c1e6109b882 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Mon, 21 Sep 2026 09:14:45 +0200 Subject: [PATCH 07/21] fix(client): scope forced caching to Sourcify and add a bypass Force caching per request, only for Sourcify responses, instead of for every host: other responses follow their caching headers again. Shorten the cache TTL to one hour, as it is now the freshness window for proxy resolution, and add ERC7730_NO_CACHE to disable the cache. Co-Authored-By: Claude Fable 5.1 --- docs/pages/usage_cli.md | 3 ++- src/erc7730/common/client.py | 26 +++++++++++++++++--------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/pages/usage_cli.md b/docs/pages/usage_cli.md index f96b6692..1f4085b9 100644 --- a/docs/pages/usage_cli.md +++ b/docs/pages/usage_cli.md @@ -95,7 +95,8 @@ erc7730 generate --chain-id=1 --address=0x00000000000000000000000000000000000000 ``` ABIs are fetched from [Sourcify](https://sourcify.dev), which requires no API key. The contract (and its -implementations, if it is a proxy) must be verified on Sourcify. +implementations, if it is a proxy) must be verified on Sourcify. Sourcify responses are cached for one hour in +`~/.cache/erc7730` (or `$XDG_CACHE_HOME/erc7730`), set `ERC7730_NO_CACHE=1` to disable the cache. Please note that while the generator does its best to guess the right format based on fields name/type, the generated descriptor should be considered a starting point to refine. diff --git a/src/erc7730/common/client.py b/src/erc7730/common/client.py index 9117761b..6dd60467 100644 --- a/src/erc7730/common/client.py +++ b/src/erc7730/common/client.py @@ -4,7 +4,7 @@ from functools import cache from typing import Any, TypeVar, final, override -from hishel import CacheTransport, Controller, FileStorage +from hishel import CacheTransport, FileStorage from httpx import URL, BaseTransport, Client, HTTPStatusError, HTTPTransport, Request, Response, codes from httpx._content import IteratorByteStream from httpx_file import FileTransport @@ -24,6 +24,9 @@ ETHERSCAN = "api.etherscan.io" SOURCIFY = "sourcify.dev" +ERC7730_NO_CACHE = "ERC7730_NO_CACHE" +CACHE_TTL = 3600 + _T = TypeVar("_T") @@ -87,7 +90,7 @@ def get_supported_chains() -> list[SourcifyChain]: :return: Sourcify supported chains, with name/chain id """ - chains = get(url=HttpUrl(f"https://{SOURCIFY}/server/chains"), model=list[SourcifyChain]) + chains = get(url=HttpUrl(f"https://{SOURCIFY}/server/chains"), model=list[SourcifyChain], force_cache=True) return [chain for chain in chains if chain.supported] @@ -153,6 +156,7 @@ def _get_sourcify_contract(chain_id: int, contract_address: Address) -> Sourcify url=HttpUrl(f"https://{SOURCIFY}/server/v2/contract/{chain_id}/{contract_address}"), fields="abi,proxyResolution", model=SourcifyContract, + force_cache=True, ) except HTTPStatusError as e: if e.response.status_code == codes.NOT_FOUND: @@ -191,7 +195,7 @@ def get_contract_explorer_url(chain_id: int, contract_address: Address) -> HttpU return HttpUrl(f"https://repo.{SOURCIFY}/{chain_id}/{contract_address}") -def get(model: type[_T], url: HttpUrl | FileUrl, **params: Any) -> _T: +def get(model: type[_T], url: HttpUrl | FileUrl, *, force_cache: bool = False, **params: Any) -> _T: """ Fetch data from a file or an HTTP URL and deserialize it. @@ -199,13 +203,18 @@ def get(model: type[_T], url: HttpUrl | FileUrl, **params: Any) -> _T: - GitHub: adaptation to "raw.githubusercontent.com" - Etherscan: rate limiting, API key parameter injection, "result" field unwrapping + Responses are cached on disk according to their caching headers, unless the ERC7730_NO_CACHE environment variable + is set. + :param url: URL to get data from :param model: Pydantic model to deserialize the data + :param force_cache: cache the response even if it has no caching headers (for CACHE_TTL seconds) + :param params: query parameters :return: deserialized response :raises Exception: if URL type is not supported, API key not setup, or unexpected response """ with _client() as client: - response = client.get(url, params=params).raise_for_status().content + response = client.get(url, params=params, extensions={"force_cache": force_cache}).raise_for_status().content try: return TypeAdapter(model).validate_json(response) except ValidationError as e: @@ -217,14 +226,13 @@ def _client() -> Client: Create a new HTTP client with GitHub and Etherscan specific transports. :return: """ - cache_storage = FileStorage(base_path=xdg_cache_home() / "erc7730", ttl=7 * 24 * 3600, check_ttl_every=24 * 3600) - # Sourcify responses have no caching headers, force caching so that they are stored (until storage TTL expires) - cache_controller = Controller(force_cache=True) - http_transport = HTTPTransport() + http_transport: BaseTransport = HTTPTransport() http_transport = GithubTransport(http_transport) http_transport = EtherscanTransport(http_transport) http_transport = RetryTransport(transport=http_transport) - http_transport = CacheTransport(transport=http_transport, storage=cache_storage, controller=cache_controller) + if os.environ.get(ERC7730_NO_CACHE) is None: + cache_storage = FileStorage(base_path=xdg_cache_home() / "erc7730", ttl=CACHE_TTL, check_ttl_every=CACHE_TTL) + http_transport = CacheTransport(transport=http_transport, storage=cache_storage) file_transport = FileTransport() # TODO file storage: authorize relative paths only transports = {"https://": http_transport, "file://": file_transport} From fc3361b7df8e017134be64466a78710469194994 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Mon, 21 Sep 2026 09:21:09 +0200 Subject: [PATCH 08/21] test(client): stub the implementation lookup in the proxy test The test only stubbed the proxy lookup and fetched the implementation from the live API. Co-Authored-By: Claude Fable 5.1 --- tests/common/test_client.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/common/test_client.py b/tests/common/test_client.py index 21c5f35c..aed7e5f1 100644 --- a/tests/common/test_client.py +++ b/tests/common/test_client.py @@ -1,4 +1,7 @@ +from typing import Any + import pytest +from httpx import HTTPStatusError, Request, Response, codes from pydantic_string_url import HttpUrl from erc7730.common import client @@ -95,11 +98,15 @@ def test_get_contract_abis_unverified_proxy_implementation(monkeypatch: pytest.M }, } ) - real_get = client.get + + def get(model: Any, url: str, **params: Any) -> Any: + if url.endswith("eb48"): + return proxy + response = Response(status_code=codes.NOT_FOUND, request=Request("GET", url)) + raise HTTPStatusError("not verified", request=response.request, response=response) + client.get_contract_abis.cache_clear() - monkeypatch.setattr( - client, "get", lambda model, url, **params: proxy if url.endswith("eb48") else real_get(model, url, **params) - ) + monkeypatch.setattr(client, "get", get) with pytest.raises(client.ProxyImplementationNotVerifiedError, match="0x0000000000000000000000000000000000000001"): client.get_contract_abis(chain_id=1, contract_address="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") From bc817e931f917a4b3a992db088beccd3b646921b Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Mon, 21 Sep 2026 09:28:11 +0200 Subject: [PATCH 09/21] fix(lint): report deployments skipped by the transaction classifier The v2 classifier swallowed every reference ABI failure. Trace which deployment was skipped and why, at debug level since the display fields linter already reports the failure itself. Co-Authored-By: Claude Fable 5.1 --- .../v2/lint_transaction_type_classifier.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/erc7730/lint/v2/lint_transaction_type_classifier.py b/src/erc7730/lint/v2/lint_transaction_type_classifier.py index 37b59779..8009cb23 100644 --- a/src/erc7730/lint/v2/lint_transaction_type_classifier.py +++ b/src/erc7730/lint/v2/lint_transaction_type_classifier.py @@ -32,12 +32,12 @@ class ClassifyTransactionTypeLinter(ERC7730Linter): def lint( self, input_descriptor: InputERC7730Descriptor, descriptor: ResolvedERC7730Descriptor, out: OutputAdder ) -> None: - if (tx_class := self._determine_tx_class(descriptor)) is None: + if (tx_class := self._determine_tx_class(descriptor, out)) is None: return None DisplayFormatChecker(tx_class, descriptor.display).check(out) @classmethod - def _determine_tx_class(cls, descriptor: ResolvedERC7730Descriptor) -> TxClass | None: + def _determine_tx_class(cls, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> TxClass | None: match descriptor.context: case ResolvedEIP712Context(): # In v2, no schemas — classify from format keys (primaryType) @@ -47,18 +47,24 @@ def _determine_tx_class(cls, descriptor: ResolvedERC7730Descriptor) -> TxClass | return None case ResolvedContractContext(): # Try to classify from fetched ABI - return cls._classify_from_fetched_abi(descriptor.context) + return cls._classify_from_fetched_abi(descriptor.context, out) @classmethod - def _classify_from_fetched_abi(cls, context: ResolvedContractContext) -> TxClass | None: + def _classify_from_fetched_abi(cls, context: ResolvedContractContext, out: OutputAdder) -> TxClass | None: if (deployments := context.contract.deployments) is None: return None for deployment in deployments: try: - if (abis := client.get_contract_abis(deployment.chainId, deployment.address)) is not None: - return ABIClassifier().classify(list(abis)) - except Exception: # nosec B112 - intentional: try next deployment on failure + abis = client.get_contract_abis(deployment.chainId, deployment.address) + except Exception as e: + # the display fields linter already reports the failure, only trace which deployment was skipped + out.debug( + title="Transaction type not classified", + message=f"Fetching reference ABI for chain id {deployment.chainId} failed, trying next deployment: " + f"{e}", + ) continue + return ABIClassifier().classify(list(abis)) return None From d6aad1aaa6d6cee2f7e858895c824620ece26e05 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Mon, 21 Sep 2026 10:50:00 +0200 Subject: [PATCH 10/21] build: skip test registries on recursive submodule update ledger-asset-dapps has a nested submodule with an SSH URL, which makes pip fail to install the library from git on machines without a GitHub SSH key. Mark both test registries update=none so a recursive update skips them, and check them out explicitly in CI and the developer docs. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 8 +++++++- .gitmodules | 2 ++ docs/pages/developer.md | 6 +++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 956cc1f0..15034023 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,13 @@ jobs: uses: actions/checkout@v7 with: fetch-depth: 0 - submodules: "recursive" + + - name: Checkout test registries + timeout-minutes: 10 + run: >- + git submodule update --init --checkout + tests/registries/clear-signing-erc7730-registry + tests/registries/ledger-asset-dapps - name: Setup mise timeout-minutes: 10 diff --git a/.gitmodules b/.gitmodules index 448e07af..d6798df9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,8 @@ [submodule "clear-signing-erc7730-registry"] path = tests/registries/clear-signing-erc7730-registry url = https://github.com/LedgerHQ/clear-signing-erc7730-registry + update = none [submodule "ledger-asset-dapps"] path = tests/registries/ledger-asset-dapps url = https://github.com/LedgerHQ/ledger-asset-dapps + update = none diff --git a/docs/pages/developer.md b/docs/pages/developer.md index 573fc60c..0679a0a3 100644 --- a/docs/pages/developer.md +++ b/docs/pages/developer.md @@ -19,10 +19,14 @@ brew install mise 1. **Clone the repository:** ```bash -git clone --recursive git@github.com:LedgerHQ/python-erc7730.git +git clone git@github.com:LedgerHQ/python-erc7730.git cd python-erc7730 +git submodule update --init --checkout tests/registries/clear-signing-erc7730-registry tests/registries/ledger-asset-dapps ``` +The test registries are git submodules that are skipped by a recursive submodule update (so that `pip` can install the +library from git without an SSH key for a nested submodule), hence the explicit `--checkout`. + 2. **Install tools via mise:** ```bash From 88e3850a071577ab5a683af1a0b2bb396679743c Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Mon, 21 Sep 2026 14:01:26 +0200 Subject: [PATCH 11/21] feat(client): send a token header on Sourcify requests When SOURCIFY_TOKEN is set, add X-Sourcify-Token to every request to sourcify.dev, so that a caller with a token can be exempted from rate limiting. Co-Authored-By: Claude Fable 5.1 --- docs/pages/usage_cli.md | 2 ++ src/erc7730/common/client.py | 18 ++++++++++++++++++ tests/common/test_client.py | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/pages/usage_cli.md b/docs/pages/usage_cli.md index 1f4085b9..317510e4 100644 --- a/docs/pages/usage_cli.md +++ b/docs/pages/usage_cli.md @@ -98,6 +98,8 @@ ABIs are fetched from [Sourcify](https://sourcify.dev), which requires no API ke implementations, if it is a proxy) must be verified on Sourcify. Sourcify responses are cached for one hour in `~/.cache/erc7730` (or `$XDG_CACHE_HOME/erc7730`), set `ERC7730_NO_CACHE=1` to disable the cache. +If you have a Sourcify API token, set `SOURCIFY_TOKEN` and it is sent with every Sourcify request. + Please note that while the generator does its best to guess the right format based on fields name/type, the generated descriptor should be considered a starting point to refine. diff --git a/src/erc7730/common/client.py b/src/erc7730/common/client.py index 6dd60467..75a55281 100644 --- a/src/erc7730/common/client.py +++ b/src/erc7730/common/client.py @@ -229,6 +229,7 @@ def _client() -> Client: http_transport: BaseTransport = HTTPTransport() http_transport = GithubTransport(http_transport) http_transport = EtherscanTransport(http_transport) + http_transport = SourcifyTransport(http_transport) http_transport = RetryTransport(transport=http_transport) if os.environ.get(ERC7730_NO_CACHE) is None: cache_storage = FileStorage(base_path=xdg_cache_home() / "erc7730", ttl=CACHE_TTL, check_ttl_every=CACHE_TTL) @@ -272,6 +273,23 @@ def handle_request(self, request: Request) -> Response: return super().handle_request(request) +@final +class SourcifyTransport(DelegateTransport): + """Sourcify specific transport for handling token header injection.""" + + SOURCIFY_TOKEN = "SOURCIFY_TOKEN" # nosec B105 - environment variable name, not a secret + + @override + def handle_request(self, request: Request) -> Response: + if request.url.host != SOURCIFY: + return super().handle_request(request) + + # add token if provided, it exempts the caller from rate limiting + if (token := os.environ.get(self.SOURCIFY_TOKEN)) is not None: + request.headers.update({"X-Sourcify-Token": token}) + return super().handle_request(request) + + @final class EtherscanTransport(DelegateTransport): """Etherscan specific transport for handling rate limiting, API key parameter injection, response unwrapping.""" diff --git a/tests/common/test_client.py b/tests/common/test_client.py index aed7e5f1..a3bcd549 100644 --- a/tests/common/test_client.py +++ b/tests/common/test_client.py @@ -1,7 +1,7 @@ from typing import Any import pytest -from httpx import HTTPStatusError, Request, Response, codes +from httpx import BaseTransport, HTTPStatusError, Request, Response, codes from pydantic_string_url import HttpUrl from erc7730.common import client @@ -129,6 +129,38 @@ def test_get_contract_abis_proxy_resolution_error(monkeypatch: pytest.MonkeyPatc client.get_contract_abis(chain_id=1, contract_address="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") +class _RecordingTransport(BaseTransport): + """Transport that records the requests it receives and answers with an empty JSON list.""" + + def __init__(self) -> None: + self.requests: list[Request] = [] + + def handle_request(self, request: Request) -> Response: + self.requests.append(request) + return Response(status_code=codes.OK, json=[]) + + +@pytest.mark.parametrize( + ("token", "url", "expected"), + [ + ("secret", "https://sourcify.dev/server/chains", "secret"), + (None, "https://sourcify.dev/server/chains", None), + ("secret", "https://api.etherscan.io/v2/chainlist", None), + ], +) +def test_sourcify_transport_token_header( + monkeypatch: pytest.MonkeyPatch, token: str | None, url: str, expected: str | None +) -> None: + if token is None: + monkeypatch.delenv(client.SourcifyTransport.SOURCIFY_TOKEN, raising=False) + else: + monkeypatch.setenv(client.SourcifyTransport.SOURCIFY_TOKEN, token) + delegate = _RecordingTransport() + client.SourcifyTransport(delegate).handle_request(Request("GET", url)) + assert len(delegate.requests) == 1 + assert delegate.requests[0].headers.get("X-Sourcify-Token") == expected + + def test_get_from_github() -> None: result1 = client.get( url=HttpUrl( From 2a923b940a542b4fba50ba45af87e263acaa60d8 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Mon, 21 Sep 2026 15:29:20 +0200 Subject: [PATCH 12/21] feat(lint): add --require-verified With the flag, a contract, proxy implementation or chain that Sourcify does not know is reported as an error instead of a warning, so the lint fails. Transient fetch failures stay warnings. Off by default. Co-Authored-By: Claude Fable 5.1 --- docs/pages/lint.md | 2 + docs/pages/usage_cli.md | 2 + src/erc7730/lint/lint.py | 14 +++-- src/erc7730/lint/lint_validate_abi.py | 17 ++++-- src/erc7730/lint/v2/lint.py | 9 ++-- .../lint/v2/lint_validate_display_fields.py | 23 +++++--- src/erc7730/main.py | 10 +++- tests/v2/lint/test_lint_require_verified.py | 53 +++++++++++++++++++ 8 files changed, 107 insertions(+), 23 deletions(-) create mode 100644 tests/v2/lint/test_lint_require_verified.py diff --git a/docs/pages/lint.md b/docs/pages/lint.md index f45ca9e2..2a7dcc28 100644 --- a/docs/pages/lint.md +++ b/docs/pages/lint.md @@ -1,5 +1,7 @@ # Linter checks list ## ABI checks +The three "not verified" / "not supported" checks below are reported as errors instead when `--require-verified` is passed. + ### Contract not verified - **Level**: ⚠️ Warning - **Message**: `contract
on chain is not verified on Sourcify, descriptor ABIs will not be validated` diff --git a/docs/pages/usage_cli.md b/docs/pages/usage_cli.md index 317510e4..bf331223 100644 --- a/docs/pages/usage_cli.md +++ b/docs/pages/usage_cli.md @@ -80,6 +80,8 @@ It can be called with single files or directories, in which case all descriptors Use `--skip-abi-validation` to disable external ABI comparisons against Sourcify (useful for offline runs or faster local checks). +Use `--require-verified` to report contracts (or proxy implementations) that are not verified on Sourcify as errors instead of warnings. + ### `erc7730 generate` The `generate` command bootstraps a new descriptor file from ABIs or message schemas: diff --git a/src/erc7730/lint/lint.py b/src/erc7730/lint/lint.py index 5376f46b..93c80968 100644 --- a/src/erc7730/lint/lint.py +++ b/src/erc7730/lint/lint.py @@ -31,10 +31,12 @@ from erc7730.model.input.descriptor import InputERC7730Descriptor -def lint_all_and_print_errors(paths: list[Path], gha: bool = False, skip_abi_validation: bool = False) -> bool: +def lint_all_and_print_errors( + paths: list[Path], gha: bool = False, skip_abi_validation: bool = False, require_verified: bool = False +) -> bool: out = GithubAnnotationsAdder() if gha else DropFileOutputAdder(delegate=ConsoleOutputAdder()) - count = lint_all(paths, out, skip_abi_validation=skip_abi_validation) + count = lint_all(paths, out, skip_abi_validation=skip_abi_validation, require_verified=require_verified) if out.has_errors: print(f"[bold][red]checked {count} descriptor files, some errors found ❌[/red][/bold]") @@ -48,7 +50,9 @@ def lint_all_and_print_errors(paths: list[Path], gha: bool = False, skip_abi_val return True -def lint_all(paths: list[Path], out: OutputAdder, skip_abi_validation: bool = False) -> int: +def lint_all( + paths: list[Path], out: OutputAdder, skip_abi_validation: bool = False, require_verified: bool = False +) -> int: """ Lint all ERC-7730 descriptor files at given paths. @@ -56,6 +60,8 @@ def lint_all(paths: list[Path], out: OutputAdder, skip_abi_validation: bool = Fa :param paths: paths to apply linter on :param out: output adder + :param skip_abi_validation: skip ABI comparison with Sourcify reference data + :param require_verified: report contracts that are not verified on Sourcify as errors instead of warnings :return: number of files checked """ linters = [ @@ -65,7 +71,7 @@ def lint_all(paths: list[Path], out: OutputAdder, skip_abi_validation: bool = Fa ValidateMaxLengthLinter(), ] if not skip_abi_validation: - linters.insert(0, ValidateABILinter()) + linters.insert(0, ValidateABILinter(require_verified=require_verified)) linter = MultiLinter(linters) files = list(get_erc7730_files(*paths, out=out)) diff --git a/src/erc7730/lint/lint_validate_abi.py b/src/erc7730/lint/lint_validate_abi.py index 545e7b87..bd5d7e13 100644 --- a/src/erc7730/lint/lint_validate_abi.py +++ b/src/erc7730/lint/lint_validate_abi.py @@ -16,6 +16,12 @@ class ValidateABILinter(ERC7730Linter): - => compares the two ABIs """ + def __init__(self, require_verified: bool = False) -> None: + """ + :param require_verified: report a contract that is not verified on Sourcify as an error instead of a warning + """ + self.require_verified = require_verified + @override def lint(self, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: if isinstance(descriptor.context, ResolvedEIP712Context): @@ -28,8 +34,7 @@ def lint(self, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: def _validate_eip712_schemas(cls, context: ResolvedEIP712Context, out: OutputAdder) -> None: pass # not implemented - @classmethod - def _validate_contract_abis(cls, context: ResolvedContractContext, out: OutputAdder) -> None: + def _validate_contract_abis(self, context: ResolvedContractContext, out: OutputAdder) -> None: if not isinstance(context.contract.abi, list): raise ValueError("Contract ABIs should have been resolved") @@ -37,16 +42,18 @@ def _validate_contract_abis(cls, context: ResolvedContractContext, out: OutputAd return for deployment in deployments: skipped = "descriptor ABIs will not be validated" + unverified = out.error if self.require_verified else out.warning + unsupported = out.error if self.require_verified else out.info try: abis = client.get_contract_abis(deployment.chainId, deployment.address) except client.ProxyImplementationNotVerifiedError as e: - out.warning(title="Proxy implementation not verified", message=f"{e}, {skipped}") + unverified(title="Proxy implementation not verified", message=f"{e}, {skipped}") continue except client.ContractNotVerifiedError as e: - out.warning(title="Contract not verified", message=f"{e}, {skipped}") + unverified(title="Contract not verified", message=f"{e}, {skipped}") continue except client.ChainNotSupportedError as e: - out.info(title="Chain not supported", message=f"{e}, {skipped}") + unsupported(title="Chain not supported", message=f"{e}, {skipped}") continue except Exception as e: out.warning( diff --git a/src/erc7730/lint/v2/lint.py b/src/erc7730/lint/v2/lint.py index 515f9e4c..22049464 100644 --- a/src/erc7730/lint/v2/lint.py +++ b/src/erc7730/lint/v2/lint.py @@ -23,11 +23,11 @@ from erc7730.model.input.v2.descriptor import InputERC7730Descriptor -def lint_all_and_print_errors(paths: list[Path], gha: bool = False) -> bool: +def lint_all_and_print_errors(paths: list[Path], gha: bool = False, require_verified: bool = False) -> bool: """Lint all ERC-7730 v2 descriptor files at given paths and print results.""" out = GithubAnnotationsAdder() if gha else DropFileOutputAdder(delegate=ConsoleOutputAdder()) - count = lint_all(paths, out) + count = lint_all(paths, out, require_verified=require_verified) if out.has_errors: print(f"[bold][red]checked {count} v2 descriptor files, some errors found ❌[/red][/bold]") @@ -41,7 +41,7 @@ def lint_all_and_print_errors(paths: list[Path], gha: bool = False) -> bool: return True -def lint_all(paths: list[Path], out: OutputAdder) -> int: +def lint_all(paths: list[Path], out: OutputAdder, require_verified: bool = False) -> int: """ Lint all ERC-7730 v2 descriptor files at given paths. @@ -49,11 +49,12 @@ def lint_all(paths: list[Path], out: OutputAdder) -> int: :param paths: paths to apply linter on :param out: output adder + :param require_verified: report contracts that are not verified on Sourcify as errors instead of warnings :return: number of files checked """ linter = MultiLinter( [ - ValidateDisplayFieldsLinter(), + ValidateDisplayFieldsLinter(require_verified=require_verified), ValidateEIP712KeysLinter(), ClassifyTransactionTypeLinter(), ValidateMaxLengthLinter(), diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index 3e77332c..55402357 100644 --- a/src/erc7730/lint/v2/lint_validate_display_fields.py +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -38,6 +38,12 @@ class ValidateDisplayFieldsLinter(ERC7730Linter): - No schema available in v2 resolved model, so no validation is performed """ + def __init__(self, require_verified: bool = False) -> None: + """ + :param require_verified: report a contract that is not verified on Sourcify as an error instead of a warning + """ + self.require_verified = require_verified + @override def lint( self, input_descriptor: InputERC7730Descriptor, descriptor: ResolvedERC7730Descriptor, out: OutputAdder @@ -48,9 +54,8 @@ def lint( case ResolvedContractContext(): self._validate_contract_display_fields(input_descriptor, descriptor, out) - @classmethod def _validate_contract_display_fields( - cls, input_descriptor: InputERC7730Descriptor, descriptor: ResolvedERC7730Descriptor, out: OutputAdder + self, input_descriptor: InputERC7730Descriptor, descriptor: ResolvedERC7730Descriptor, out: OutputAdder ) -> None: context = descriptor.context if not isinstance(context, ResolvedContractContext): @@ -64,16 +69,18 @@ def _validate_contract_display_fields( explorer_url = None for deployment in deployments: skipped = "display fields will not be validated against ABI" + unverified = out.error if self.require_verified else out.warning + unsupported = out.error if self.require_verified else out.info try: abis = client.get_contract_abis(deployment.chainId, deployment.address) except client.ProxyImplementationNotVerifiedError as e: - out.warning(title="Proxy implementation not verified", message=f"{e}, {skipped}") + unverified(title="Proxy implementation not verified", message=f"{e}, {skipped}") continue except client.ContractNotVerifiedError as e: - out.warning(title="Contract not verified", message=f"{e}, {skipped}") + unverified(title="Contract not verified", message=f"{e}, {skipped}") continue except client.ChainNotSupportedError as e: - out.info(title="Chain not supported", message=f"{e}, {skipped}") + unsupported(title="Chain not supported", message=f"{e}, {skipped}") continue except Exception as e: out.warning( @@ -95,7 +102,7 @@ def _validate_contract_display_fields( abi_paths_by_selector[selector] = compute_abi_schema_paths(abi) # Parse the input format keys, which carry the parameter names resolution reduced to selectors - declared_abis_by_selector = cls._parse_declared_abis(input_descriptor) + declared_abis_by_selector = self._parse_declared_abis(input_descriptor) # Validate display field paths against ABI paths for selector, fmt in descriptor.display.formats.items(): @@ -109,7 +116,7 @@ def _validate_contract_display_fields( format_paths = compute_format_schema_paths(fmt) abi_paths = abi_paths_by_selector[selector] - unnamed_parameter_names = cls._unnamed_parameter_names( + unnamed_parameter_names = self._unnamed_parameter_names( reference_abis.functions[selector], declared_abis_by_selector.get(selector) ) @@ -118,7 +125,7 @@ def _validate_contract_display_fields( # (e.g. defining a field for an array root covers all nested elements). for path in format_paths.data_paths - abi_paths: if not any(data_path_starts_with(abi_path, path) for abi_path in abi_paths): - if cls._root_name(path) in unnamed_parameter_names: + if self._root_name(path) in unnamed_parameter_names: continue out.error( title="Invalid display field", diff --git a/src/erc7730/main.py b/src/erc7730/main.py index 15fbb4ba..87571cf1 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -105,15 +105,21 @@ def command_lint( skip_abi_validation: Annotated[ bool, Option("--skip-abi-validation", help="Skip ABI comparison with Sourcify reference data") ] = False, + require_verified: Annotated[ + bool, + Option("--require-verified", help="Report contracts that are not verified on Sourcify as errors"), + ] = False, v2: Annotated[ bool, Option("--v2", help="Use v2 model for validation (auto-detected from $schema if not set)") ] = False, ) -> None: if v2 or _any_v2_descriptor(paths): - if not lint_all_and_print_errors_v2(paths, gha): + if not lint_all_and_print_errors_v2(paths, gha, require_verified=require_verified): raise Exit(1) else: - if not lint_all_and_print_errors_v1(paths, gha, skip_abi_validation=skip_abi_validation): + if not lint_all_and_print_errors_v1( + paths, gha, skip_abi_validation=skip_abi_validation, require_verified=require_verified + ): raise Exit(1) diff --git a/tests/v2/lint/test_lint_require_verified.py b/tests/v2/lint/test_lint_require_verified.py new file mode 100644 index 00000000..27e2cf36 --- /dev/null +++ b/tests/v2/lint/test_lint_require_verified.py @@ -0,0 +1,53 @@ +import pytest + +from erc7730.common import client +from erc7730.common.output import ListOutputAdder, Output +from erc7730.lint.v2.lint import lint_all +from tests.files import ERC7730_REGISTRY + +USDT = ERC7730_REGISTRY / "tether" / "calldata-usdt.json" + +NOT_VERIFIED = client.ContractNotVerifiedError("contract 0x1 on chain 1 is not verified on Sourcify") +IMPLEMENTATION_NOT_VERIFIED = client.ProxyImplementationNotVerifiedError("contract 0x1 on chain 1 is a proxy") +CHAIN_NOT_SUPPORTED = client.ChainNotSupportedError("chain 1 is not supported by Sourcify") +RATE_LIMITED = Exception("Sourcify rate limit exceeded, please retry") + + +def lint_with(monkeypatch: pytest.MonkeyPatch, error: Exception, require_verified: bool) -> ListOutputAdder: + def get_contract_abis(chain_id: int, contract_address: str) -> list[object]: + raise error + + monkeypatch.setattr(client, "get_contract_abis", get_contract_abis) + out = ListOutputAdder() + lint_all([USDT], out, require_verified=require_verified) + return out + + +def level_of(out: ListOutputAdder, title: str) -> Output.Level: + return next(output.level for output in out.outputs if output.title == title) + + +@pytest.mark.parametrize( + ("error", "title", "default_level"), + [ + (NOT_VERIFIED, "Contract not verified", Output.Level.WARNING), + (IMPLEMENTATION_NOT_VERIFIED, "Proxy implementation not verified", Output.Level.WARNING), + (CHAIN_NOT_SUPPORTED, "Chain not supported", Output.Level.INFO), + ], +) +def test_unverified_contract_is_an_error_only_when_required( + monkeypatch: pytest.MonkeyPatch, error: Exception, title: str, default_level: Output.Level +) -> None: + out = lint_with(monkeypatch, error, require_verified=False) + assert level_of(out, title) == default_level + assert not out.has_errors + + out = lint_with(monkeypatch, error, require_verified=True) + assert level_of(out, title) == Output.Level.ERROR + assert out.has_errors + + +def test_transient_failure_stays_a_warning_when_verified_is_required(monkeypatch: pytest.MonkeyPatch) -> None: + out = lint_with(monkeypatch, RATE_LIMITED, require_verified=True) + assert level_of(out, "Could not fetch ABI") == Output.Level.WARNING + assert not out.has_errors From f28dcc6a221b4e8f951fe2c9e686bcb3b27f4bf3 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Mon, 21 Sep 2026 23:02:19 +0200 Subject: [PATCH 13/21] feat(lint): validate display fields against every deployment Fetch the reference ABI of every deployment instead of stopping at the first one that succeeds. Deployments exposing the same functions are validated once; when they differ, a warning lists the groups and each distinct ABI is validated. Co-Authored-By: Claude Fable 5.1 --- docs/pages/lint.md | 5 ++ .../lint/v2/lint_validate_display_fields.py | 45 +++++++++++---- .../test_lint_validate_all_deployments.py | 57 +++++++++++++++++++ 3 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 tests/v2/lint/test_lint_validate_all_deployments.py diff --git a/docs/pages/lint.md b/docs/pages/lint.md index 2a7dcc28..9c9a7efb 100644 --- a/docs/pages/lint.md +++ b/docs/pages/lint.md @@ -22,6 +22,11 @@ The three "not verified" / "not supported" checks below are reported as errors i - **Message**: `Fetching reference ABI for chain id failed, descriptor ABIs will not be validated: ` - **Description**: ABI fetch from Sourcify has failed for another reason, such as a rate limit, a network error or a proxy resolution error. Subsequent checks are skipped for the current deployment. +### Deployments differ +- **Level**: ⚠️ Warning +- **Message**: `Deployments do not all expose the same functions, display fields are validated against each distinct reference ABI: :
, ...; :
, ...` +- **Description**: The deployments of the descriptor do not have the same reference ABI. Display fields are validated once per distinct ABI, so findings may apply to some chains only (the contract URL in each finding tells which). + ### Extra function - **Level**: ⚠️ Warning - **Message**: `Function (selector: ) defined in descriptor ABIs does not exist in reference ABI (see )` diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index 55402357..9f148e86 100644 --- a/src/erc7730/lint/v2/lint_validate_display_fields.py +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -10,7 +10,7 @@ from typing import final, override from erc7730.common import client -from erc7730.common.abi import compute_signature, get_functions, parse_signature, signature_to_selector +from erc7730.common.abi import Functions, compute_signature, get_functions, parse_signature, signature_to_selector from erc7730.common.output import OutputAdder from erc7730.lint.v2 import ERC7730Linter from erc7730.lint.v2.path_schemas import compute_format_schema_paths @@ -19,7 +19,7 @@ from erc7730.model.paths import DataPath, Field from erc7730.model.paths.path_ops import data_path_starts_with from erc7730.model.paths.path_schemas import compute_abi_schema_paths -from erc7730.model.resolved.v2.context import ResolvedContractContext, ResolvedEIP712Context +from erc7730.model.resolved.v2.context import ResolvedContractContext, ResolvedDeployment, ResolvedEIP712Context from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor @@ -29,8 +29,8 @@ class ValidateDisplayFieldsLinter(ERC7730Linter): Validates display fields against reference ABIs fetched from Sourcify. For contract context: - - Fetches ABI from Sourcify for each deployment - - Validates that display field paths exist in the ABI + - Fetches ABI from Sourcify for each deployment, and reports deployments not exposing the same functions + - Validates that display field paths exist in the ABI (once per distinct reference ABI) - Validates that all ABI function params have display fields - Checks that all selectors in the ABI have corresponding display formats @@ -64,9 +64,9 @@ def _validate_contract_display_fields( if (deployments := context.contract.deployments) is None: return - # Try to fetch ABI from Sourcify for the first deployment that succeeds - reference_abis = None - explorer_url = None + # Fetch the reference ABI of every deployment, and group deployments exposing the same functions, so that + # each distinct ABI is validated once and deployments diverging from the others are reported + groups: list[tuple[Functions, list[ResolvedDeployment]]] = [] for deployment in deployments: skipped = "display fields will not be validated against ABI" unverified = out.error if self.require_verified else out.warning @@ -90,12 +90,33 @@ def _validate_contract_display_fields( continue reference_abis = get_functions(abis) - explorer_url = client.get_contract_explorer_url(deployment.chainId, deployment.address) - break - - if reference_abis is None: - return + for group_abis, group_deployments in groups: + if group_abis.functions == reference_abis.functions: + group_deployments.append(deployment) + break + else: + groups.append((reference_abis, [deployment])) + + if len(groups) > 1: + out.warning( + title="Deployments differ", + message="Deployments do not all expose the same functions, display fields are validated against each " + "distinct reference ABI: " + + "; ".join(", ".join(f"{d.chainId}:{d.address}" for d in ds) for _, ds in groups), + ) + for reference_abis, group_deployments in groups: + explorer_url = client.get_contract_explorer_url(group_deployments[0].chainId, group_deployments[0].address) + self._validate_display_fields(input_descriptor, descriptor, reference_abis, explorer_url, out) + + def _validate_display_fields( + self, + input_descriptor: InputERC7730Descriptor, + descriptor: ResolvedERC7730Descriptor, + reference_abis: Functions, + explorer_url: str, + out: OutputAdder, + ) -> None: # Build ABI paths by selector abi_paths_by_selector: dict[str, set[DataPath]] = {} for selector, abi in reference_abis.functions.items(): diff --git a/tests/v2/lint/test_lint_validate_all_deployments.py b/tests/v2/lint/test_lint_validate_all_deployments.py new file mode 100644 index 00000000..29b8efc7 --- /dev/null +++ b/tests/v2/lint/test_lint_validate_all_deployments.py @@ -0,0 +1,57 @@ +from collections.abc import Callable + +import pytest + +from erc7730.common import client +from erc7730.common.abi import parse_signature +from erc7730.common.output import ListOutputAdder +from erc7730.lint.v2.lint import lint_all +from erc7730.model.abi import ABI +from tests.files import ERC7730_REGISTRY + +# USDT is deployed on chains 1 and 137, and declares formats for transfer and approve +USDT = ERC7730_REGISTRY / "tether" / "calldata-usdt.json" + +TRANSFER = parse_signature("transfer(address _to, uint256 _value)") +APPROVE = parse_signature("approve(address _spender, uint256 _value)") +# same functions with different parameter names, so display field paths do not match +TRANSFER_RENAMED = parse_signature("transfer(address to, uint256 value)") +APPROVE_RENAMED = parse_signature("approve(address spender, uint256 value)") + + +def lint_with(monkeypatch: pytest.MonkeyPatch, abis_of: Callable[[int], list[ABI]]) -> ListOutputAdder: + monkeypatch.setattr(client, "get_contract_abis", lambda chain_id, contract_address: abis_of(chain_id)) + out = ListOutputAdder() + lint_all([USDT], out) + return out + + +def titles(out: ListOutputAdder, title: str) -> int: + return sum(1 for output in out.outputs if output.title == title) + + +def test_deployments_with_the_same_abi_are_validated_once(monkeypatch: pytest.MonkeyPatch) -> None: + out = lint_with(monkeypatch, lambda chain_id: [TRANSFER, APPROVE]) + assert titles(out, "Deployments differ") == 0 + assert titles(out, "Invalid display field") == 0 + + +def test_deployments_with_different_abis_are_reported_and_each_validated(monkeypatch: pytest.MonkeyPatch) -> None: + out = lint_with( + monkeypatch, lambda chain_id: [TRANSFER, APPROVE] if chain_id == 1 else [TRANSFER_RENAMED, APPROVE_RENAMED] + ) + assert titles(out, "Deployments differ") == 1 + # the chain 137 ABI names the parameters differently, so the 4 display fields are invalid against it only + assert titles(out, "Invalid display field") == 4 + + +def test_a_deployment_that_cannot_be_fetched_does_not_stop_the_others(monkeypatch: pytest.MonkeyPatch) -> None: + def abis_of(chain_id: int) -> list[ABI]: + if chain_id == 1: + raise client.ContractNotVerifiedError("contract 0x1 on chain 1 is not verified on Sourcify") + return [TRANSFER_RENAMED, APPROVE_RENAMED] + + out = lint_with(monkeypatch, abis_of) + assert titles(out, "Contract not verified") == 1 + assert titles(out, "Deployments differ") == 0 + assert titles(out, "Invalid display field") == 4 From b3cf980b28262f4ae450d20a4f322ff5cef4fea6 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Tue, 22 Sep 2026 08:43:20 +0200 Subject: [PATCH 14/21] fix(lint): group deployments by their external ABI Compare the functions as seen by a caller (name, parameters, state mutability) rather than the full ABI model, so that deployments differing only in compiler details such as internalType are validated once. Co-Authored-By: Claude Fable 5.1 --- .../lint/v2/lint_validate_display_fields.py | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index 9f148e86..8bc18477 100644 --- a/src/erc7730/lint/v2/lint_validate_display_fields.py +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -7,14 +7,15 @@ - For EIP-712 context: no schema to validate against (no-op). """ -from typing import final, override +import json +from typing import Any, final, override from erc7730.common import client from erc7730.common.abi import Functions, compute_signature, get_functions, parse_signature, signature_to_selector from erc7730.common.output import OutputAdder from erc7730.lint.v2 import ERC7730Linter from erc7730.lint.v2.path_schemas import compute_format_schema_paths -from erc7730.model.abi import Function +from erc7730.model.abi import Component, Function, InputOutput from erc7730.model.input.v2.descriptor import InputERC7730Descriptor from erc7730.model.paths import DataPath, Field from erc7730.model.paths.path_ops import data_path_starts_with @@ -66,7 +67,7 @@ def _validate_contract_display_fields( # Fetch the reference ABI of every deployment, and group deployments exposing the same functions, so that # each distinct ABI is validated once and deployments diverging from the others are reported - groups: list[tuple[Functions, list[ResolvedDeployment]]] = [] + groups: dict[str, tuple[Functions, list[ResolvedDeployment]]] = {} for deployment in deployments: skipped = "display fields will not be validated against ABI" unverified = out.error if self.require_verified else out.warning @@ -90,25 +91,51 @@ def _validate_contract_display_fields( continue reference_abis = get_functions(abis) - for group_abis, group_deployments in groups: - if group_abis.functions == reference_abis.functions: - group_deployments.append(deployment) - break - else: - groups.append((reference_abis, [deployment])) + groups.setdefault(self._external_abi_key(reference_abis), (reference_abis, []))[1].append(deployment) if len(groups) > 1: out.warning( title="Deployments differ", message="Deployments do not all expose the same functions, display fields are validated against each " "distinct reference ABI: " - + "; ".join(", ".join(f"{d.chainId}:{d.address}" for d in ds) for _, ds in groups), + + "; ".join(", ".join(f"{d.chainId}:{d.address}" for d in ds) for _, ds in groups.values()), ) - for reference_abis, group_deployments in groups: + for reference_abis, group_deployments in groups.values(): explorer_url = client.get_contract_explorer_url(group_deployments[0].chainId, group_deployments[0].address) self._validate_display_fields(input_descriptor, descriptor, reference_abis, explorer_url, out) + @classmethod + def _external_abi_key(cls, reference_abis: Functions) -> str: + """ + Compute a key identifying the functions as seen by a caller of the contract. + + Compiler details such as internal types, or legacy fields, are left out, so that deployments compiled from + slightly different sources but exposing the same functions are grouped together. + + :param reference_abis: functions of a reference ABI + :return: key equal for two ABIs exposing the same functions + """ + return json.dumps( + { + selector: { + "name": abi.name, + "inputs": [cls._external_parameter(param) for param in abi.inputs or []], + "outputs": [cls._external_parameter(param) for param in abi.outputs or []], + "stateMutability": abi.stateMutability, + } + for selector, abi in sorted(reference_abis.functions.items()) + } + ) + + @classmethod + def _external_parameter(cls, param: InputOutput | Component) -> dict[str, Any]: + return { + "name": param.name, + "type": param.type, + "components": [cls._external_parameter(component) for component in param.components or []], + } + def _validate_display_fields( self, input_descriptor: InputERC7730Descriptor, From f5da9215d22324b4e4cb475534c0dd7c8e5c6a36 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Tue, 22 Sep 2026 08:46:15 +0200 Subject: [PATCH 15/21] fix(lint): name the differing deployment ABIs in the warning Co-Authored-By: Claude Fable 5.1 --- docs/pages/lint.md | 4 ++-- src/erc7730/lint/v2/lint_validate_display_fields.py | 6 +++--- tests/v2/lint/test_lint_validate_all_deployments.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/pages/lint.md b/docs/pages/lint.md index 9c9a7efb..8c739d46 100644 --- a/docs/pages/lint.md +++ b/docs/pages/lint.md @@ -22,9 +22,9 @@ The three "not verified" / "not supported" checks below are reported as errors i - **Message**: `Fetching reference ABI for chain id failed, descriptor ABIs will not be validated: ` - **Description**: ABI fetch from Sourcify has failed for another reason, such as a rate limit, a network error or a proxy resolution error. Subsequent checks are skipped for the current deployment. -### Deployments differ +### Deployment ABIs differ - **Level**: ⚠️ Warning -- **Message**: `Deployments do not all expose the same functions, display fields are validated against each distinct reference ABI: :
, ...; :
, ...` +- **Message**: `The reference ABIs of the deployments do not all expose the same functions, display fields are validated against each distinct reference ABI: :
, ...; :
, ...` - **Description**: The deployments of the descriptor do not have the same reference ABI. Display fields are validated once per distinct ABI, so findings may apply to some chains only (the contract URL in each finding tells which). ### Extra function diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index 8bc18477..9829d864 100644 --- a/src/erc7730/lint/v2/lint_validate_display_fields.py +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -95,9 +95,9 @@ def _validate_contract_display_fields( if len(groups) > 1: out.warning( - title="Deployments differ", - message="Deployments do not all expose the same functions, display fields are validated against each " - "distinct reference ABI: " + title="Deployment ABIs differ", + message="The reference ABIs of the deployments do not all expose the same functions, display fields " + "are validated against each distinct reference ABI: " + "; ".join(", ".join(f"{d.chainId}:{d.address}" for d in ds) for _, ds in groups.values()), ) diff --git a/tests/v2/lint/test_lint_validate_all_deployments.py b/tests/v2/lint/test_lint_validate_all_deployments.py index 29b8efc7..972bcd52 100644 --- a/tests/v2/lint/test_lint_validate_all_deployments.py +++ b/tests/v2/lint/test_lint_validate_all_deployments.py @@ -32,7 +32,7 @@ def titles(out: ListOutputAdder, title: str) -> int: def test_deployments_with_the_same_abi_are_validated_once(monkeypatch: pytest.MonkeyPatch) -> None: out = lint_with(monkeypatch, lambda chain_id: [TRANSFER, APPROVE]) - assert titles(out, "Deployments differ") == 0 + assert titles(out, "Deployment ABIs differ") == 0 assert titles(out, "Invalid display field") == 0 @@ -40,7 +40,7 @@ def test_deployments_with_different_abis_are_reported_and_each_validated(monkeyp out = lint_with( monkeypatch, lambda chain_id: [TRANSFER, APPROVE] if chain_id == 1 else [TRANSFER_RENAMED, APPROVE_RENAMED] ) - assert titles(out, "Deployments differ") == 1 + assert titles(out, "Deployment ABIs differ") == 1 # the chain 137 ABI names the parameters differently, so the 4 display fields are invalid against it only assert titles(out, "Invalid display field") == 4 @@ -53,5 +53,5 @@ def abis_of(chain_id: int) -> list[ABI]: out = lint_with(monkeypatch, abis_of) assert titles(out, "Contract not verified") == 1 - assert titles(out, "Deployments differ") == 0 + assert titles(out, "Deployment ABIs differ") == 0 assert titles(out, "Invalid display field") == 4 From 9b8f72aeebcc58cc89b661329e1dc7f2a0be912e Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Tue, 22 Sep 2026 08:47:38 +0200 Subject: [PATCH 16/21] fix(lint): annotate the contract URL as HttpUrl Co-Authored-By: Claude Fable 5.1 --- src/erc7730/lint/v2/lint_validate_display_fields.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index 9829d864..17d40d3c 100644 --- a/src/erc7730/lint/v2/lint_validate_display_fields.py +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -10,6 +10,8 @@ import json from typing import Any, final, override +from pydantic_string_url import HttpUrl + from erc7730.common import client from erc7730.common.abi import Functions, compute_signature, get_functions, parse_signature, signature_to_selector from erc7730.common.output import OutputAdder @@ -141,7 +143,7 @@ def _validate_display_fields( input_descriptor: InputERC7730Descriptor, descriptor: ResolvedERC7730Descriptor, reference_abis: Functions, - explorer_url: str, + explorer_url: HttpUrl, out: OutputAdder, ) -> None: # Build ABI paths by selector From 66f78e7c3d8ee007510e2d87481f059934eafd1f Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Tue, 22 Sep 2026 08:50:07 +0200 Subject: [PATCH 17/21] test(lint): lint a fixture descriptor instead of the registry's USDT file The registry file can change with the daily submodule update; the tests depended on its two deployments and parameter names. Share the linting helper between the require-verified and all-deployments tests. Co-Authored-By: Claude Fable 5.1 --- .../lint/calldata-two-deployments.json | 36 +++++++++++++++++++ tests/v2/lint/conftest.py | 27 ++++++++++++++ tests/v2/lint/test_lint_require_verified.py | 27 +++++++------- .../test_lint_validate_all_deployments.py | 35 ++++++------------ 4 files changed, 86 insertions(+), 39 deletions(-) create mode 100644 tests/resources/lint/calldata-two-deployments.json create mode 100644 tests/v2/lint/conftest.py diff --git a/tests/resources/lint/calldata-two-deployments.json b/tests/resources/lint/calldata-two-deployments.json new file mode 100644 index 00000000..b80e34da --- /dev/null +++ b/tests/resources/lint/calldata-two-deployments.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + "context": { + "$id": "Test Token", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0x0000000000000000000000000000000000000001" }, + { "chainId": 137, "address": "0x0000000000000000000000000000000000000002" } + ] + } + }, + "metadata": { + "owner": "Test", + "info": { "url": "https://example.com", "deploymentDate": "2024-01-01T00:00:00Z" }, + "token": { "ticker": "TST", "name": "Test Token", "decimals": 18 }, + "contractName": "Test Token" + }, + "display": { + "formats": { + "transfer(address _to, uint256 _value)": { + "intent": "Send", + "fields": [ + { "path": "#._value", "label": "Amount", "format": "tokenAmount", "params": { "tokenPath": "@.to" } }, + { "path": "#._to", "label": "To", "format": "addressName", "params": { "types": ["eoa"] } } + ] + }, + "approve(address _spender, uint256 _value)": { + "intent": "Approve", + "fields": [ + { "path": "#._spender", "label": "Spender", "format": "addressName", "params": { "types": ["contract"] } }, + { "path": "#._value", "label": "Amount", "format": "tokenAmount", "params": { "tokenPath": "@.to" } } + ] + } + } + } +} diff --git a/tests/v2/lint/conftest.py b/tests/v2/lint/conftest.py new file mode 100644 index 00000000..84c98188 --- /dev/null +++ b/tests/v2/lint/conftest.py @@ -0,0 +1,27 @@ +from collections.abc import Callable + +import pytest + +from erc7730.common import client +from erc7730.common.output import ListOutputAdder +from erc7730.lint.v2.lint import lint_all +from erc7730.model.abi import ABI +from tests.files import TEST_RESOURCES + +# a descriptor with deployments on chains 1 and 137, and formats for transfer and approve with named parameters +TWO_DEPLOYMENTS = TEST_RESOURCES / "lint" / "calldata-two-deployments.json" + +LintDescriptor = Callable[[Callable[[int], list[ABI]], bool], ListOutputAdder] + + +@pytest.fixture +def lint_descriptor(monkeypatch: pytest.MonkeyPatch) -> LintDescriptor: + """Lint the two deployments descriptor, with reference ABIs (or failures) provided per chain id.""" + + def lint(abis_of: Callable[[int], list[ABI]], require_verified: bool = False) -> ListOutputAdder: + monkeypatch.setattr(client, "get_contract_abis", lambda chain_id, contract_address: abis_of(chain_id)) + out = ListOutputAdder() + lint_all([TWO_DEPLOYMENTS], out, require_verified=require_verified) + return out + + return lint diff --git a/tests/v2/lint/test_lint_require_verified.py b/tests/v2/lint/test_lint_require_verified.py index 27e2cf36..604b0480 100644 --- a/tests/v2/lint/test_lint_require_verified.py +++ b/tests/v2/lint/test_lint_require_verified.py @@ -1,11 +1,11 @@ +from collections.abc import Callable + import pytest from erc7730.common import client from erc7730.common.output import ListOutputAdder, Output -from erc7730.lint.v2.lint import lint_all -from tests.files import ERC7730_REGISTRY - -USDT = ERC7730_REGISTRY / "tether" / "calldata-usdt.json" +from erc7730.model.abi import ABI +from tests.v2.lint.conftest import LintDescriptor NOT_VERIFIED = client.ContractNotVerifiedError("contract 0x1 on chain 1 is not verified on Sourcify") IMPLEMENTATION_NOT_VERIFIED = client.ProxyImplementationNotVerifiedError("contract 0x1 on chain 1 is a proxy") @@ -13,14 +13,11 @@ RATE_LIMITED = Exception("Sourcify rate limit exceeded, please retry") -def lint_with(monkeypatch: pytest.MonkeyPatch, error: Exception, require_verified: bool) -> ListOutputAdder: - def get_contract_abis(chain_id: int, contract_address: str) -> list[object]: +def raising(error: Exception) -> Callable[[int], list[ABI]]: + def abis_of(chain_id: int) -> list[ABI]: raise error - monkeypatch.setattr(client, "get_contract_abis", get_contract_abis) - out = ListOutputAdder() - lint_all([USDT], out, require_verified=require_verified) - return out + return abis_of def level_of(out: ListOutputAdder, title: str) -> Output.Level: @@ -36,18 +33,18 @@ def level_of(out: ListOutputAdder, title: str) -> Output.Level: ], ) def test_unverified_contract_is_an_error_only_when_required( - monkeypatch: pytest.MonkeyPatch, error: Exception, title: str, default_level: Output.Level + lint_descriptor: LintDescriptor, error: Exception, title: str, default_level: Output.Level ) -> None: - out = lint_with(monkeypatch, error, require_verified=False) + out = lint_descriptor(raising(error), False) assert level_of(out, title) == default_level assert not out.has_errors - out = lint_with(monkeypatch, error, require_verified=True) + out = lint_descriptor(raising(error), True) assert level_of(out, title) == Output.Level.ERROR assert out.has_errors -def test_transient_failure_stays_a_warning_when_verified_is_required(monkeypatch: pytest.MonkeyPatch) -> None: - out = lint_with(monkeypatch, RATE_LIMITED, require_verified=True) +def test_transient_failure_stays_a_warning_when_verified_is_required(lint_descriptor: LintDescriptor) -> None: + out = lint_descriptor(raising(RATE_LIMITED), True) assert level_of(out, "Could not fetch ABI") == Output.Level.WARNING assert not out.has_errors diff --git a/tests/v2/lint/test_lint_validate_all_deployments.py b/tests/v2/lint/test_lint_validate_all_deployments.py index 972bcd52..3e6c064f 100644 --- a/tests/v2/lint/test_lint_validate_all_deployments.py +++ b/tests/v2/lint/test_lint_validate_all_deployments.py @@ -1,17 +1,10 @@ -from collections.abc import Callable - -import pytest - from erc7730.common import client from erc7730.common.abi import parse_signature from erc7730.common.output import ListOutputAdder -from erc7730.lint.v2.lint import lint_all from erc7730.model.abi import ABI -from tests.files import ERC7730_REGISTRY - -# USDT is deployed on chains 1 and 137, and declares formats for transfer and approve -USDT = ERC7730_REGISTRY / "tether" / "calldata-usdt.json" +from tests.v2.lint.conftest import LintDescriptor +# the functions the descriptor declares formats for TRANSFER = parse_signature("transfer(address _to, uint256 _value)") APPROVE = parse_signature("approve(address _spender, uint256 _value)") # same functions with different parameter names, so display field paths do not match @@ -19,39 +12,33 @@ APPROVE_RENAMED = parse_signature("approve(address spender, uint256 value)") -def lint_with(monkeypatch: pytest.MonkeyPatch, abis_of: Callable[[int], list[ABI]]) -> ListOutputAdder: - monkeypatch.setattr(client, "get_contract_abis", lambda chain_id, contract_address: abis_of(chain_id)) - out = ListOutputAdder() - lint_all([USDT], out) - return out - - def titles(out: ListOutputAdder, title: str) -> int: return sum(1 for output in out.outputs if output.title == title) -def test_deployments_with_the_same_abi_are_validated_once(monkeypatch: pytest.MonkeyPatch) -> None: - out = lint_with(monkeypatch, lambda chain_id: [TRANSFER, APPROVE]) +def test_deployments_with_the_same_abi_are_validated_once(lint_descriptor: LintDescriptor) -> None: + out = lint_descriptor(lambda chain_id: [TRANSFER, APPROVE], False) assert titles(out, "Deployment ABIs differ") == 0 assert titles(out, "Invalid display field") == 0 -def test_deployments_with_different_abis_are_reported_and_each_validated(monkeypatch: pytest.MonkeyPatch) -> None: - out = lint_with( - monkeypatch, lambda chain_id: [TRANSFER, APPROVE] if chain_id == 1 else [TRANSFER_RENAMED, APPROVE_RENAMED] - ) +def test_deployments_with_different_abis_are_reported_and_each_validated(lint_descriptor: LintDescriptor) -> None: + def abis_of(chain_id: int) -> list[ABI]: + return [TRANSFER, APPROVE] if chain_id == 1 else [TRANSFER_RENAMED, APPROVE_RENAMED] + + out = lint_descriptor(abis_of, False) assert titles(out, "Deployment ABIs differ") == 1 # the chain 137 ABI names the parameters differently, so the 4 display fields are invalid against it only assert titles(out, "Invalid display field") == 4 -def test_a_deployment_that_cannot_be_fetched_does_not_stop_the_others(monkeypatch: pytest.MonkeyPatch) -> None: +def test_a_deployment_that_cannot_be_fetched_does_not_stop_the_others(lint_descriptor: LintDescriptor) -> None: def abis_of(chain_id: int) -> list[ABI]: if chain_id == 1: raise client.ContractNotVerifiedError("contract 0x1 on chain 1 is not verified on Sourcify") return [TRANSFER_RENAMED, APPROVE_RENAMED] - out = lint_with(monkeypatch, abis_of) + out = lint_descriptor(abis_of, False) assert titles(out, "Contract not verified") == 1 assert titles(out, "Deployment ABIs differ") == 0 assert titles(out, "Invalid display field") == 4 From 46702f0c8ad4092a6602253d3d33d156b202e032 Mon Sep 17 00:00:00 2001 From: marcocastignoli Date: Wed, 23 Sep 2026 13:43:47 +0200 Subject: [PATCH 18/21] fix(lint): fail on any reference ABI fetch failure under --require-verified With --require-verified, a reference ABI that could not be fetched (rate limit, 5xx, timeout, proxy resolution failure) is now an error instead of a warning, in both the v1 ABI linter and the v2 display fields linter. Before, a strict run could skip a deployment and still exit 0. Without the flag it stays a warning. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/pages/lint.md | 2 +- docs/pages/usage_cli.md | 2 +- src/erc7730/lint/lint_validate_abi.py | 4 +- .../lint/v2/lint_validate_display_fields.py | 4 +- src/erc7730/main.py | 6 ++- tests/lint/test_lint_validate_abi.py | 43 +++++++++++++++++++ tests/v2/lint/test_lint_require_verified.py | 8 +++- 7 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 tests/lint/test_lint_validate_abi.py diff --git a/docs/pages/lint.md b/docs/pages/lint.md index 8c739d46..fa28f7d3 100644 --- a/docs/pages/lint.md +++ b/docs/pages/lint.md @@ -1,6 +1,6 @@ # Linter checks list ## ABI checks -The three "not verified" / "not supported" checks below are reported as errors instead when `--require-verified` is passed. +The four "not verified" / "not supported" / "could not fetch" checks below are reported as errors instead when `--require-verified` is passed, so that a strict run never passes without having checked every deployment. ### Contract not verified - **Level**: ⚠️ Warning diff --git a/docs/pages/usage_cli.md b/docs/pages/usage_cli.md index bf331223..86f29dcb 100644 --- a/docs/pages/usage_cli.md +++ b/docs/pages/usage_cli.md @@ -80,7 +80,7 @@ It can be called with single files or directories, in which case all descriptors Use `--skip-abi-validation` to disable external ABI comparisons against Sourcify (useful for offline runs or faster local checks). -Use `--require-verified` to report contracts (or proxy implementations) that are not verified on Sourcify as errors instead of warnings. +Use `--require-verified` to report contracts (or proxy implementations) that are not verified on Sourcify as errors instead of warnings. With the flag, a reference ABI that could not be fetched (rate limit, network error, proxy resolution failure) is an error too. ### `erc7730 generate` diff --git a/src/erc7730/lint/lint_validate_abi.py b/src/erc7730/lint/lint_validate_abi.py index bd5d7e13..da37a33d 100644 --- a/src/erc7730/lint/lint_validate_abi.py +++ b/src/erc7730/lint/lint_validate_abi.py @@ -19,6 +19,7 @@ class ValidateABILinter(ERC7730Linter): def __init__(self, require_verified: bool = False) -> None: """ :param require_verified: report a contract that is not verified on Sourcify as an error instead of a warning + (as well as a reference ABI that could not be fetched, for instance because of a rate limit) """ self.require_verified = require_verified @@ -44,6 +45,7 @@ def _validate_contract_abis(self, context: ResolvedContractContext, out: OutputA skipped = "descriptor ABIs will not be validated" unverified = out.error if self.require_verified else out.warning unsupported = out.error if self.require_verified else out.info + failed = out.error if self.require_verified else out.warning try: abis = client.get_contract_abis(deployment.chainId, deployment.address) except client.ProxyImplementationNotVerifiedError as e: @@ -56,7 +58,7 @@ def _validate_contract_abis(self, context: ResolvedContractContext, out: OutputA unsupported(title="Chain not supported", message=f"{e}, {skipped}") continue except Exception as e: - out.warning( + failed( title="Could not fetch ABI", message=f"Fetching reference ABI for chain id {deployment.chainId} failed, {skipped}: {e}", ) diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index 17d40d3c..d11f4aad 100644 --- a/src/erc7730/lint/v2/lint_validate_display_fields.py +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -44,6 +44,7 @@ class ValidateDisplayFieldsLinter(ERC7730Linter): def __init__(self, require_verified: bool = False) -> None: """ :param require_verified: report a contract that is not verified on Sourcify as an error instead of a warning + (as well as a reference ABI that could not be fetched, for instance because of a rate limit) """ self.require_verified = require_verified @@ -74,6 +75,7 @@ def _validate_contract_display_fields( skipped = "display fields will not be validated against ABI" unverified = out.error if self.require_verified else out.warning unsupported = out.error if self.require_verified else out.info + failed = out.error if self.require_verified else out.warning try: abis = client.get_contract_abis(deployment.chainId, deployment.address) except client.ProxyImplementationNotVerifiedError as e: @@ -86,7 +88,7 @@ def _validate_contract_display_fields( unsupported(title="Chain not supported", message=f"{e}, {skipped}") continue except Exception as e: - out.warning( + failed( title="Could not fetch ABI", message=f"Fetching reference ABI for chain id {deployment.chainId} failed, {skipped}: {e}", ) diff --git a/src/erc7730/main.py b/src/erc7730/main.py index 87571cf1..72c22299 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -107,7 +107,11 @@ def command_lint( ] = False, require_verified: Annotated[ bool, - Option("--require-verified", help="Report contracts that are not verified on Sourcify as errors"), + Option( + "--require-verified", + help="Report contracts that are not verified on Sourcify, and reference ABIs that could not be fetched, " + "as errors", + ), ] = False, v2: Annotated[ bool, Option("--v2", help="Use v2 model for validation (auto-detected from $schema if not set)") diff --git a/tests/lint/test_lint_validate_abi.py b/tests/lint/test_lint_validate_abi.py new file mode 100644 index 00000000..478b3573 --- /dev/null +++ b/tests/lint/test_lint_validate_abi.py @@ -0,0 +1,43 @@ +import pytest + +from erc7730.common import client +from erc7730.common.output import ListOutputAdder, Output +from erc7730.lint.lint_validate_abi import ValidateABILinter +from erc7730.model.resolved.context import ResolvedContractContext + +CONTEXT = ResolvedContractContext.model_validate( + { + "contract": { + "abi": [], + "deployments": [{"chainId": 1, "address": "0x0000000000000000000000000000000000000001"}], + } + } +) + +NOT_VERIFIED = client.ContractNotVerifiedError("contract 0x1 on chain 1 is not verified on Sourcify") +IMPLEMENTATION_NOT_VERIFIED = client.ProxyImplementationNotVerifiedError("contract 0x1 on chain 1 is a proxy") +CHAIN_NOT_SUPPORTED = client.ChainNotSupportedError("chain 1 is not supported by Sourcify") +RATE_LIMITED = Exception("Sourcify rate limit exceeded, please retry") + + +@pytest.mark.parametrize( + ("error", "title", "default_level"), + [ + (NOT_VERIFIED, "Contract not verified", Output.Level.WARNING), + (IMPLEMENTATION_NOT_VERIFIED, "Proxy implementation not verified", Output.Level.WARNING), + (CHAIN_NOT_SUPPORTED, "Chain not supported", Output.Level.INFO), + (RATE_LIMITED, "Could not fetch ABI", Output.Level.WARNING), + ], +) +def test_fetch_failure_is_an_error_only_when_verified_is_required( + monkeypatch: pytest.MonkeyPatch, error: Exception, title: str, default_level: Output.Level +) -> None: + def get_contract_abis(chain_id: int, contract_address: str) -> None: + raise error + + monkeypatch.setattr(client, "get_contract_abis", get_contract_abis) + + for require_verified, level in ((False, default_level), (True, Output.Level.ERROR)): + out = ListOutputAdder() + ValidateABILinter(require_verified=require_verified)._validate_contract_abis(CONTEXT, out) + assert [(output.title, output.level) for output in out.outputs] == [(title, level)] diff --git a/tests/v2/lint/test_lint_require_verified.py b/tests/v2/lint/test_lint_require_verified.py index 604b0480..3d6d9d44 100644 --- a/tests/v2/lint/test_lint_require_verified.py +++ b/tests/v2/lint/test_lint_require_verified.py @@ -44,7 +44,11 @@ def test_unverified_contract_is_an_error_only_when_required( assert out.has_errors -def test_transient_failure_stays_a_warning_when_verified_is_required(lint_descriptor: LintDescriptor) -> None: - out = lint_descriptor(raising(RATE_LIMITED), True) +def test_fetch_failure_is_an_error_only_when_verified_is_required(lint_descriptor: LintDescriptor) -> None: + out = lint_descriptor(raising(RATE_LIMITED), False) assert level_of(out, "Could not fetch ABI") == Output.Level.WARNING assert not out.has_errors + + out = lint_descriptor(raising(RATE_LIMITED), True) + assert level_of(out, "Could not fetch ABI") == Output.Level.ERROR + assert out.has_errors From 3806a6169083e27c24645d428c5ad3701261cf27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kaan=20Uzdo=C4=9Fan?= Date: Wed, 23 Sep 2026 15:23:59 +0300 Subject: [PATCH 19/21] docs(lint): state fetch failures in the lint_all docstrings of --require-verified The two lint_all entry points still described the flag as covering unverified contracts only. Co-Authored-By: Claude Opus 4.8 --- src/erc7730/lint/lint.py | 3 ++- src/erc7730/lint/v2/lint.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/erc7730/lint/lint.py b/src/erc7730/lint/lint.py index 93c80968..313e68c6 100644 --- a/src/erc7730/lint/lint.py +++ b/src/erc7730/lint/lint.py @@ -61,7 +61,8 @@ def lint_all( :param paths: paths to apply linter on :param out: output adder :param skip_abi_validation: skip ABI comparison with Sourcify reference data - :param require_verified: report contracts that are not verified on Sourcify as errors instead of warnings + :param require_verified: report contracts that are not verified on Sourcify, and reference ABIs that could not be + fetched, as errors instead of warnings :return: number of files checked """ linters = [ diff --git a/src/erc7730/lint/v2/lint.py b/src/erc7730/lint/v2/lint.py index 22049464..c47018f8 100644 --- a/src/erc7730/lint/v2/lint.py +++ b/src/erc7730/lint/v2/lint.py @@ -49,7 +49,8 @@ def lint_all(paths: list[Path], out: OutputAdder, require_verified: bool = False :param paths: paths to apply linter on :param out: output adder - :param require_verified: report contracts that are not verified on Sourcify as errors instead of warnings + :param require_verified: report contracts that are not verified on Sourcify, and reference ABIs that could not be + fetched, as errors instead of warnings :return: number of files checked """ linter = MultiLinter( From 5a4b000fcfd4d15d464dd2297ae9e02781ff8490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kaan=20Uzdo=C4=9Fan?= Date: Wed, 30 Sep 2026 17:19:00 +0300 Subject: [PATCH 20/21] fix(model): reject an address with a wrong EIP-55 checksum MixedCaseAddress accepted any 40 hex digits in any letter case, so a mixed-case address with a wrong checksum passed the lint. Such an address is most likely a typo or a corrupted copy, and the Sourcify API rejects it. A mixed-case address must now equal its EIP-55 form; the error shows the expected form. An address in lowercase only (or uppercase only) carries no checksum and stays accepted, so no descriptor of the registry changes: 1801 deployment addresses, 188 constants and 50 parameters on master pass. The chain-specific EIP-1191 checksum is not supported. The check covers every field typed MixedCaseAddress: deployments, verifyingContract, token, nativeCurrencyAddress, senderAddress, callee, spender, collection. A value that comes from $.metadata.constants is not seen by the input model, so the resolver validates it with the same rules (resolved_address), for constants and for path-or-value fields of type address. assert_not_address now matches the shape only, so an address with a wrong checksum written in place of a path still gets the "use a constant" error. In the v1 senderAddress resolver, a constant that resolved to None fell through to the exception; it now resolves to None. Two v1 fixtures used made-up mixed-case addresses; they are lowercase now. Co-Authored-By: Claude Fable 5.1 --- src/erc7730/convert/resolved/address.py | 26 +++++ src/erc7730/convert/resolved/constants.py | 11 +-- src/erc7730/convert/resolved/parameters.py | 23 +++-- src/erc7730/convert/resolved/v2/constants.py | 11 +-- src/erc7730/convert/resolved/v2/parameters.py | 29 ++++-- src/erc7730/convert/resolved/v2/values.py | 4 + src/erc7730/convert/resolved/values.py | 4 + src/erc7730/model/types.py | 32 ++++++- .../resolved/data/literal_values_input.json | 6 +- .../data/literal_values_resolved.json | 4 +- .../resolved/data/minimal_contract_input.json | 2 +- tests/model/test_types.py | 44 +++++++++ tests/v2/convert/resolved/test_address.py | 96 +++++++++++++++++++ 13 files changed, 259 insertions(+), 33 deletions(-) create mode 100644 src/erc7730/convert/resolved/address.py create mode 100644 tests/model/test_types.py create mode 100644 tests/v2/convert/resolved/test_address.py diff --git a/src/erc7730/convert/resolved/address.py b/src/erc7730/convert/resolved/address.py new file mode 100644 index 00000000..898ae8d4 --- /dev/null +++ b/src/erc7730/convert/resolved/address.py @@ -0,0 +1,26 @@ +from typing import Any + +from pydantic import TypeAdapter, ValidationError + +from erc7730.common.output import OutputAdder +from erc7730.model.types import Address, MixedCaseAddress + +_ADDRESS = TypeAdapter(MixedCaseAddress) + + +def resolved_address(value: Any, out: OutputAdder) -> Address | None: + """ + Validate a value that a parameter resolved to as an address. + + An address written in the parameter itself is validated by the input model. A value that comes from a constant + (`$.metadata.constants...`) is not, so it is validated here with the same rules: the shape, and the EIP-55 + checksum if the address is mixed-case. + + :param value: resolved value + :param out: error handler + :return: the address, or None if an error was reported + """ + try: + return Address(_ADDRESS.validate_python(value)) + except ValidationError as e: + return out.error(title="Invalid address", message=e.errors()[0]["msg"]) diff --git a/src/erc7730/convert/resolved/constants.py b/src/erc7730/convert/resolved/constants.py index 315b692c..8c03660f 100644 --- a/src/erc7730/convert/resolved/constants.py +++ b/src/erc7730/convert/resolved/constants.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from typing import Any, assert_never, override -from pydantic import TypeAdapter, ValidationError +from pydantic import TypeAdapter from typing_extensions import TypeVar from erc7730.common.output import OutputAdder @@ -11,7 +11,7 @@ from erc7730.model.input.path import ContainerPathStr, DataPathStr from erc7730.model.paths import ROOT_DESCRIPTOR_PATH, ArrayElement, ContainerPath, DataPath, DescriptorPath, Field from erc7730.model.paths.path_ops import descriptor_path_append, to_absolute -from erc7730.model.types import MixedCaseAddress +from erc7730.model.types import ADDRESS_PATTERN _T = TypeVar("_T", covariant=True) @@ -70,8 +70,8 @@ def assert_not_address(path: DataPath | ContainerPath) -> bool: case DataPath(): if path.absolute: return True - try: - TypeAdapter(MixedCaseAddress).validate_strings(str(path)) + # the shape only: an address with a wrong checksum is still an address, not a path + if ADDRESS_PATTERN.match(str(path)): out.error( title="Invalid data path", message=f""""{path}" is invalid, it must contain a data path to the address in the """ @@ -79,8 +79,7 @@ def assert_not_address(path: DataPath | ContainerPath) -> bool: "use the adequate parameter to provide a constant value.", ) return False - except ValidationError: - return True + return True case _: assert_never(path) diff --git a/src/erc7730/convert/resolved/parameters.py b/src/erc7730/convert/resolved/parameters.py index 6ac195f1..21676171 100644 --- a/src/erc7730/convert/resolved/parameters.py +++ b/src/erc7730/convert/resolved/parameters.py @@ -2,6 +2,7 @@ from erc7730.common.abi import ABIDataType from erc7730.common.output import OutputAdder +from erc7730.convert.resolved.address import resolved_address from erc7730.convert.resolved.constants import ConstantProvider from erc7730.convert.resolved.enums import get_enum, get_enum_id from erc7730.convert.resolved.values import resolve_path_or_constant_value @@ -62,15 +63,21 @@ def resolve_field_parameters( def resolve_address_name_parameters( prefix: DataPath, params: InputAddressNameParameters, constants: ConstantProvider, out: OutputAdder ) -> ResolvedAddressNameParameters | None: - sender_address: list[MixedCaseAddress] | None = None + sender_address: list[Address] | None = None if (sender_addr_input := params.senderAddress) is not None: resolved_sender = constants.resolve_or_none(sender_addr_input, out) if resolved_sender is None: sender_address = None - if isinstance(resolved_sender, str): - sender_address = [resolved_sender] + elif isinstance(resolved_sender, str): + if (address := resolved_address(resolved_sender, out)) is None: + return None + sender_address = [address] elif isinstance(resolved_sender, list): - sender_address = resolved_sender + sender_address = [] + for addr in resolved_sender: + if (address := resolved_address(addr, out)) is None: + return None + sender_address.append(address) else: raise Exception("Invalid senderAddress type") @@ -155,11 +162,13 @@ def resolve_token_amount_parameters( elif isinstance(input_addresses, list): resolved_addresses = [] for input_address in input_addresses: - if (resolved_address := constants.resolve(input_address, out)) is None: + if (address := resolved_address(constants.resolve(input_address, out), out)) is None: return None - resolved_addresses.append(Address(resolved_address)) + resolved_addresses.append(address) elif isinstance(input_addresses, str): - resolved_addresses = [Address(input_addresses)] + if (address := resolved_address(input_addresses, out)) is None: + return None + resolved_addresses = [address] else: raise Exception("Invalid nativeCurrencyAddress type") diff --git a/src/erc7730/convert/resolved/v2/constants.py b/src/erc7730/convert/resolved/v2/constants.py index 7fdcbd39..34140f84 100644 --- a/src/erc7730/convert/resolved/v2/constants.py +++ b/src/erc7730/convert/resolved/v2/constants.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from typing import Any, assert_never, override -from pydantic import TypeAdapter, ValidationError +from pydantic import TypeAdapter from typing_extensions import TypeVar from erc7730.common.output import OutputAdder @@ -12,7 +12,7 @@ from erc7730.model.input.v2.display import InputMapReference from erc7730.model.paths import ROOT_DESCRIPTOR_PATH, ArrayElement, ContainerPath, DataPath, DescriptorPath, Field from erc7730.model.paths.path_ops import descriptor_path_append, to_absolute -from erc7730.model.types import MixedCaseAddress +from erc7730.model.types import ADDRESS_PATTERN _T = TypeVar("_T", covariant=True) @@ -83,8 +83,8 @@ def assert_not_address(path: DataPath | ContainerPath) -> bool: case DataPath(): if path.absolute: return True - try: - TypeAdapter(MixedCaseAddress).validate_strings(str(path)) + # the shape only: an address with a wrong checksum is still an address, not a path + if ADDRESS_PATTERN.match(str(path)): out.error( title="Invalid data path", message=f""""{path}" is invalid, it must contain a data path to the address in the """ @@ -92,8 +92,7 @@ def assert_not_address(path: DataPath | ContainerPath) -> bool: "use the adequate parameter to provide a constant value.", ) return False - except ValidationError: - return True + return True case _: assert_never(path) diff --git a/src/erc7730/convert/resolved/v2/parameters.py b/src/erc7730/convert/resolved/v2/parameters.py index 2252d70e..83c3d243 100644 --- a/src/erc7730/convert/resolved/v2/parameters.py +++ b/src/erc7730/convert/resolved/v2/parameters.py @@ -2,6 +2,7 @@ from erc7730.common.abi import ABIDataType from erc7730.common.output import OutputAdder +from erc7730.convert.resolved.address import resolved_address from erc7730.convert.resolved.v2.constants import ConstantProvider from erc7730.convert.resolved.v2.enums import get_enum, get_enum_id from erc7730.convert.resolved.v2.values import resolve_path_or_constant_value @@ -90,9 +91,15 @@ def resolve_address_name_parameters( if resolved_sender is None: sender_address = None elif isinstance(resolved_sender, str): - sender_address = [Address(resolved_sender)] + if (address := resolved_address(resolved_sender, out)) is None: + return None + sender_address = [address] elif isinstance(resolved_sender, list): - sender_address = [Address(addr) for addr in resolved_sender] + sender_address = [] + for addr in resolved_sender: + if (address := resolved_address(addr, out)) is None: + return None + sender_address.append(address) else: raise Exception("Invalid senderAddress type") @@ -120,9 +127,15 @@ def resolve_interoperable_address_name_parameters( if resolved_sender is None: sender_address = None elif isinstance(resolved_sender, str): - sender_address = [Address(resolved_sender)] + if (address := resolved_address(resolved_sender, out)) is None: + return None + sender_address = [address] elif isinstance(resolved_sender, list): - sender_address = [Address(addr) for addr in resolved_sender] + sender_address = [] + for addr in resolved_sender: + if (address := resolved_address(addr, out)) is None: + return None + sender_address.append(address) else: raise Exception("Invalid senderAddress type") @@ -247,11 +260,13 @@ def resolve_token_amount_parameters( elif isinstance(input_addresses, list): resolved_addresses = [] for input_address in input_addresses: - if (resolved_address := constants.resolve(input_address, out)) is None: + if (address := resolved_address(constants.resolve(input_address, out), out)) is None: return None - resolved_addresses.append(Address(resolved_address)) + resolved_addresses.append(address) elif isinstance(input_addresses, str): - resolved_addresses = [Address(input_addresses)] + if (address := resolved_address(input_addresses, out)) is None: + return None + resolved_addresses = [address] else: raise Exception("Invalid nativeCurrencyAddress type") diff --git a/src/erc7730/convert/resolved/v2/values.py b/src/erc7730/convert/resolved/v2/values.py index 55ef0739..bdbf780c 100644 --- a/src/erc7730/convert/resolved/v2/values.py +++ b/src/erc7730/convert/resolved/v2/values.py @@ -4,6 +4,7 @@ from erc7730.common.abi import ABIDataType from erc7730.common.output import OutputAdder +from erc7730.convert.resolved.address import resolved_address from erc7730.convert.resolved.v2.constants import ConstantProvider from erc7730.model.input.v2.display import InputFieldBase from erc7730.model.input.v2.format import FieldFormat @@ -103,6 +104,9 @@ def resolve_path_or_constant_value( if (value := constants.resolve(input_value, out)) is None: return None + if abi_type == ABIDataType.ADDRESS and resolved_address(value, out) is None: + return None + if not isinstance(value, str | bool | int | float): return out.error( title="Invalid constant value", diff --git a/src/erc7730/convert/resolved/values.py b/src/erc7730/convert/resolved/values.py index b93629b2..e3df9869 100644 --- a/src/erc7730/convert/resolved/values.py +++ b/src/erc7730/convert/resolved/values.py @@ -4,6 +4,7 @@ from erc7730.common.abi import ABIDataType from erc7730.common.output import OutputAdder +from erc7730.convert.resolved.address import resolved_address from erc7730.convert.resolved.constants import ConstantProvider from erc7730.model.display import FieldFormat from erc7730.model.input.display import InputFieldBase @@ -99,6 +100,9 @@ def resolve_path_or_constant_value( if (value := constants.resolve(input_value, out)) is None: return None + if abi_type == ABIDataType.ADDRESS and resolved_address(value, out) is None: + return None + if not isinstance(value, str | bool | int | float): return out.error( title="Invalid constant value", diff --git a/src/erc7730/model/types.py b/src/erc7730/model/types.py index 9b249163..89d242fb 100644 --- a/src/erc7730/model/types.py +++ b/src/erc7730/model/types.py @@ -5,12 +5,40 @@ JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v1.schema.json """ +import re from typing import Annotated -from pydantic import BeforeValidator, Field +from eth_utils.address import to_checksum_address +from pydantic import AfterValidator, BeforeValidator, Field +from pydantic_core import PydanticCustomError from erc7730.common.pydantic import ErrorTypeLabel +ADDRESS_PATTERN = re.compile(r"^0x[a-fA-F0-9]{40}$") +"""The shape of an address, without the checksum.""" + + +def validate_address_checksum(value: str) -> str: + """ + Reject a mixed-case address whose letter cases do not form a valid EIP-55 checksum. + + An address written in lowercase only, or in uppercase only, carries no checksum and is accepted as is. An address + that mixes both cases claims a checksum, so a mismatch is most likely a typo or a corrupted copy. + + The chain-specific checksum of EIP-1191 (used by Rootstock) is not supported: the type does not know the chain. + """ + hex_part = value[2:] + if not any(c.islower() for c in hex_part) or not any(c.isupper() for c in hex_part): + return value + if value != (expected := to_checksum_address(value)): + raise PydanticCustomError( + "address_checksum", + 'invalid EIP-55 checksum for address "{value}", expected "{expected}" (or the address in lowercase)', + {"value": value, "expected": expected}, + ) + return value + + Id = Annotated[ str, Field( @@ -36,6 +64,8 @@ '20 bytes, hexadecimal Ethereum address prefixed with "0x" (EIP-55 or lowercase), such as ' + '"0xdac17f958d2ee523a2206206994597c13d831ec7".' ), + # after the label wrapper, so that a checksum error keeps its own message + AfterValidator(validate_address_checksum), ] Address = Annotated[ diff --git a/tests/convert/resolved/data/literal_values_input.json b/tests/convert/resolved/data/literal_values_input.json index 8ca0f8b5..975819e8 100644 --- a/tests/convert/resolved/data/literal_values_input.json +++ b/tests/convert/resolved/data/literal_values_input.json @@ -5,7 +5,7 @@ "deployments": [ { "chainId": 1, - "address": "0x0000000000000000000000000000000000000aAa" + "address": "0x0000000000000000000000000000000000000aaa" } ], "abi": [ @@ -30,7 +30,7 @@ }, { "label": "Test - addressName", - "value": "0x0000000000000000000000000000000000000Bbb", + "value": "0x0000000000000000000000000000000000000bbb", "format": "addressName", "params": { "types": [ "eoa" ], "sources": [ "ens" ]} }, @@ -41,7 +41,7 @@ }, { "label": "Test - calldata", - "value": "0x0000000000000000000000000000000000000Bbb", + "value": "0x0000000000000000000000000000000000000bbb", "format": "calldata", "params": { "callee": "0x0000000000000000000000000000000000000001" } }, diff --git a/tests/convert/resolved/data/literal_values_resolved.json b/tests/convert/resolved/data/literal_values_resolved.json index 06b8905e..ae8b09e2 100644 --- a/tests/convert/resolved/data/literal_values_resolved.json +++ b/tests/convert/resolved/data/literal_values_resolved.json @@ -27,7 +27,7 @@ "type": "constant", "type_family": "address", "type_size": 20, - "value": "0x0000000000000000000000000000000000000Bbb", + "value": "0x0000000000000000000000000000000000000bbb", "raw": "0x0000000000000000000000000000000000000bbb" }, "label": "Test - addressName", @@ -44,7 +44,7 @@ "type": "constant", "type_family": "bytes", "type_size": 20, - "value": "0x0000000000000000000000000000000000000Bbb", + "value": "0x0000000000000000000000000000000000000bbb", "raw": "0x0000000000000000000000000000000000000bbb" }, "label": "Test - calldata", diff --git a/tests/convert/resolved/data/minimal_contract_input.json b/tests/convert/resolved/data/minimal_contract_input.json index a927cb85..c18f1af1 100644 --- a/tests/convert/resolved/data/minimal_contract_input.json +++ b/tests/convert/resolved/data/minimal_contract_input.json @@ -5,7 +5,7 @@ "deployments": [ { "chainId": 1, - "address": "0x0000000000000000000000000000000000000aAa" + "address": "0x0000000000000000000000000000000000000aaa" } ], "abi": [ diff --git a/tests/model/test_types.py b/tests/model/test_types.py new file mode 100644 index 00000000..1cfceb7c --- /dev/null +++ b/tests/model/test_types.py @@ -0,0 +1,44 @@ +import pytest +from pydantic import TypeAdapter, ValidationError + +from erc7730.model.input.context import InputDeployment +from erc7730.model.types import MixedCaseAddress + +CHECKSUMMED = "0xb426b5AE61c23fF1B901a8AD1f1A3921D1E9D2F1" +WRONG_CHECKSUM = "0xb426B5aE61c23Ff1b901A8ad1F1A3921D1E9D2f1" + + +@pytest.mark.parametrize( + "address", + [ + CHECKSUMMED, + CHECKSUMMED.lower(), + "0x" + CHECKSUMMED[2:].upper(), + "0x0000000000000000000000000000000000000000", + "0x1234567890123456789012345678901234567890", + ], +) +def test_mixed_case_address_accepted(address: str) -> None: + assert TypeAdapter(MixedCaseAddress).validate_python(address) == address + + +def test_mixed_case_address_wrong_checksum() -> None: + with pytest.raises(ValidationError) as e: + TypeAdapter(MixedCaseAddress).validate_python(WRONG_CHECKSUM) + message = e.value.errors()[0]["msg"] + assert WRONG_CHECKSUM in message + assert CHECKSUMMED in message + + +@pytest.mark.parametrize( + "address", ["0xb426", "b426b5AE61c23fF1B901a8AD1f1A3921D1E9D2F1", "0xZZ26b5AE61c23fF1B901a8AD1f1A3921D1E9D2F1"] +) +def test_mixed_case_address_wrong_shape(address: str) -> None: + with pytest.raises(ValidationError) as e: + TypeAdapter(MixedCaseAddress).validate_python(address) + assert "expected a 20 bytes, hexadecimal Ethereum address" in e.value.errors()[0]["msg"] + + +def test_deployment_wrong_checksum() -> None: + with pytest.raises(ValidationError, match="invalid EIP-55 checksum"): + InputDeployment(chainId=1, address=WRONG_CHECKSUM) diff --git a/tests/v2/convert/resolved/test_address.py b/tests/v2/convert/resolved/test_address.py new file mode 100644 index 00000000..93522bab --- /dev/null +++ b/tests/v2/convert/resolved/test_address.py @@ -0,0 +1,96 @@ +""" +Address checksum checks on the way from the input to the resolved descriptor. + +An address written in a field is validated by the input model. An address that comes from a constant is only seen +by the resolver, so these tests go through the whole conversion. +""" + +from typing import Any + +import pytest + +from erc7730.common.output import ListOutputAdder +from erc7730.convert.resolved.address import resolved_address +from erc7730.convert.resolved.v2.convert_erc7730_input_to_resolved import ERC7730InputToResolved +from erc7730.model.input.v2.descriptor import InputERC7730Descriptor + +CHECKSUMMED = "0xb426b5AE61c23fF1B901a8AD1f1A3921D1E9D2F1" +WRONG_CHECKSUM = "0xb426B5aE61c23Ff1b901A8ad1F1A3921D1E9D2f1" + + +def _descriptor(constants: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]: + """A contract descriptor with one token amount field, whose parameters may refer to the constants.""" + return { + "context": { + "$id": "test", + "contract": {"deployments": [{"chainId": 1, "address": "0x0000000000000000000000000000000000000001"}]}, + }, + "metadata": {"owner": "Test", "constants": constants}, + "display": { + "formats": { + "transfer(address to,uint256 amount)": { + "intent": "Transfer", + "fields": [{"path": "amount", "label": "Amount", "format": "tokenAmount", "params": params}], + } + } + }, + } + + +def _convert(descriptor: dict[str, Any]) -> ListOutputAdder: + out = ListOutputAdder() + ERC7730InputToResolved().convert(InputERC7730Descriptor.model_validate(descriptor, strict=False), out) + return out + + +@pytest.mark.parametrize("address", [CHECKSUMMED, CHECKSUMMED.lower()]) +def test_resolved_address_accepted(address: str) -> None: + out = ListOutputAdder() + assert resolved_address(address, out) == address + assert out.outputs == [] + + +@pytest.mark.parametrize( + "value,expected_error", + [ + (WRONG_CHECKSUM, "invalid EIP-55 checksum"), + ("0xb426", "expected a 20 bytes, hexadecimal Ethereum address"), + (42, "expected a 20 bytes, hexadecimal Ethereum address"), + ], +) +def test_resolved_address_rejected(value: object, expected_error: str) -> None: + out = ListOutputAdder() + assert resolved_address(value, out) is None + assert [o.title for o in out.outputs] == ["Invalid address"] + assert expected_error in out.outputs[0].message + + +@pytest.mark.parametrize("param", ["token", "nativeCurrencyAddress"]) +def test_constant_address_accepted(param: str) -> None: + out = _convert(_descriptor({"addr": CHECKSUMMED}, {param: "$.metadata.constants.addr"})) + assert out.outputs == [] + + +@pytest.mark.parametrize("param", ["token", "nativeCurrencyAddress"]) +def test_constant_address_wrong_checksum(param: str) -> None: + out = _convert(_descriptor({"addr": WRONG_CHECKSUM}, {param: "$.metadata.constants.addr"})) + # the converter may add a generic error at the parameter after the specific one + assert out.outputs[0].title == "Invalid address" + assert "invalid EIP-55 checksum" in out.outputs[0].message + assert CHECKSUMMED in out.outputs[0].message + + +def test_constant_address_in_list_wrong_checksum() -> None: + out = _convert( + _descriptor( + {"addr": WRONG_CHECKSUM}, + {"nativeCurrencyAddress": ["0x0000000000000000000000000000000000000002", "$.metadata.constants.addr"]}, + ) + ) + assert out.outputs[0].title == "Invalid address" + assert WRONG_CHECKSUM in out.outputs[0].message + + +def test_literal_address_wrong_checksum_rejected_by_model() -> None: + with pytest.raises(ValueError, match="invalid EIP-55 checksum"): + InputERC7730Descriptor.model_validate(_descriptor({}, {"token": WRONG_CHECKSUM}), strict=False) From e6e6745e662da8fbd39345665f5650805256eb4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kaan=20Uzdo=C4=9Fan?= Date: Thu, 1 Oct 2026 11:52:06 +0300 Subject: [PATCH 21/21] fix(model): address the review of the checksum check - Limit the resolver change to v2: the v1 resolver files are back to main, and the helper lives in convert/resolved/v2/address.py. The shared MixedCaseAddress type still reaches the v1 input model, so the two v1 fixtures keep their lowercase addresses. - Do not check a constant interoperableAddressName value as a 20 bytes address. The format has the address type family, but an ERC-7930 interoperable address is a longer binary value, so a valid value failed with "Invalid address". resolve_path_or_constant_value takes an evm_address flag; resolve_field_value clears it for this format. - Reject an address in uppercase only. EIP-55 does not define it as a form without checksum, and the type says "EIP-55 or lowercase". - One address shape: MixedCaseAddress uses ADDRESS_PATTERN, and assert_not_address uses fullmatch. - resolved_addresses replaces the three copies of the list loop. - Tests: the v2 InputDeployment, a field value written as a literal and as a constant (addressName, tokenTicker), the callee, spender, collection and senderAddress parameters from a constant, and the interoperableAddressName value. Co-Authored-By: Claude Fable 5.1 --- src/erc7730/convert/resolved/constants.py | 11 +- src/erc7730/convert/resolved/parameters.py | 23 ++-- .../convert/resolved/{ => v2}/address.py | 17 +++ src/erc7730/convert/resolved/v2/constants.py | 2 +- src/erc7730/convert/resolved/v2/parameters.py | 46 +++----- src/erc7730/convert/resolved/v2/values.py | 12 ++- src/erc7730/convert/resolved/values.py | 4 - src/erc7730/model/types.py | 12 +-- tests/model/test_types.py | 20 ++-- tests/v2/convert/resolved/test_address.py | 102 ++++++++++++++---- 10 files changed, 156 insertions(+), 93 deletions(-) rename src/erc7730/convert/resolved/{ => v2}/address.py (63%) diff --git a/src/erc7730/convert/resolved/constants.py b/src/erc7730/convert/resolved/constants.py index 8c03660f..315b692c 100644 --- a/src/erc7730/convert/resolved/constants.py +++ b/src/erc7730/convert/resolved/constants.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from typing import Any, assert_never, override -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from typing_extensions import TypeVar from erc7730.common.output import OutputAdder @@ -11,7 +11,7 @@ from erc7730.model.input.path import ContainerPathStr, DataPathStr from erc7730.model.paths import ROOT_DESCRIPTOR_PATH, ArrayElement, ContainerPath, DataPath, DescriptorPath, Field from erc7730.model.paths.path_ops import descriptor_path_append, to_absolute -from erc7730.model.types import ADDRESS_PATTERN +from erc7730.model.types import MixedCaseAddress _T = TypeVar("_T", covariant=True) @@ -70,8 +70,8 @@ def assert_not_address(path: DataPath | ContainerPath) -> bool: case DataPath(): if path.absolute: return True - # the shape only: an address with a wrong checksum is still an address, not a path - if ADDRESS_PATTERN.match(str(path)): + try: + TypeAdapter(MixedCaseAddress).validate_strings(str(path)) out.error( title="Invalid data path", message=f""""{path}" is invalid, it must contain a data path to the address in the """ @@ -79,7 +79,8 @@ def assert_not_address(path: DataPath | ContainerPath) -> bool: "use the adequate parameter to provide a constant value.", ) return False - return True + except ValidationError: + return True case _: assert_never(path) diff --git a/src/erc7730/convert/resolved/parameters.py b/src/erc7730/convert/resolved/parameters.py index 21676171..6ac195f1 100644 --- a/src/erc7730/convert/resolved/parameters.py +++ b/src/erc7730/convert/resolved/parameters.py @@ -2,7 +2,6 @@ from erc7730.common.abi import ABIDataType from erc7730.common.output import OutputAdder -from erc7730.convert.resolved.address import resolved_address from erc7730.convert.resolved.constants import ConstantProvider from erc7730.convert.resolved.enums import get_enum, get_enum_id from erc7730.convert.resolved.values import resolve_path_or_constant_value @@ -63,21 +62,15 @@ def resolve_field_parameters( def resolve_address_name_parameters( prefix: DataPath, params: InputAddressNameParameters, constants: ConstantProvider, out: OutputAdder ) -> ResolvedAddressNameParameters | None: - sender_address: list[Address] | None = None + sender_address: list[MixedCaseAddress] | None = None if (sender_addr_input := params.senderAddress) is not None: resolved_sender = constants.resolve_or_none(sender_addr_input, out) if resolved_sender is None: sender_address = None - elif isinstance(resolved_sender, str): - if (address := resolved_address(resolved_sender, out)) is None: - return None - sender_address = [address] + if isinstance(resolved_sender, str): + sender_address = [resolved_sender] elif isinstance(resolved_sender, list): - sender_address = [] - for addr in resolved_sender: - if (address := resolved_address(addr, out)) is None: - return None - sender_address.append(address) + sender_address = resolved_sender else: raise Exception("Invalid senderAddress type") @@ -162,13 +155,11 @@ def resolve_token_amount_parameters( elif isinstance(input_addresses, list): resolved_addresses = [] for input_address in input_addresses: - if (address := resolved_address(constants.resolve(input_address, out), out)) is None: + if (resolved_address := constants.resolve(input_address, out)) is None: return None - resolved_addresses.append(address) + resolved_addresses.append(Address(resolved_address)) elif isinstance(input_addresses, str): - if (address := resolved_address(input_addresses, out)) is None: - return None - resolved_addresses = [address] + resolved_addresses = [Address(input_addresses)] else: raise Exception("Invalid nativeCurrencyAddress type") diff --git a/src/erc7730/convert/resolved/address.py b/src/erc7730/convert/resolved/v2/address.py similarity index 63% rename from src/erc7730/convert/resolved/address.py rename to src/erc7730/convert/resolved/v2/address.py index 898ae8d4..3ecec772 100644 --- a/src/erc7730/convert/resolved/address.py +++ b/src/erc7730/convert/resolved/v2/address.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from typing import Any from pydantic import TypeAdapter, ValidationError @@ -24,3 +25,19 @@ def resolved_address(value: Any, out: OutputAdder) -> Address | None: return Address(_ADDRESS.validate_python(value)) except ValidationError as e: return out.error(title="Invalid address", message=e.errors()[0]["msg"]) + + +def resolved_addresses(values: Sequence[Any], out: OutputAdder) -> list[Address] | None: + """ + Validate a list of values that a parameter resolved to as addresses. + + :param values: resolved values + :param out: error handler + :return: the addresses, or None after the first error was reported + """ + addresses: list[Address] = [] + for value in values: + if (address := resolved_address(value, out)) is None: + return None + addresses.append(address) + return addresses diff --git a/src/erc7730/convert/resolved/v2/constants.py b/src/erc7730/convert/resolved/v2/constants.py index 34140f84..26386db0 100644 --- a/src/erc7730/convert/resolved/v2/constants.py +++ b/src/erc7730/convert/resolved/v2/constants.py @@ -84,7 +84,7 @@ def assert_not_address(path: DataPath | ContainerPath) -> bool: if path.absolute: return True # the shape only: an address with a wrong checksum is still an address, not a path - if ADDRESS_PATTERN.match(str(path)): + if ADDRESS_PATTERN.fullmatch(str(path)): out.error( title="Invalid data path", message=f""""{path}" is invalid, it must contain a data path to the address in the """ diff --git a/src/erc7730/convert/resolved/v2/parameters.py b/src/erc7730/convert/resolved/v2/parameters.py index 83c3d243..0c9c7b0c 100644 --- a/src/erc7730/convert/resolved/v2/parameters.py +++ b/src/erc7730/convert/resolved/v2/parameters.py @@ -2,7 +2,7 @@ from erc7730.common.abi import ABIDataType from erc7730.common.output import OutputAdder -from erc7730.convert.resolved.address import resolved_address +from erc7730.convert.resolved.v2.address import resolved_addresses from erc7730.convert.resolved.v2.constants import ConstantProvider from erc7730.convert.resolved.v2.enums import get_enum, get_enum_id from erc7730.convert.resolved.v2.values import resolve_path_or_constant_value @@ -90,16 +90,10 @@ def resolve_address_name_parameters( resolved_sender = constants.resolve_or_none(sender_addr_input, out) if resolved_sender is None: sender_address = None - elif isinstance(resolved_sender, str): - if (address := resolved_address(resolved_sender, out)) is None: + elif isinstance(resolved_sender, str | list): + senders = [resolved_sender] if isinstance(resolved_sender, str) else resolved_sender + if (sender_address := resolved_addresses(senders, out)) is None: return None - sender_address = [address] - elif isinstance(resolved_sender, list): - sender_address = [] - for addr in resolved_sender: - if (address := resolved_address(addr, out)) is None: - return None - sender_address.append(address) else: raise Exception("Invalid senderAddress type") @@ -126,16 +120,10 @@ def resolve_interoperable_address_name_parameters( resolved_sender = constants.resolve_or_none(sender_addr_input, out) if resolved_sender is None: sender_address = None - elif isinstance(resolved_sender, str): - if (address := resolved_address(resolved_sender, out)) is None: + elif isinstance(resolved_sender, str | list): + senders = [resolved_sender] if isinstance(resolved_sender, str) else resolved_sender + if (sender_address := resolved_addresses(senders, out)) is None: return None - sender_address = [address] - elif isinstance(resolved_sender, list): - sender_address = [] - for addr in resolved_sender: - if (address := resolved_address(addr, out)) is None: - return None - sender_address.append(address) else: raise Exception("Invalid senderAddress type") @@ -254,19 +242,15 @@ def resolve_token_amount_parameters( list[DescriptorPathStr | MixedCaseAddress] | MixedCaseAddress | None, constants.resolve_or_none(params.nativeCurrencyAddress, out), ) - resolved_addresses: list[Address] | None + native_addresses: list[Address] | None if input_addresses is None: - resolved_addresses = None - elif isinstance(input_addresses, list): - resolved_addresses = [] - for input_address in input_addresses: - if (address := resolved_address(constants.resolve(input_address, out), out)) is None: - return None - resolved_addresses.append(address) - elif isinstance(input_addresses, str): - if (address := resolved_address(input_addresses, out)) is None: + native_addresses = None + elif isinstance(input_addresses, str | list): + inputs = [input_addresses] if isinstance(input_addresses, str) else input_addresses + # an element of the list can be a path to a constant + resolved_inputs = [constants.resolve(i, out) for i in inputs] + if None in resolved_inputs or (native_addresses := resolved_addresses(resolved_inputs, out)) is None: return None - resolved_addresses = [address] else: raise Exception("Invalid nativeCurrencyAddress type") @@ -294,7 +278,7 @@ def resolve_token_amount_parameters( return ResolvedTokenAmountParameters( token=token_resolved, - nativeCurrencyAddress=resolved_addresses, + nativeCurrencyAddress=native_addresses, threshold=resolved_threshold, message=constants.resolve_or_none(params.message, out), chainId=resolved_chain_id, diff --git a/src/erc7730/convert/resolved/v2/values.py b/src/erc7730/convert/resolved/v2/values.py index bdbf780c..bc8351aa 100644 --- a/src/erc7730/convert/resolved/v2/values.py +++ b/src/erc7730/convert/resolved/v2/values.py @@ -4,7 +4,7 @@ from erc7730.common.abi import ABIDataType from erc7730.common.output import OutputAdder -from erc7730.convert.resolved.address import resolved_address +from erc7730.convert.resolved.v2.address import resolved_address from erc7730.convert.resolved.v2.constants import ConstantProvider from erc7730.model.input.v2.display import InputFieldBase from erc7730.model.input.v2.format import FieldFormat @@ -63,6 +63,9 @@ def resolve_field_value( abi_type=abi_type, constants=constants, out=out, + # an ERC-7930 interoperable address has the address type family, but is a longer binary value + evm_address=abi_type == ABIDataType.ADDRESS + and input_field_format != FieldFormat.INTEROPERABLE_ADDRESS_NAME, ) ) is None: return out.error(title="Invalid field", message="Field must have either a path or a value.") @@ -76,6 +79,7 @@ def resolve_path_or_constant_value( abi_type: ABIDataType, constants: ConstantProvider, out: OutputAdder, + evm_address: bool | None = None, ) -> ResolvedValue | None: """ Resolve value, as a data path or constant value. @@ -86,6 +90,8 @@ def resolve_path_or_constant_value( :param abi_type: expected encoded value data type :param constants: descriptor paths constants resolver :param out: error handler + :param evm_address: whether a constant value must be a 20 bytes address (shape and EIP-55 checksum); by default, + whether the data type is an address :return: resolved value or None if error or value resolves to None """ if input_path is not None: @@ -104,7 +110,9 @@ def resolve_path_or_constant_value( if (value := constants.resolve(input_value, out)) is None: return None - if abi_type == ABIDataType.ADDRESS and resolved_address(value, out) is None: + if evm_address is None: + evm_address = abi_type == ABIDataType.ADDRESS + if evm_address and resolved_address(value, out) is None: return None if not isinstance(value, str | bool | int | float): diff --git a/src/erc7730/convert/resolved/values.py b/src/erc7730/convert/resolved/values.py index e3df9869..b93629b2 100644 --- a/src/erc7730/convert/resolved/values.py +++ b/src/erc7730/convert/resolved/values.py @@ -4,7 +4,6 @@ from erc7730.common.abi import ABIDataType from erc7730.common.output import OutputAdder -from erc7730.convert.resolved.address import resolved_address from erc7730.convert.resolved.constants import ConstantProvider from erc7730.model.display import FieldFormat from erc7730.model.input.display import InputFieldBase @@ -100,9 +99,6 @@ def resolve_path_or_constant_value( if (value := constants.resolve(input_value, out)) is None: return None - if abi_type == ABIDataType.ADDRESS and resolved_address(value, out) is None: - return None - if not isinstance(value, str | bool | int | float): return out.error( title="Invalid constant value", diff --git a/src/erc7730/model/types.py b/src/erc7730/model/types.py index 89d242fb..6c74a2d3 100644 --- a/src/erc7730/model/types.py +++ b/src/erc7730/model/types.py @@ -20,15 +20,15 @@ def validate_address_checksum(value: str) -> str: """ - Reject a mixed-case address whose letter cases do not form a valid EIP-55 checksum. + Reject an address that is neither in lowercase nor in its EIP-55 checksum form. - An address written in lowercase only, or in uppercase only, carries no checksum and is accepted as is. An address - that mixes both cases claims a checksum, so a mismatch is most likely a typo or a corrupted copy. + An address written in lowercase only carries no checksum and is accepted as is. An address with an uppercase + letter claims a checksum, so a mismatch is most likely a typo or a corrupted copy. This includes an address + written in uppercase only: EIP-55 does not define it as a form without checksum. The chain-specific checksum of EIP-1191 (used by Rootstock) is not supported: the type does not know the chain. """ - hex_part = value[2:] - if not any(c.islower() for c in hex_part) or not any(c.isupper() for c in hex_part): + if value == value.lower(): return value if value != (expected := to_checksum_address(value)): raise PydanticCustomError( @@ -58,7 +58,7 @@ def validate_address_checksum(value: str) -> str: description="An Ethereum contract address, can be lowercase or EIP-55.", min_length=42, max_length=42, - pattern=r"^0x[a-fA-F0-9]+$", + pattern=ADDRESS_PATTERN.pattern, ), ErrorTypeLabel( '20 bytes, hexadecimal Ethereum address prefixed with "0x" (EIP-55 or lowercase), such as ' diff --git a/tests/model/test_types.py b/tests/model/test_types.py index 1cfceb7c..83782adc 100644 --- a/tests/model/test_types.py +++ b/tests/model/test_types.py @@ -1,11 +1,12 @@ import pytest from pydantic import TypeAdapter, ValidationError -from erc7730.model.input.context import InputDeployment +from erc7730.model.input.v2.context import InputDeployment from erc7730.model.types import MixedCaseAddress CHECKSUMMED = "0xb426b5AE61c23fF1B901a8AD1f1A3921D1E9D2F1" WRONG_CHECKSUM = "0xb426B5aE61c23Ff1b901A8ad1F1A3921D1E9D2f1" +UPPERCASE = "0x" + CHECKSUMMED[2:].upper() @pytest.mark.parametrize( @@ -13,7 +14,6 @@ [ CHECKSUMMED, CHECKSUMMED.lower(), - "0x" + CHECKSUMMED[2:].upper(), "0x0000000000000000000000000000000000000000", "0x1234567890123456789012345678901234567890", ], @@ -22,16 +22,24 @@ def test_mixed_case_address_accepted(address: str) -> None: assert TypeAdapter(MixedCaseAddress).validate_python(address) == address -def test_mixed_case_address_wrong_checksum() -> None: +@pytest.mark.parametrize("address", [WRONG_CHECKSUM, UPPERCASE]) +def test_mixed_case_address_wrong_checksum(address: str) -> None: with pytest.raises(ValidationError) as e: - TypeAdapter(MixedCaseAddress).validate_python(WRONG_CHECKSUM) + TypeAdapter(MixedCaseAddress).validate_python(address) message = e.value.errors()[0]["msg"] - assert WRONG_CHECKSUM in message + assert "invalid EIP-55 checksum" in message + assert address in message assert CHECKSUMMED in message @pytest.mark.parametrize( - "address", ["0xb426", "b426b5AE61c23fF1B901a8AD1f1A3921D1E9D2F1", "0xZZ26b5AE61c23fF1B901a8AD1f1A3921D1E9D2F1"] + "address", + [ + "0xb426", + "b426b5AE61c23fF1B901a8AD1f1A3921D1E9D2F1", + "0xZZ26b5AE61c23fF1B901a8AD1f1A3921D1E9D2F1", + CHECKSUMMED.lower() + "\n", + ], ) def test_mixed_case_address_wrong_shape(address: str) -> None: with pytest.raises(ValidationError) as e: diff --git a/tests/v2/convert/resolved/test_address.py b/tests/v2/convert/resolved/test_address.py index 93522bab..864102a1 100644 --- a/tests/v2/convert/resolved/test_address.py +++ b/tests/v2/convert/resolved/test_address.py @@ -1,8 +1,8 @@ """ Address checksum checks on the way from the input to the resolved descriptor. -An address written in a field is validated by the input model. An address that comes from a constant is only seen -by the resolver, so these tests go through the whole conversion. +An address written in a parameter is validated by the input model. An address that comes from a constant, or that is +the literal value of a field, is only seen by the resolver, so these tests go through the whole conversion. """ from typing import Any @@ -10,27 +10,48 @@ import pytest from erc7730.common.output import ListOutputAdder -from erc7730.convert.resolved.address import resolved_address +from erc7730.convert.resolved.v2.address import resolved_address, resolved_addresses from erc7730.convert.resolved.v2.convert_erc7730_input_to_resolved import ERC7730InputToResolved from erc7730.model.input.v2.descriptor import InputERC7730Descriptor CHECKSUMMED = "0xb426b5AE61c23fF1B901a8AD1f1A3921D1E9D2F1" WRONG_CHECKSUM = "0xb426B5aE61c23Ff1b901A8ad1F1A3921D1E9D2f1" - - -def _descriptor(constants: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]: - """A contract descriptor with one token amount field, whose parameters may refer to the constants.""" +OTHER_WRONG_CHECKSUM = "0xDAC17F958D2ee523a2206206994597C13D831ec7" + +# an ERC-7930 interoperable address: longer than 20 bytes, so not an address for the checksum check +INTEROPERABLE_ADDRESS = "0x00010000010114dac17f958d2ee523a2206206994597c13d831ec7" + +# fields with an address parameter, written with a path to the constant `addr` +CONSTANT = "$.metadata.constants.addr" +FIELDS_WITH_ADDRESS_PARAMETER = { + "token": {"path": "amount", "format": "tokenAmount", "params": {"token": CONSTANT}}, + "nativeCurrencyAddress": {"path": "amount", "format": "tokenAmount", "params": {"nativeCurrencyAddress": CONSTANT}}, + "senderAddress": {"path": "to", "format": "addressName", "params": {"senderAddress": CONSTANT}}, + "callee": {"path": "data", "format": "calldata", "params": {"callee": CONSTANT}}, + "spender": {"path": "data", "format": "calldata", "params": {"callee": CHECKSUMMED, "spender": CONSTANT}}, + "collection": {"path": "amount", "format": "nftName", "params": {"collection": CONSTANT}}, +} + +# formats whose field value is an address +ADDRESS_FORMATS: dict[str, dict[str, Any]] = { + "addressName": {"format": "addressName", "params": {"types": ["eoa"]}}, + "tokenTicker": {"format": "tokenTicker"}, +} + + +def _descriptor(field: dict[str, Any], constants: dict[str, Any] | None = None) -> dict[str, Any]: + """A contract descriptor with one field, which may refer to the constants.""" return { "context": { "$id": "test", "contract": {"deployments": [{"chainId": 1, "address": "0x0000000000000000000000000000000000000001"}]}, }, - "metadata": {"owner": "Test", "constants": constants}, + "metadata": {"owner": "Test", "constants": constants or {}}, "display": { "formats": { - "transfer(address to,uint256 amount)": { + "transfer(address to,uint256 amount,bytes data)": { "intent": "Transfer", - "fields": [{"path": "amount", "label": "Amount", "format": "tokenAmount", "params": params}], + "fields": [{"label": "Field", **field}], } } }, @@ -65,15 +86,28 @@ def test_resolved_address_rejected(value: object, expected_error: str) -> None: assert expected_error in out.outputs[0].message -@pytest.mark.parametrize("param", ["token", "nativeCurrencyAddress"]) +def test_resolved_addresses_accepted() -> None: + out = ListOutputAdder() + assert resolved_addresses([CHECKSUMMED, CHECKSUMMED.lower()], out) == [CHECKSUMMED, CHECKSUMMED.lower()] + assert out.outputs == [] + + +def test_resolved_addresses_stops_at_first_error() -> None: + out = ListOutputAdder() + assert resolved_addresses([CHECKSUMMED, WRONG_CHECKSUM, OTHER_WRONG_CHECKSUM], out) is None + assert len(out.outputs) == 1 + assert WRONG_CHECKSUM in out.outputs[0].message + + +@pytest.mark.parametrize("param", FIELDS_WITH_ADDRESS_PARAMETER) def test_constant_address_accepted(param: str) -> None: - out = _convert(_descriptor({"addr": CHECKSUMMED}, {param: "$.metadata.constants.addr"})) + out = _convert(_descriptor(FIELDS_WITH_ADDRESS_PARAMETER[param], {"addr": CHECKSUMMED})) assert out.outputs == [] -@pytest.mark.parametrize("param", ["token", "nativeCurrencyAddress"]) +@pytest.mark.parametrize("param", FIELDS_WITH_ADDRESS_PARAMETER) def test_constant_address_wrong_checksum(param: str) -> None: - out = _convert(_descriptor({"addr": WRONG_CHECKSUM}, {param: "$.metadata.constants.addr"})) + out = _convert(_descriptor(FIELDS_WITH_ADDRESS_PARAMETER[param], {"addr": WRONG_CHECKSUM})) # the converter may add a generic error at the parameter after the specific one assert out.outputs[0].title == "Invalid address" assert "invalid EIP-55 checksum" in out.outputs[0].message @@ -81,16 +115,40 @@ def test_constant_address_wrong_checksum(param: str) -> None: def test_constant_address_in_list_wrong_checksum() -> None: - out = _convert( - _descriptor( - {"addr": WRONG_CHECKSUM}, - {"nativeCurrencyAddress": ["0x0000000000000000000000000000000000000002", "$.metadata.constants.addr"]}, - ) - ) + field = { + "path": "amount", + "format": "tokenAmount", + "params": {"nativeCurrencyAddress": ["0x0000000000000000000000000000000000000002", CONSTANT]}, + } + out = _convert(_descriptor(field, {"addr": WRONG_CHECKSUM})) assert out.outputs[0].title == "Invalid address" assert WRONG_CHECKSUM in out.outputs[0].message -def test_literal_address_wrong_checksum_rejected_by_model() -> None: +def test_literal_parameter_wrong_checksum_rejected_by_model() -> None: + field = {"path": "amount", "format": "tokenAmount", "params": {"token": WRONG_CHECKSUM}} with pytest.raises(ValueError, match="invalid EIP-55 checksum"): - InputERC7730Descriptor.model_validate(_descriptor({}, {"token": WRONG_CHECKSUM}), strict=False) + InputERC7730Descriptor.model_validate(_descriptor(field), strict=False) + + +@pytest.mark.parametrize("fmt", ADDRESS_FORMATS) +@pytest.mark.parametrize("value", [WRONG_CHECKSUM, CONSTANT]) +def test_field_value_wrong_checksum(fmt: str, value: str) -> None: + """The value of a field is a scalar for the input model, so only the resolver can check it.""" + out = _convert(_descriptor({"value": value, **ADDRESS_FORMATS[fmt]}, {"addr": WRONG_CHECKSUM})) + assert out.outputs[0].title == "Invalid address" + assert "invalid EIP-55 checksum" in out.outputs[0].message + + +@pytest.mark.parametrize("fmt", ADDRESS_FORMATS) +@pytest.mark.parametrize("value", [CHECKSUMMED, CONSTANT]) +def test_field_value_accepted(fmt: str, value: str) -> None: + out = _convert(_descriptor({"value": value, **ADDRESS_FORMATS[fmt]}, {"addr": CHECKSUMMED})) + assert out.outputs == [] + + +@pytest.mark.parametrize("value", [INTEROPERABLE_ADDRESS, CONSTANT]) +def test_interoperable_address_value_is_not_checked(value: str) -> None: + field = {"value": value, "format": "interoperableAddressName", "params": {"types": ["eoa"]}} + out = _convert(_descriptor(field, {"addr": INTEROPERABLE_ADDRESS})) + assert out.outputs == []