Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions src/erc7730/lint/v2/lint_validate_display_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ class ValidateDisplayFieldsLinter(ERC7730Linter):
- No schema available in v2 resolved model, so no validation is performed
"""

@staticmethod
def _coverage_message(source: str | None, unavailable: list[str], not_reached: list[str]) -> str:
"""State which deployment the ABI checks ran against, and which they did not cover.

One reference ABI validates the display fields, so a descriptor with several deployments
is only ever compared against one of them. Saying so keeps "validated" from reading as
"validated against every deployment".
"""
parts = [f"Display fields validated against the ABI of {source}."]
if unavailable:
parts.append(f"No ABI available for {len(unavailable)} earlier deployment(s): "
f"{'; '.join(unavailable)}.")
if not_reached:
parts.append(f"Not compared against {len(not_reached)} further deployment(s): "
f"{', '.join(not_reached)}.")
return " ".join(parts)

@override
def lint(
self, input_descriptor: InputERC7730Descriptor, descriptor: ResolvedERC7730Descriptor, out: OutputAdder
Expand All @@ -59,37 +76,53 @@ 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
# Fetch the reference ABI from the first deployment that has one, recording the outcome
# of every deployment so the result says what was checked and what could not be.
reference_abis = None
explorer_url = None
unavailable: list[str] = []
not_reached: list[str] = []
for deployment in deployments:
where = f"chain id {deployment.chainId} address {deployment.address}"
if reference_abis is not None:
# one reference ABI is enough to validate against, so do not fetch the rest
not_reached.append(where)
continue
try:
if (abis := client.get_contract_abis(deployment.chainId, deployment.address)) is None:
unavailable.append(f"{where}: no verified source found")
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}",
)
unavailable.append(f"{where}: {e}")
continue

reference_abis = get_functions(abis)
try:
explorer_url = client.get_contract_explorer_url(deployment.chainId, deployment.address)
except NotImplementedError:
explorer_url = f"<chain id {deployment.chainId} address {deployment.address}>"
break
explorer_url = f"<{where}>"

if reference_abis is None:
return
# Say so explicitly. A silent return is indistinguishable from a descriptor whose
# display fields were checked against an ABI and found correct.
return out.warning(
title="Display fields not validated against an ABI",
message="No deployment yielded a reference ABI, so display field paths, parameter coverage "
f"and selector exhaustiveness were not checked. Tried {len(unavailable)} deployment(s): "
+ "; ".join(unavailable),
)

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

out.info(
title="Reference ABI",
message=cls._coverage_message(explorer_url, unavailable, not_reached),
)

# Build ABI paths by selector
abi_paths_by_selector: dict[str, set[DataPath]] = {}
for selector, abi in reference_abis.functions.items():
Expand Down
37 changes: 37 additions & 0 deletions tests/v2/lint/test_lint_validate_display_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,40 @@ def test_every_declared_name_is_accepted_when_the_reference_abi_names_nothing()

def test_no_name_is_accepted_when_the_format_key_is_a_selector() -> None:
assert unnamed_parameter_names(reference_abi("", "", ""), declared_abi=None) == set()


def coverage(unavailable: list[str] | None = None, not_reached: list[str] | None = None) -> str:
return ValidateDisplayFieldsLinter._coverage_message(
"https://etherscan.io//address/0xabc#code", unavailable or [], not_reached or []
)


def test_coverage_names_the_reference_it_validated_against() -> None:
assert coverage() == "Display fields validated against the ABI of https://etherscan.io//address/0xabc#code."


def test_coverage_reports_deployments_it_never_compared() -> None:
"""One ABI validates the fields, so the other deployments are not covered and must be named."""
message = coverage(not_reached=["chain id 137 address 0xdead", "chain id 10 address 0xbeef"])

assert "Not compared against 2 further deployment(s)" in message
assert "chain id 137 address 0xdead" in message
assert "chain id 10 address 0xbeef" in message


def test_coverage_keeps_the_reason_an_earlier_deployment_had_no_abi() -> None:
"""The reason is what tells a reviewer whether to go and verify the contract."""
message = coverage(unavailable=["chain id 1 address 0xaaa: no verified source found"])

assert "No ABI available for 1 earlier deployment(s)" in message
assert "no verified source found" in message


def test_coverage_reports_both_kinds_of_gap_together() -> None:
message = coverage(
unavailable=["chain id 1 address 0xaaa: no verified source found"],
not_reached=["chain id 10 address 0xbeef"],
)

assert "No ABI available" in message
assert "Not compared against" in message
Loading