From b4e271b50a2073a77ba3d8120eb708c261475539 Mon Sep 17 00:00:00 2001 From: Julien Nicoulaud Date: Mon, 30 Sep 2024 17:26:33 +0200 Subject: [PATCH 1/8] chore: create packages --- src/erc7730/model/__init__.py | 2 +- src/erc7730/model/input/__init__.py | 8 ++++++++ src/erc7730/model/resolved/__init__.py | 8 ++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/erc7730/model/input/__init__.py create mode 100644 src/erc7730/model/resolved/__init__.py diff --git a/src/erc7730/model/__init__.py b/src/erc7730/model/__init__.py index a7335d8..08c7ce6 100644 --- a/src/erc7730/model/__init__.py +++ b/src/erc7730/model/__init__.py @@ -1 +1 @@ -"""Package implementing all typed method and their validations""" +"""Package implementing an object model for ERC-7730 descriptors.""" diff --git a/src/erc7730/model/input/__init__.py b/src/erc7730/model/input/__init__.py new file mode 100644 index 0000000..4e8b4ad --- /dev/null +++ b/src/erc7730/model/input/__init__.py @@ -0,0 +1,8 @@ +""" +Package implementing an object model for ERC-7730 resolved descriptors. + +This model represents descriptors before resolution phase: + - URLs have been not been fetched yet + - References have not been inlined + - Selectors have not been converted to 4 bytes form +""" diff --git a/src/erc7730/model/resolved/__init__.py b/src/erc7730/model/resolved/__init__.py new file mode 100644 index 0000000..88d9b43 --- /dev/null +++ b/src/erc7730/model/resolved/__init__.py @@ -0,0 +1,8 @@ +""" +Package implementing an object model for ERC-7730 input descriptors. + +This model represents descriptors after resolution phase: + - URLs have been fetched + - References have been inlined + - Selectors have been converted to 4 bytes form +""" From 71513e678ff25252d781c77430a4a125d06224eb Mon Sep 17 00:00:00 2001 From: Julien Nicoulaud Date: Tue, 1 Oct 2024 09:48:30 +0200 Subject: [PATCH 2/8] split model in input/resolved --- src/erc7730/convert/__init__.py | 6 +- .../convert/convert_eip712_to_erc7730.py | 8 +- .../convert/convert_erc7730_to_eip712.py | 6 +- src/erc7730/lint/__init__.py | 4 +- src/erc7730/lint/lint.py | 4 +- src/erc7730/lint/lint_base.py | 4 +- .../lint/lint_transaction_type_classifier.py | 6 +- src/erc7730/lint/lint_validate_abi.py | 4 +- .../lint/lint_validate_display_fields.py | 8 +- src/erc7730/main.py | 4 +- src/erc7730/model/abi.py | 6 + src/erc7730/model/base.py | 12 ++ src/erc7730/model/context.py | 44 +----- src/erc7730/model/display.py | 124 +--------------- src/erc7730/model/input/__init__.py | 2 +- src/erc7730/model/input/context.py | 38 +++++ src/erc7730/model/{ => input}/descriptor.py | 27 ++-- src/erc7730/model/input/display.py | 137 ++++++++++++++++++ src/erc7730/model/metadata.py | 7 + src/erc7730/model/resolved/__init__.py | 2 +- src/erc7730/model/resolved/context.py | 38 +++++ src/erc7730/model/resolved/descriptor.py | 75 ++++++++++ src/erc7730/model/resolved/display.py | 127 ++++++++++++++++ src/erc7730/model/types.py | 7 + src/erc7730/model/utils.py | 16 +- .../convert/test_convert_eip712_round_trip.py | 4 +- .../convert/test_convert_erc7730_to_eip712.py | 4 +- tests/files.py | 2 - tests/model/test_model_serialization.py | 8 +- tests/schemas.py | 4 +- 30 files changed, 520 insertions(+), 218 deletions(-) create mode 100644 src/erc7730/model/input/context.py rename src/erc7730/model/{ => input}/descriptor.py (67%) create mode 100644 src/erc7730/model/input/display.py create mode 100644 src/erc7730/model/resolved/context.py create mode 100644 src/erc7730/model/resolved/descriptor.py create mode 100644 src/erc7730/model/resolved/display.py diff --git a/src/erc7730/convert/__init__.py b/src/erc7730/convert/__init__.py index 4360c04..4b1be12 100644 --- a/src/erc7730/convert/__init__.py +++ b/src/erc7730/convert/__init__.py @@ -5,7 +5,7 @@ from pydantic import BaseModel -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor InputType = TypeVar("InputType", bound=BaseModel) OutputType = TypeVar("OutputType", bound=BaseModel) @@ -52,9 +52,9 @@ class Level(IntEnum): """ERC7730Converter output sink.""" -class FromERC7730Converter(ERC7730Converter[ERC7730Descriptor, OutputType], ABC): +class FromERC7730Converter(ERC7730Converter[ERC7730InputDescriptor, OutputType], ABC): """Converter from ERC-7730 to another format.""" -class ToERC7730Converter(ERC7730Converter[InputType, ERC7730Descriptor], ABC): +class ToERC7730Converter(ERC7730Converter[InputType, ERC7730InputDescriptor], ABC): """Converter from another format to ERC-7730.""" diff --git a/src/erc7730/convert/convert_eip712_to_erc7730.py b/src/erc7730/convert/convert_eip712_to_erc7730.py index 26bd9bd..a6b16f2 100644 --- a/src/erc7730/convert/convert_eip712_to_erc7730.py +++ b/src/erc7730/convert/convert_eip712_to_erc7730.py @@ -10,7 +10,7 @@ from erc7730.convert import ERC7730Converter, ToERC7730Converter from erc7730.model.context import EIP712, Deployment, Deployments, Domain, EIP712Context, EIP712JsonSchema, NameType -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor from erc7730.model.display import ( Display, Field, @@ -28,7 +28,9 @@ class EIP712toERC7730Converter(ToERC7730Converter[EIP712DAppDescriptor]): """Converts Ledger legacy EIP-712 descriptor to ERC-7730 descriptor.""" @override - def convert(self, descriptor: EIP712DAppDescriptor, error: ERC7730Converter.ErrorAdder) -> ERC7730Descriptor | None: + def convert( + self, descriptor: EIP712DAppDescriptor, error: ERC7730Converter.ErrorAdder + ) -> ERC7730InputDescriptor | None: # FIXME this code flattens all messages in first contract verifying_contract: ContractAddress | None = None contract_name = descriptor.name @@ -64,7 +66,7 @@ def convert(self, descriptor: EIP712DAppDescriptor, error: ERC7730Converter.Erro screens=None, ) - return ERC7730Descriptor( + return ERC7730InputDescriptor( context=( EIP712Context( eip712=EIP712( diff --git a/src/erc7730/convert/convert_erc7730_to_eip712.py b/src/erc7730/convert/convert_erc7730_to_eip712.py index 515051e..69a0e36 100644 --- a/src/erc7730/convert/convert_erc7730_to_eip712.py +++ b/src/erc7730/convert/convert_erc7730_to_eip712.py @@ -15,7 +15,7 @@ from erc7730.common.pydantic import model_from_json_bytes from erc7730.convert import ERC7730Converter, FromERC7730Converter from erc7730.model.context import EIP712Context, EIP712JsonSchema, NameType -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor from erc7730.model.display import ( CallDataParameters, Display, @@ -34,7 +34,9 @@ class ERC7730toEIP712Converter(FromERC7730Converter[EIP712DAppDescriptor]): """Converts ERC-7730 descriptor to Ledger legacy EIP-712 descriptor.""" @override - def convert(self, descriptor: ERC7730Descriptor, error: ERC7730Converter.ErrorAdder) -> EIP712DAppDescriptor | None: + def convert( + self, descriptor: ERC7730InputDescriptor, error: ERC7730Converter.ErrorAdder + ) -> EIP712DAppDescriptor | None: # FIXME to debug and split in smaller methods context = descriptor.context diff --git a/src/erc7730/lint/__init__.py b/src/erc7730/lint/__init__.py index 99b4d89..09b5861 100644 --- a/src/erc7730/lint/__init__.py +++ b/src/erc7730/lint/__init__.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, FilePath -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor class ERC7730Linter(ABC): @@ -16,7 +16,7 @@ class ERC7730Linter(ABC): """ @abstractmethod - def lint(self, descriptor: ERC7730Descriptor, out: "OutputAdder") -> None: + def lint(self, descriptor: ERC7730InputDescriptor, out: "OutputAdder") -> None: raise NotImplementedError() class Output(BaseModel): diff --git a/src/erc7730/lint/lint.py b/src/erc7730/lint/lint.py index cd32c18..2e0fcf9 100644 --- a/src/erc7730/lint/lint.py +++ b/src/erc7730/lint/lint.py @@ -9,7 +9,7 @@ from erc7730.lint.lint_transaction_type_classifier import ClassifyTransactionTypeLinter from erc7730.lint.lint_validate_abi import ValidateABILinter from erc7730.lint.lint_validate_display_fields import ValidateDisplayFieldsLinter -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor from erc7730.model.utils import resolve_external_references @@ -91,7 +91,7 @@ def adder(output: ERC7730Linter.Output) -> None: out(output.model_copy(update={"file": path})) try: - descriptor = ERC7730Descriptor.load(path) + descriptor = ERC7730InputDescriptor.load(path) descriptor = resolve_external_references(descriptor) linter.lint(descriptor, adder) except Exception as e: diff --git a/src/erc7730/lint/lint_base.py b/src/erc7730/lint/lint_base.py index aa02823..274ef16 100644 --- a/src/erc7730/lint/lint_base.py +++ b/src/erc7730/lint/lint_base.py @@ -1,7 +1,7 @@ from typing import final, override from erc7730.lint import ERC7730Linter -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor @final @@ -12,6 +12,6 @@ def __init__(self, linters: list[ERC7730Linter]): self.lints = linters @override - def lint(self, descriptor: ERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: + def lint(self, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: for linter in self.lints: linter.lint(descriptor, out) diff --git a/src/erc7730/lint/lint_transaction_type_classifier.py b/src/erc7730/lint/lint_transaction_type_classifier.py index d71cffb..73526ae 100644 --- a/src/erc7730/lint/lint_transaction_type_classifier.py +++ b/src/erc7730/lint/lint_transaction_type_classifier.py @@ -7,7 +7,7 @@ from erc7730.lint.classifier.abi_classifier import ABIClassifier from erc7730.lint.classifier.eip712_classifier import EIP712Classifier from erc7730.model.context import ContractContext, EIP712Context, EIP712JsonSchema -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor from erc7730.model.display import Display, Format @@ -19,7 +19,7 @@ class ClassifyTransactionTypeLinter(ERC7730Linter): """ @override - def lint(self, descriptor: ERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: + def lint(self, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: if descriptor.context is None: return None if (tx_class := self._determine_tx_class(descriptor)) is None: @@ -38,7 +38,7 @@ def lint(self, descriptor: ERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> out(linter_output) @classmethod - def _determine_tx_class(cls, descriptor: ERC7730Descriptor) -> TxClass | None: + def _determine_tx_class(cls, descriptor: ERC7730InputDescriptor) -> TxClass | None: if isinstance(descriptor.context, EIP712Context): classifier = EIP712Classifier() if descriptor.context.eip712.schemas is not None: diff --git a/src/erc7730/lint/lint_validate_abi.py b/src/erc7730/lint/lint_validate_abi.py index e79c865..b83f764 100644 --- a/src/erc7730/lint/lint_validate_abi.py +++ b/src/erc7730/lint/lint_validate_abi.py @@ -4,7 +4,7 @@ from erc7730.common.client.etherscan import get_contract_abis from erc7730.lint import ERC7730Linter from erc7730.model.context import ContractContext, EIP712Context -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor @final @@ -16,7 +16,7 @@ class ValidateABILinter(ERC7730Linter): """ @override - def lint(self, descriptor: ERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: + def lint(self, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: if isinstance(descriptor.context, EIP712Context): return self._validate_eip712_schemas(descriptor.context, out) if isinstance(descriptor.context, ContractContext): diff --git a/src/erc7730/lint/lint_validate_display_fields.py b/src/erc7730/lint/lint_validate_display_fields.py index edd140e..d88bdc2 100644 --- a/src/erc7730/lint/lint_validate_display_fields.py +++ b/src/erc7730/lint/lint_validate_display_fields.py @@ -4,7 +4,7 @@ from erc7730.lint import ERC7730Linter from erc7730.lint.common.paths import compute_eip712_paths, compute_format_paths from erc7730.model.context import ContractContext, EIP712Context, EIP712JsonSchema -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor @final @@ -15,12 +15,12 @@ class ValidateDisplayFieldsLinter(ERC7730Linter): """ @override - def lint(self, descriptor: ERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: + def lint(self, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: self._validate_eip712_paths(descriptor, out) self._validate_abi_paths(descriptor, out) @classmethod - def _validate_eip712_paths(cls, descriptor: ERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: + def _validate_eip712_paths(cls, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: if isinstance(descriptor.context, EIP712Context) and descriptor.context.eip712.schemas is not None: primary_types: set[str] = set() for schema in descriptor.context.eip712.schemas: @@ -84,7 +84,7 @@ def _validate_eip712_paths(cls, descriptor: ERC7730Descriptor, out: ERC7730Linte ) @classmethod - def _validate_abi_paths(cls, descriptor: ERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: + def _validate_abi_paths(cls, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: if ( descriptor.context is not None and descriptor.display is not None diff --git a/src/erc7730/main.py b/src/erc7730/main.py index 8ed1fd6..e3f48f4 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -9,7 +9,7 @@ from erc7730.convert.convert_eip712_to_erc7730 import EIP712toERC7730Converter from erc7730.convert.convert_erc7730_to_eip712 import ERC7730toEIP712Converter from erc7730.lint.lint import lint_all_and_print_errors -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor app = Typer( name="erc7730", @@ -75,7 +75,7 @@ def convert_erc7730_to_eip712( output_eip712_path: Annotated[Path, Argument(help="The output EIP-712 file path")], ) -> None: if not convert_to_file_and_print_errors( - input_descriptor=ERC7730Descriptor.load(input_erc7730_path), + input_descriptor=ERC7730InputDescriptor.load(input_erc7730_path), output_file=output_eip712_path, converter=ERC7730toEIP712Converter(), ): diff --git a/src/erc7730/model/abi.py b/src/erc7730/model/abi.py index d56eaf3..d00319a 100644 --- a/src/erc7730/model/abi.py +++ b/src/erc7730/model/abi.py @@ -1,3 +1,9 @@ +""" +Object model for Solidity ABIs. + +See https://docs.soliditylang.org/en/latest/abi-spec.html +""" + from enum import StrEnum from typing import Annotated, Literal, Self diff --git a/src/erc7730/model/base.py b/src/erc7730/model/base.py index 735fada..c452286 100644 --- a/src/erc7730/model/base.py +++ b/src/erc7730/model/base.py @@ -1,7 +1,19 @@ +""" +Base model for library, using pydantic. + +See https://docs.pydantic.dev +""" + from pydantic import BaseModel, ConfigDict class Model(BaseModel): + """ + Base model for library, using pydantic. + + See https://docs.pydantic.dev + """ + model_config = ConfigDict( strict=True, frozen=True, diff --git a/src/erc7730/model/context.py b/src/erc7730/model/context.py index 52a5ed4..b001519 100644 --- a/src/erc7730/model/context.py +++ b/src/erc7730/model/context.py @@ -1,8 +1,6 @@ -from enum import Enum -from typing import ForwardRef - from pydantic import AnyUrl, Field, RootModel, field_validator +from erc7730.model.abi import ABI from erc7730.model.base import Model from erc7730.model.types import ContractAddress, Id @@ -56,51 +54,13 @@ class EIP712DomainBinding(Model): eip712: EIP712 -class AbiParameter(Model): - name: str - type: str - internalType: str | None = None - components: list[ForwardRef("AbiParameter")] | None = None # type: ignore - - -AbiParameter.model_rebuild() - - -class StateMutability(Enum): - pure = "pure" - view = "view" - nonpayable = "nonpayable" - payable = "payable" - - -class Type(Enum): - function = "function" - constructor = "constructor" - receive = "receive" - fallback = "fallback" - - -class AbiJsonSchemaItem(Model): - name: str - inputs: list[AbiParameter] - outputs: list[AbiParameter] | None - stateMutability: StateMutability | None = None - type: Type - constant: bool | None = None - payable: bool | None = None - - -class AbiJsonSchema(RootModel[list[AbiJsonSchemaItem]]): - """abi json schema""" - - class Factory(Model): deployments: Deployments deployEvent: str class Contract(Model): - abi: AnyUrl | AbiJsonSchema + abi: AnyUrl | list[ABI] deployments: Deployments addressMatcher: AnyUrl | None = None factory: Factory | None = None diff --git a/src/erc7730/model/display.py b/src/erc7730/model/display.py index 299a142..07807fc 100644 --- a/src/erc7730/model/display.py +++ b/src/erc7730/model/display.py @@ -1,11 +1,9 @@ from enum import Enum -from typing import Annotated, Any, ForwardRef +from typing import Any -from pydantic import Discriminator, RootModel, Tag -from pydantic import Field as PydanticField +from pydantic import RootModel from erc7730.model.base import Model -from erc7730.model.types import Id, Path # ruff: noqa: N815 - camel case field names are tolerated to match schema @@ -31,15 +29,6 @@ class FieldFormat(str, Enum): ENUM = "enum" -class FieldsParent(Model): - path: str - - -class Reference(FieldsParent): - ref: str = PydanticField(alias="$ref") - params: dict[str, str] | None = None - - class TokenAmountParameters(Model): tokenPath: str nativeCurrencyAddress: str | None = None @@ -89,114 +78,5 @@ class UnitParameters(Model): prefix: bool | None = None -class EnumParameters(Model): - field_ref: str = PydanticField(alias="$ref") - - -def get_param_discriminator(v: Any) -> str | None: - if isinstance(v, dict): - if v.get("tokenPath") is not None: - return "token_amount" - if v.get("collectionPath") is not None: - return "nft_name" - if v.get("encoding") is not None: - return "date" - if v.get("base") is not None: - return "unit" - if v.get("$ref") is not None: - return "enum" - if v.get("type") is not None or v.get("sources") is not None: - return "address_name" - if v.get("selector") is not None or v.get("calleePath") is not None: - return "call_data" - return None - if getattr(v, "tokenPath", None) is not None: - return "token_amount" - if getattr(v, "encoding", None) is not None: - return "date" - if getattr(v, "collectionPath", None) is not None: - return "nft_name" - if getattr(v, "base", None) is not None: - return "unit" - if getattr(v, "$ref", None) is not None: - return "enum" - if getattr(v, "type", None) is not None: - return "address_name" - if getattr(v, "selector", None) is not None: - return "call_data" - return None - - -FieldParameters = Annotated[ - Annotated[AddressNameParameters, Tag("address_name")] - | Annotated[CallDataParameters, Tag("call_data")] - | Annotated[TokenAmountParameters, Tag("token_amount")] - | Annotated[NftNameParameters, Tag("nft_name")] - | Annotated[DateParameters, Tag("date")] - | Annotated[UnitParameters, Tag("unit")] - | Annotated[EnumParameters, Tag("enum")], - Discriminator(get_param_discriminator), -] - - -class FieldDescription(Model): - id: Id | None = PydanticField(None, alias="$id") - path: Path - label: str - format: FieldFormat | None - params: FieldParameters | None = None - - -class NestedFields(FieldsParent): - fields: list[ForwardRef("Field")] | None = None # type: ignore - - -def get_discriminator_value(v: Any) -> str | None: - if isinstance(v, dict): - if v.get("label") is not None and v.get("format") is not None: - return "field_description" - if v.get("fields") is not None: - return "nested_fields" - if v.get("$ref") is not None: - return "reference" - return None - if getattr(v, "label", None) is not None: - return "field_description" - if getattr(v, "fields", None) is not None: - return "nested_fields" - if getattr(v, "ref", None) is not None: - return "reference" - return None - - -class Field( - RootModel[ - Annotated[ - Annotated[Reference, Tag("reference")] - | Annotated[FieldDescription, Tag("field_description")] - | Annotated[NestedFields, Tag("nested_fields")], - Discriminator(get_discriminator_value), - ] - ] -): - """Field""" - - -NestedFields.model_rebuild() - - class Screen(RootModel[dict[str, Any]]): """Screen""" - - -class Format(Model): - field_id: Id | None = PydanticField(None, alias="$id") - intent: str | dict[str, str] | None = None - fields: list[Field] | None = None - required: list[str] | None = None - screens: dict[str, list[Screen]] | None = None - - -class Display(Model): - definitions: dict[str, FieldDescription] | None = None - formats: dict[str, Format] diff --git a/src/erc7730/model/input/__init__.py b/src/erc7730/model/input/__init__.py index 4e8b4ad..744873b 100644 --- a/src/erc7730/model/input/__init__.py +++ b/src/erc7730/model/input/__init__.py @@ -1,5 +1,5 @@ """ -Package implementing an object model for ERC-7730 resolved descriptors. +Package implementing an object model for ERC-7730 input descriptors. This model represents descriptors before resolution phase: - URLs have been not been fetched yet diff --git a/src/erc7730/model/input/context.py b/src/erc7730/model/input/context.py new file mode 100644 index 0000000..ebe0ec1 --- /dev/null +++ b/src/erc7730/model/input/context.py @@ -0,0 +1,38 @@ +from pydantic import AnyUrl, Field + +from erc7730.model.abi import ABI +from erc7730.model.base import Model +from erc7730.model.context import Deployments, Domain, EIP712JsonSchema, Factory +from erc7730.model.types import Id + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class InputEIP712(Model): + domain: Domain | None = None + schemas: list[EIP712JsonSchema | AnyUrl] + domainSeparator: str | None = None + deployments: Deployments + + +class InputEIP712DomainBinding(Model): + eip712: InputEIP712 + + +class InputContract(Model): + abi: AnyUrl | list[ABI] + deployments: Deployments + addressMatcher: AnyUrl | None = None + factory: Factory | None = None + + +class InputContractBinding(Model): + contract: InputContract + + +class InputContractContext(InputContractBinding): + id: Id | None = Field(None, alias="$id") + + +class InputEIP712Context(InputEIP712DomainBinding): + id: Id | None = Field(None, alias="$id") diff --git a/src/erc7730/model/descriptor.py b/src/erc7730/model/input/descriptor.py similarity index 67% rename from src/erc7730/model/descriptor.py rename to src/erc7730/model/input/descriptor.py index c34c861..36bc143 100644 --- a/src/erc7730/model/descriptor.py +++ b/src/erc7730/model/input/descriptor.py @@ -1,3 +1,12 @@ +""" +Package implementing an object model for ERC-7730 input descriptors. + +This model represents descriptors before resolution phase: + - URLs have been not been fetched yet + - References have not been inlined + - Selectors have not been converted to 4 bytes form +""" + from pathlib import Path from typing import Optional @@ -9,14 +18,14 @@ model_to_json_str, ) from erc7730.model.base import Model -from erc7730.model.context import ContractContext, EIP712Context -from erc7730.model.display import Display +from erc7730.model.input.context import InputContractContext, InputEIP712Context +from erc7730.model.input.display import InputDisplay from erc7730.model.metadata import Metadata # ruff: noqa: N815 - camel case field names are tolerated to match schema -class ERC7730Descriptor(Model): +class InputERC7730Descriptor(Model): """ An ERC7730 Clear Signing descriptor. @@ -26,12 +35,12 @@ class ERC7730Descriptor(Model): """ schema_: str | None = Field(None, alias="$schema") - context: ContractContext | EIP712Context + context: InputContractContext | InputEIP712Context metadata: Metadata - display: Display + display: InputDisplay @classmethod - def load(cls, path: Path) -> "ERC7730Descriptor": + def load(cls, path: Path) -> "InputERC7730Descriptor": """ Load an ERC7730 descriptor from a JSON file. @@ -39,10 +48,10 @@ def load(cls, path: Path) -> "ERC7730Descriptor": :return: validated in-memory representation of descriptor :raises Exception: if the file does not exist or has validation errors """ - return model_from_json_file_with_includes(path, ERC7730Descriptor) + return model_from_json_file_with_includes(path, InputERC7730Descriptor) @classmethod - def load_or_none(cls, path: Path) -> Optional["ERC7730Descriptor"]: + def load_or_none(cls, path: Path) -> Optional["InputERC7730Descriptor"]: """ Load an ERC7730 descriptor from a JSON file. @@ -50,7 +59,7 @@ def load_or_none(cls, path: Path) -> Optional["ERC7730Descriptor"]: :return: validated in-memory representation of descriptor, or None if file does not exist :raises Exception: if the file has validation errors """ - return model_from_json_file_with_includes_or_none(path, ERC7730Descriptor) + return model_from_json_file_with_includes_or_none(path, InputERC7730Descriptor) def to_json_string(self) -> str: """ diff --git a/src/erc7730/model/input/display.py b/src/erc7730/model/input/display.py new file mode 100644 index 0000000..70f6d95 --- /dev/null +++ b/src/erc7730/model/input/display.py @@ -0,0 +1,137 @@ +from typing import Annotated, Any, ForwardRef + +from pydantic import Discriminator, RootModel, Tag +from pydantic import Field as PydanticField + +from erc7730.model.base import Model +from erc7730.model.display import ( + AddressNameParameters, + CallDataParameters, + DateParameters, + FieldFormat, + NftNameParameters, + Screen, + TokenAmountParameters, + UnitParameters, +) +from erc7730.model.types import Id, Path + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class InputFieldsBase(Model): + path: str + + +class InputReference(InputFieldsBase): + ref: str = PydanticField(alias="$ref") + params: dict[str, str] | None = None + + +class InputEnumParameters(Model): + field_ref: str = PydanticField(alias="$ref") + + +def get_param_discriminator(v: Any) -> str | None: + if isinstance(v, dict): + if v.get("tokenPath") is not None: + return "token_amount" + if v.get("collectionPath") is not None: + return "nft_name" + if v.get("encoding") is not None: + return "date" + if v.get("base") is not None: + return "unit" + if v.get("$ref") is not None: + return "enum" + if v.get("type") is not None or v.get("sources") is not None: + return "address_name" + if v.get("selector") is not None or v.get("calleePath") is not None: + return "call_data" + return None + if getattr(v, "tokenPath", None) is not None: + return "token_amount" + if getattr(v, "encoding", None) is not None: + return "date" + if getattr(v, "collectionPath", None) is not None: + return "nft_name" + if getattr(v, "base", None) is not None: + return "unit" + if getattr(v, "$ref", None) is not None: + return "enum" + if getattr(v, "type", None) is not None: + return "address_name" + if getattr(v, "selector", None) is not None: + return "call_data" + return None + + +InputFieldParameters = Annotated[ + Annotated[AddressNameParameters, Tag("address_name")] + | Annotated[CallDataParameters, Tag("call_data")] + | Annotated[TokenAmountParameters, Tag("token_amount")] + | Annotated[NftNameParameters, Tag("nft_name")] + | Annotated[DateParameters, Tag("date")] + | Annotated[UnitParameters, Tag("unit")] + | Annotated[InputEnumParameters, Tag("enum")], + Discriminator(get_param_discriminator), +] + + +class InputFieldDescription(Model): + id: Id | None = PydanticField(None, alias="$id") + path: Path + label: str + format: FieldFormat | None + params: InputFieldParameters | None = None + + +class InputNestedFields(InputFieldsBase): + fields: list[ForwardRef("InputField")] | None = None # type: ignore + + +def get_field_discriminator(v: Any) -> str | None: + if isinstance(v, dict): + if v.get("label") is not None and v.get("format") is not None: + return "field_description" + if v.get("fields") is not None: + return "nested_fields" + if v.get("$ref") is not None: + return "reference" + return None + if getattr(v, "label", None) is not None: + return "field_description" + if getattr(v, "fields", None) is not None: + return "nested_fields" + if getattr(v, "ref", None) is not None: + return "reference" + return None + + +class InputField( + RootModel[ + Annotated[ + Annotated[InputReference, Tag("reference")] + | Annotated[InputFieldDescription, Tag("field_description")] + | Annotated[InputNestedFields, Tag("nested_fields")], + Discriminator(get_field_discriminator), + ] + ] +): + """Field""" + + +InputNestedFields.model_rebuild() + + +class InputFormat(Model): + field_id: Id | None = PydanticField(None, alias="$id") + intent: str | dict[str, str] | None = None + fields: list[InputField] | None = None + required: list[str] | None = None + screens: dict[str, list[Screen]] | None = None + + +class InputDisplay(Model): + definitions: dict[str, InputFieldDescription] | None = None + formats: dict[str, InputFormat] diff --git a/src/erc7730/model/metadata.py b/src/erc7730/model/metadata.py index 16b9d1a..697f82a 100644 --- a/src/erc7730/model/metadata.py +++ b/src/erc7730/model/metadata.py @@ -1,3 +1,10 @@ +""" +Object model for ERC-7730 descriptors `metadata` section. + +Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs +JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v1.schema.json +""" + from datetime import datetime from erc7730.model.base import Model diff --git a/src/erc7730/model/resolved/__init__.py b/src/erc7730/model/resolved/__init__.py index 88d9b43..f4440b7 100644 --- a/src/erc7730/model/resolved/__init__.py +++ b/src/erc7730/model/resolved/__init__.py @@ -1,5 +1,5 @@ """ -Package implementing an object model for ERC-7730 input descriptors. +Package implementing an object model for ERC-7730 resolved descriptors. This model represents descriptors after resolution phase: - URLs have been fetched diff --git a/src/erc7730/model/resolved/context.py b/src/erc7730/model/resolved/context.py new file mode 100644 index 0000000..5973207 --- /dev/null +++ b/src/erc7730/model/resolved/context.py @@ -0,0 +1,38 @@ +from pydantic import AnyUrl, Field + +from erc7730.model.abi import ABI +from erc7730.model.base import Model +from erc7730.model.context import Deployments, Domain, EIP712JsonSchema, Factory +from erc7730.model.types import Id + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class ResolvedEIP712(Model): + domain: Domain | None = None + schemas: list[EIP712JsonSchema] + domainSeparator: str | None = None + deployments: Deployments + + +class ResolvedEIP712DomainBinding(Model): + eip712: ResolvedEIP712 + + +class ResolvedContract(Model): + abi: list[ABI] + deployments: Deployments + addressMatcher: AnyUrl | None = None + factory: Factory | None = None + + +class ResolvedContractBinding(Model): + contract: ResolvedContract + + +class ResolvedContractContext(ResolvedContractBinding): + id: Id | None = Field(None, alias="$id") + + +class ResolvedEIP712Context(ResolvedEIP712DomainBinding): + id: Id | None = Field(None, alias="$id") diff --git a/src/erc7730/model/resolved/descriptor.py b/src/erc7730/model/resolved/descriptor.py new file mode 100644 index 0000000..26a97be --- /dev/null +++ b/src/erc7730/model/resolved/descriptor.py @@ -0,0 +1,75 @@ +""" +Module implementing an object model for ERC-7730 resolved descriptors. + +This model represents descriptors after resolution phase: + - URLs have been fetched + - References have been inlined + - Selectors have been converted to 4 bytes form +""" + +from pathlib import Path +from typing import Optional + +from pydantic import Field + +from erc7730.common.pydantic import ( + model_from_json_file_with_includes, + model_from_json_file_with_includes_or_none, + model_to_json_str, +) +from erc7730.model.base import Model +from erc7730.model.metadata import Metadata +from erc7730.model.resolved.context import ResolvedContractContext, ResolvedEIP712Context +from erc7730.model.resolved.display import ResolvedDisplay + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class ResolvedERC7730Descriptor(Model): + """ + An ERC7730 Clear Signing descriptor. + + This model represents descriptors after resolution phase: + - URLs have been fetched + - References have been inlined + - Selectors have been converted to 4 bytes form + + Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs + + JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v1.schema.json + """ + + schema_: str | None = Field(None, alias="$schema") + context: ResolvedContractContext | ResolvedEIP712Context + metadata: Metadata + display: ResolvedDisplay + + @classmethod + def load(cls, path: Path) -> "ResolvedERC7730Descriptor": + """ + Load an ERC7730 descriptor from a JSON file. + + :param path: file path + :return: validated in-memory representation of descriptor + :raises Exception: if the file does not exist or has validation errors + """ + return model_from_json_file_with_includes(path, ResolvedERC7730Descriptor) + + @classmethod + def load_or_none(cls, path: Path) -> Optional["ResolvedERC7730Descriptor"]: + """ + Load an ERC7730 descriptor from a JSON file. + + :param path: file path + :return: validated in-memory representation of descriptor, or None if file does not exist + :raises Exception: if the file has validation errors + """ + return model_from_json_file_with_includes_or_none(path, ResolvedERC7730Descriptor) + + def to_json_string(self) -> str: + """ + Serialize the descriptor to a JSON string. + + :return: JSON representation of descriptor, serialized as a string + """ + return model_to_json_str(self) diff --git a/src/erc7730/model/resolved/display.py b/src/erc7730/model/resolved/display.py new file mode 100644 index 0000000..fad8b14 --- /dev/null +++ b/src/erc7730/model/resolved/display.py @@ -0,0 +1,127 @@ +from typing import Annotated, Any, ForwardRef + +from pydantic import Discriminator, RootModel, Tag +from pydantic import Field as PydanticField + +from erc7730.model.base import Model +from erc7730.model.display import ( + AddressNameParameters, + CallDataParameters, + DateParameters, + FieldFormat, + NftNameParameters, + Screen, + TokenAmountParameters, + UnitParameters, +) +from erc7730.model.types import Id, Path + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class ResolvedFieldsParent(Model): + path: str + + +class ResolvedEnumParameters(Model): + field_ref: str = PydanticField(alias="$ref") + + +def get_param_discriminator(v: Any) -> str | None: + if isinstance(v, dict): + if v.get("tokenPath") is not None: + return "token_amount" + if v.get("collectionPath") is not None: + return "nft_name" + if v.get("encoding") is not None: + return "date" + if v.get("base") is not None: + return "unit" + if v.get("$ref") is not None: + return "enum" + if v.get("type") is not None or v.get("sources") is not None: + return "address_name" + if v.get("selector") is not None or v.get("calleePath") is not None: + return "call_data" + return None + if getattr(v, "tokenPath", None) is not None: + return "token_amount" + if getattr(v, "encoding", None) is not None: + return "date" + if getattr(v, "collectionPath", None) is not None: + return "nft_name" + if getattr(v, "base", None) is not None: + return "unit" + if getattr(v, "$ref", None) is not None: + return "enum" + if getattr(v, "type", None) is not None: + return "address_name" + if getattr(v, "selector", None) is not None: + return "call_data" + return None + + +ResolvedFieldParameters = Annotated[ + Annotated[AddressNameParameters, Tag("address_name")] + | Annotated[CallDataParameters, Tag("call_data")] + | Annotated[TokenAmountParameters, Tag("token_amount")] + | Annotated[NftNameParameters, Tag("nft_name")] + | Annotated[DateParameters, Tag("date")] + | Annotated[UnitParameters, Tag("unit")] + | Annotated[ResolvedEnumParameters, Tag("enum")], + Discriminator(get_param_discriminator), +] + + +class ResolvedFieldDescription(Model): + id: Id | None = PydanticField(None, alias="$id") + path: Path + label: str + format: FieldFormat | None + params: ResolvedFieldParameters | None = None + + +class ResolvedNestedFields(ResolvedFieldsParent): + fields: list[ForwardRef("Field")] | None = None # type: ignore + + +def get_field_discriminator(v: Any) -> str | None: + if isinstance(v, dict): + if v.get("label") is not None and v.get("format") is not None: + return "field_description" + if v.get("fields") is not None: + return "nested_fields" + return None + if getattr(v, "label", None) is not None: + return "field_description" + if getattr(v, "fields", None) is not None: + return "nested_fields" + return None + + +class Field( + RootModel[ + Annotated[ + Annotated[ResolvedFieldDescription, Tag("field_description")] + | Annotated[ResolvedNestedFields, Tag("nested_fields")], + Discriminator(get_field_discriminator), + ] + ] +): + """Field""" + + +ResolvedNestedFields.model_rebuild() + + +class ResolvedFormat(Model): + field_id: Id | None = PydanticField(None, alias="$id") + intent: str | dict[str, str] | None = None + fields: list[Field] | None = None + required: list[str] | None = None + screens: dict[str, list[Screen]] | None = None + + +class ResolvedDisplay(Model): + definitions: dict[str, ResolvedFieldDescription] | None = None + formats: dict[str, ResolvedFormat] diff --git a/src/erc7730/model/types.py b/src/erc7730/model/types.py index 4f153c5..051b31d 100644 --- a/src/erc7730/model/types.py +++ b/src/erc7730/model/types.py @@ -1,3 +1,10 @@ +""" +Base types for ERC-7730 descriptors. + +Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs +JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v1.schema.json +""" + from typing import Annotated as Ann from pydantic import Field diff --git a/src/erc7730/model/utils.py b/src/erc7730/model/utils.py index 483094d..69ba462 100644 --- a/src/erc7730/model/utils.py +++ b/src/erc7730/model/utils.py @@ -1,19 +1,23 @@ +""" +Utilities for manipulating ERC-7730 descriptors. +""" + import requests from pydantic import AnyUrl, RootModel from erc7730.model.abi import ABI from erc7730.model.context import ContractContext, Deployments, EIP712Context, EIP712JsonSchema -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor -def get_chain_ids(descriptor: ERC7730Descriptor) -> set[int] | None: +def get_chain_ids(descriptor: ERC7730InputDescriptor) -> set[int] | None: """Get deployment chaind ids for a descriptor.""" if (deployments := get_deployments(descriptor)) is None: return None return {d.chainId for d in deployments.root} -def get_deployments(descriptor: ERC7730Descriptor) -> Deployments | None: +def get_deployments(descriptor: ERC7730InputDescriptor) -> Deployments | None: """Get deployments section for a descriptor.""" if isinstance(context := descriptor.context, EIP712Context): return context.eip712.deployments @@ -22,7 +26,7 @@ def get_deployments(descriptor: ERC7730Descriptor) -> Deployments | None: raise ValueError(f"Invalid context type {type(descriptor.context)}") -def resolve_external_references(descriptor: ERC7730Descriptor) -> ERC7730Descriptor: +def resolve_external_references(descriptor: ERC7730InputDescriptor) -> ERC7730InputDescriptor: if isinstance(descriptor.context, EIP712Context): return _resolve_external_references_eip712(descriptor) if isinstance(descriptor.context, ContractContext): @@ -30,7 +34,7 @@ def resolve_external_references(descriptor: ERC7730Descriptor) -> ERC7730Descrip raise ValueError("Invalid context type") -def _resolve_external_references_eip712(descriptor: ERC7730Descriptor) -> ERC7730Descriptor: +def _resolve_external_references_eip712(descriptor: ERC7730InputDescriptor) -> ERC7730InputDescriptor: schemas: list[EIP712JsonSchema | AnyUrl] = descriptor.context.eip712.schemas # type:ignore schemas_resolved = [] for schema in schemas: @@ -52,7 +56,7 @@ def _resolve_external_references_eip712(descriptor: ERC7730Descriptor) -> ERC773 ) -def _resolve_external_references_contract(descriptor: ERC7730Descriptor) -> ERC7730Descriptor: +def _resolve_external_references_contract(descriptor: ERC7730InputDescriptor) -> ERC7730InputDescriptor: abis: AnyUrl | list[ABI] = descriptor.context.contract.abi # type:ignore if isinstance(abis, AnyUrl): resp = requests.get(_adapt_uri(abis), timeout=10) # type:ignore diff --git a/tests/convert/test_convert_eip712_round_trip.py b/tests/convert/test_convert_eip712_round_trip.py index 276cce4..b169033 100644 --- a/tests/convert/test_convert_eip712_round_trip.py +++ b/tests/convert/test_convert_eip712_round_trip.py @@ -7,7 +7,7 @@ from erc7730.convert.convert import convert_and_print_errors from erc7730.convert.convert_eip712_to_erc7730 import EIP712toERC7730Converter from erc7730.convert.convert_erc7730_to_eip712 import ERC7730toEIP712Converter -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.input.descriptor import InputERC7730Descriptor from tests.assertions import assert_model_json_equals from tests.cases import path_id from tests.files import ERC7730_EIP712_DESCRIPTORS, LEGACY_EIP712_DESCRIPTORS @@ -15,7 +15,7 @@ @pytest.mark.parametrize("input_file", ERC7730_EIP712_DESCRIPTORS, ids=path_id) def test_roundtrip_from_erc7730(input_file: Path) -> None: - input_erc7730_descriptor = ERC7730Descriptor.load(input_file) + input_erc7730_descriptor = InputERC7730Descriptor.load(input_file) legacy_eip712_descriptor = convert_and_print_errors(input_erc7730_descriptor, ERC7730toEIP712Converter()) assert legacy_eip712_descriptor is not None output_erc7730_descriptor = convert_and_print_errors(legacy_eip712_descriptor, EIP712toERC7730Converter()) diff --git a/tests/convert/test_convert_erc7730_to_eip712.py b/tests/convert/test_convert_erc7730_to_eip712.py index 39a2e9f..c2e7aaf 100644 --- a/tests/convert/test_convert_erc7730_to_eip712.py +++ b/tests/convert/test_convert_erc7730_to_eip712.py @@ -4,7 +4,7 @@ from erc7730.convert.convert import convert_and_print_errors from erc7730.convert.convert_erc7730_to_eip712 import ERC7730toEIP712Converter -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor from tests.cases import path_id from tests.files import ERC7730_EIP712_DESCRIPTORS from tests.schemas import assert_valid_legacy_eip_712 @@ -12,7 +12,7 @@ @pytest.mark.parametrize("input_file", ERC7730_EIP712_DESCRIPTORS, ids=path_id) def test_convert_erc7730_registry_files(input_file: Path) -> None: - input_descriptor = ERC7730Descriptor.load(input_file) + input_descriptor = ERC7730InputDescriptor.load(input_file) output_descriptor = convert_and_print_errors(input_descriptor, ERC7730toEIP712Converter()) assert output_descriptor is not None assert_valid_legacy_eip_712(output_descriptor) diff --git a/tests/files.py b/tests/files.py index 660be58..c191be0 100644 --- a/tests/files.py +++ b/tests/files.py @@ -29,5 +29,3 @@ # legacy registry resources LEGACY_REGISTRY = TEST_REGISTRIES / "ledger-asset-dapps" LEGACY_EIP712_DESCRIPTORS = sorted(list(LEGACY_REGISTRY.rglob("**/eip712.json"))) -LEGACY_EIP712_SCHEMA_PATH = LEGACY_REGISTRY / "ethereum" / "eip712.schema.json" -LEGACY_EIP712_SCHEMA = load_json_file(LEGACY_EIP712_SCHEMA_PATH) diff --git a/tests/model/test_model_serialization.py b/tests/model/test_model_serialization.py index 4ed1a30..687b894 100644 --- a/tests/model/test_model_serialization.py +++ b/tests/model/test_model_serialization.py @@ -7,7 +7,7 @@ from erc7730.common.json import read_json_with_includes from erc7730.common.pydantic import model_from_json_str, model_to_json_str from erc7730.model.context import AbiJsonSchemaItem -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.descriptor import ERC7730InputDescriptor from erc7730.model.display import Display from tests.assertions import assert_dict_equals from tests.cases import path_id @@ -18,13 +18,13 @@ @pytest.mark.parametrize("input_file", ERC7730_DESCRIPTORS, ids=path_id) def test_schema(input_file: Path) -> None: """Test model serializes to JSON that matches the schema.""" - assert_valid_erc_7730(ERC7730Descriptor.load(input_file)) + assert_valid_erc_7730(ERC7730InputDescriptor.load(input_file)) @pytest.mark.parametrize("input_file", ERC7730_DESCRIPTORS, ids=path_id) def test_round_trip(input_file: Path) -> None: """Test model serializes back to same JSON.""" - actual = json.loads(ERC7730Descriptor.load(input_file).to_json_string()) + actual = json.loads(ERC7730InputDescriptor.load(input_file).to_json_string()) expected = read_json_with_includes(input_file) assert_dict_equals(expected, actual) @@ -68,4 +68,4 @@ def test_22_screens_serialization_not_symmetric() -> None: @pytest.mark.raises(exception=ValidationError) def test_invalid_paths() -> None: """Test deserialization does not allow invalid paths.""" - ERC7730Descriptor.load(TEST_RESOURCES / "eip712_wrong_path.json") + ERC7730InputDescriptor.load(TEST_RESOURCES / "eip712_wrong_path.json") diff --git a/tests/schemas.py b/tests/schemas.py index c500d78..6ccf2d5 100644 --- a/tests/schemas.py +++ b/tests/schemas.py @@ -1,13 +1,13 @@ import pytest from eip712 import EIP712DAppDescriptor -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.input.descriptor import InputERC7730Descriptor from tests.assertions import assert_model_json_schema from tests.files import ERC7730_SCHEMA, LEGACY_REGISTRY from tests.io import load_json_file -def assert_valid_erc_7730(descriptor: ERC7730Descriptor) -> None: +def assert_valid_erc_7730(descriptor: InputERC7730Descriptor) -> None: """Assert descriptor serializes to a JSON that passes JSON schema validation.""" assert_model_json_schema(descriptor, ERC7730_SCHEMA) From f1e8a6f40ef4f12fe3989a8c2a563866a00bdeb4 Mon Sep 17 00:00:00 2001 From: Julien Nicoulaud Date: Tue, 1 Oct 2024 18:54:11 +0200 Subject: [PATCH 3/8] split model in input/resolved (WIP) --- src/erc7730/convert/__init__.py | 23 +- src/erc7730/convert/convert.py | 13 +- .../convert/convert_eip712_to_erc7730.py | 77 +++-- .../convert_erc7730_input_to_resolved.py | 263 ++++++++++++++++++ .../convert/convert_erc7730_to_eip712.py | 64 ++--- src/erc7730/lint/__init__.py | 4 +- src/erc7730/lint/classifier/__init__.py | 5 +- src/erc7730/lint/classifier/abi_classifier.py | 8 +- src/erc7730/lint/common/paths.py | 18 +- src/erc7730/lint/lint.py | 27 +- src/erc7730/lint/lint_base.py | 4 +- .../lint/lint_transaction_type_classifier.py | 12 +- src/erc7730/lint/lint_validate_abi.py | 4 +- .../lint/lint_validate_display_fields.py | 8 +- src/erc7730/main.py | 4 +- src/erc7730/model/input/display.py | 8 +- src/erc7730/model/resolved/descriptor.py | 4 + src/erc7730/model/resolved/display.py | 14 +- src/erc7730/model/utils.py | 65 +---- .../convert/test_convert_eip712_round_trip.py | 13 +- .../convert/test_convert_erc7730_to_eip712.py | 4 +- tests/model/test_model_serialization.py | 18 +- 22 files changed, 434 insertions(+), 226 deletions(-) create mode 100644 src/erc7730/convert/convert_erc7730_input_to_resolved.py diff --git a/src/erc7730/convert/__init__.py b/src/erc7730/convert/__init__.py index 4b1be12..3cf766c 100644 --- a/src/erc7730/convert/__init__.py +++ b/src/erc7730/convert/__init__.py @@ -1,12 +1,9 @@ from abc import ABC, abstractmethod -from collections.abc import Callable from enum import IntEnum, auto from typing import Generic, TypeVar from pydantic import BaseModel -from erc7730.model.descriptor import ERC7730InputDescriptor - InputType = TypeVar("InputType", bound=BaseModel) OutputType = TypeVar("OutputType", bound=BaseModel) @@ -39,22 +36,20 @@ class Error(BaseModel): class Level(IntEnum): """ERC7730Converter error level.""" - ERROR = auto() + WARNING = auto() """Indicates a non-fatal error: descriptor can be partially converted, but some parts will be lost.""" - FATAL = auto() + ERROR = auto() """Indicates a fatal error: descriptor cannot be converted.""" - level: Level = Level.ERROR + level: Level message: str - ErrorAdder = Callable[[Error], None] - """ERC7730Converter output sink.""" - - -class FromERC7730Converter(ERC7730Converter[ERC7730InputDescriptor, OutputType], ABC): - """Converter from ERC-7730 to another format.""" + class ErrorAdder(ABC): + """ERC7730Converter output sink.""" + def warning(self, message: str) -> None: + raise NotImplementedError() -class ToERC7730Converter(ERC7730Converter[InputType, ERC7730InputDescriptor], ABC): - """Converter from another format to ERC-7730.""" + def error(self, message: str) -> None: + raise NotImplementedError() diff --git a/src/erc7730/convert/convert.py b/src/erc7730/convert/convert.py index 6ce6495..b70453c 100644 --- a/src/erc7730/convert/convert.py +++ b/src/erc7730/convert/convert.py @@ -38,13 +38,20 @@ def convert_and_print_errors( """ errors: list[ERC7730Converter.Error] = [] - result = converter.convert(input_descriptor, errors.append) + class ErrorAdder(ERC7730Converter.ErrorAdder): + def warning(self, message: str) -> None: + errors.append(ERC7730Converter.Error(level=ERC7730Converter.Error.Level.WARNING, message=message)) + + def error(self, message: str) -> None: + errors.append(ERC7730Converter.Error(level=ERC7730Converter.Error.Level.ERROR, message=message)) + + result = converter.convert(input_descriptor, ErrorAdder()) for error in errors: match error.level: - case ERC7730Converter.Error.Level.ERROR: + case ERC7730Converter.Error.Level.WARNING: print(f"[yellow][bold]{error.level}: [/bold]{error.message}[/yellow]") - case ERC7730Converter.Error.Level.FATAL: + case ERC7730Converter.Error.Level.ERROR: print(f"[red][bold]{error.level}: [/bold]{error.message}[/red]") return result diff --git a/src/erc7730/convert/convert_eip712_to_erc7730.py b/src/erc7730/convert/convert_eip712_to_erc7730.py index a6b16f2..8c7e04e 100644 --- a/src/erc7730/convert/convert_eip712_to_erc7730.py +++ b/src/erc7730/convert/convert_eip712_to_erc7730.py @@ -8,29 +8,27 @@ ) from pydantic import AnyUrl -from erc7730.convert import ERC7730Converter, ToERC7730Converter -from erc7730.model.context import EIP712, Deployment, Deployments, Domain, EIP712Context, EIP712JsonSchema, NameType -from erc7730.model.descriptor import ERC7730InputDescriptor +from erc7730.convert import ERC7730Converter +from erc7730.model.context import Deployment, Deployments, Domain, EIP712JsonSchema, NameType from erc7730.model.display import ( - Display, - Field, - FieldDescription, FieldFormat, - Format, TokenAmountParameters, ) +from erc7730.model.input.context import InputEIP712, InputEIP712Context +from erc7730.model.input.descriptor import InputERC7730Descriptor +from erc7730.model.input.display import InputDisplay, InputField, InputFieldDescription, InputFormat from erc7730.model.metadata import Metadata from erc7730.model.types import ContractAddress @final -class EIP712toERC7730Converter(ToERC7730Converter[EIP712DAppDescriptor]): +class EIP712toERC7730Converter(ERC7730Converter[EIP712DAppDescriptor, InputERC7730Descriptor]): """Converts Ledger legacy EIP-712 descriptor to ERC-7730 descriptor.""" @override def convert( self, descriptor: EIP712DAppDescriptor, error: ERC7730Converter.ErrorAdder - ) -> ERC7730InputDescriptor | None: + ) -> InputERC7730Descriptor | None: # FIXME this code flattens all messages in first contract verifying_contract: ContractAddress | None = None contract_name = descriptor.name @@ -39,13 +37,9 @@ def convert( contract_name = descriptor.contracts[0].name # FIXME if verifying_contract is None: - return error( - ERC7730Converter.Error( - level=ERC7730Converter.Error.Level.FATAL, message="verifying_contract is undefined" - ) - ) + return error.error("verifying_contract is undefined") - formats = dict[str, Format]() + formats = dict[str, InputFormat]() schemas = list[EIP712JsonSchema | AnyUrl]() for contract in descriptor.contracts: for message in contract.messages: @@ -58,27 +52,25 @@ def convert( types=schema, ) ) - fields = [Field(self._convert_field(field)) for field in mapper.fields] - formats[mapper.label] = Format( + fields = [InputField(self._convert_field(field)) for field in mapper.fields] + formats[mapper.label] = InputFormat( intent=None, # FIXME fields=fields, required=None, # FIXME screens=None, ) - return ERC7730InputDescriptor( - context=( - EIP712Context( - eip712=EIP712( - domain=Domain( - name=descriptor.name, - version=None, # FIXME - chainId=descriptor.chain_id, - verifyingContract=verifying_contract, - ), - schemas=schemas, - deployments=Deployments([Deployment(chainId=descriptor.chain_id, address=verifying_contract)]), - ) + return InputERC7730Descriptor( + context=InputEIP712Context( + eip712=InputEIP712( + domain=Domain( + name=descriptor.name, + version=None, # FIXME + chainId=descriptor.chain_id, + verifyingContract=verifying_contract, + ), + schemas=schemas, + deployments=Deployments([Deployment(chainId=descriptor.chain_id, address=verifying_contract)]), ) ), metadata=Metadata( @@ -88,28 +80,27 @@ def convert( constants=None, # FIXME enums=None, # FIXME ), - display=Display( + display=InputDisplay( definitions=None, # FIXME formats=formats, ), ) @classmethod - def _convert_field(cls, field: EIP712Field) -> FieldDescription: + def _convert_field(cls, field: EIP712Field) -> InputFieldDescription: match field.format: + case EIP712Format.AMOUNT if field.assetPath is not None: + return InputFieldDescription( + label=field.label, + format=FieldFormat.TOKEN_AMOUNT, + params=TokenAmountParameters(tokenPath=field.assetPath), + path=field.path, + ) case EIP712Format.AMOUNT: - if field.assetPath is not None: - return FieldDescription( - label=field.label, - format=FieldFormat.TOKEN_AMOUNT, - params=TokenAmountParameters(tokenPath=field.assetPath), - path=field.path, - ) - else: - return FieldDescription(label=field.label, format=FieldFormat.AMOUNT, params=None, path=field.path) + return InputFieldDescription(label=field.label, format=FieldFormat.AMOUNT, params=None, path=field.path) case EIP712Format.DATETIME: - return FieldDescription(label=field.label, format=FieldFormat.DATE, params=None, path=field.path) + return InputFieldDescription(label=field.label, format=FieldFormat.DATE, params=None, path=field.path) case EIP712Format.RAW | None: - return FieldDescription(label=field.label, format=FieldFormat.RAW, params=None, path=field.path) + return InputFieldDescription(label=field.label, format=FieldFormat.RAW, params=None, path=field.path) case _: assert_never(field.format) diff --git a/src/erc7730/convert/convert_erc7730_input_to_resolved.py b/src/erc7730/convert/convert_erc7730_input_to_resolved.py new file mode 100644 index 0000000..841605f --- /dev/null +++ b/src/erc7730/convert/convert_erc7730_input_to_resolved.py @@ -0,0 +1,263 @@ +from typing import final, override + +import requests +from pydantic import AnyUrl, RootModel + +from erc7730.convert import ERC7730Converter +from erc7730.model.abi import ABI +from erc7730.model.context import EIP712JsonSchema +from erc7730.model.display import ( + AddressNameParameters, + CallDataParameters, + DateParameters, + FieldFormat, + NftNameParameters, + TokenAmountParameters, + UnitParameters, +) +from erc7730.model.input.context import InputContract, InputContractContext, InputEIP712, InputEIP712Context +from erc7730.model.input.descriptor import InputERC7730Descriptor +from erc7730.model.input.display import ( + InputDisplay, + InputEnumParameters, + InputField, + InputFieldDescription, + InputFieldParameters, + InputFormat, + InputNestedFields, + InputReference, +) +from erc7730.model.resolved.context import ( + ResolvedContract, + ResolvedContractContext, + ResolvedEIP712, + ResolvedEIP712Context, +) +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor +from erc7730.model.resolved.display import ( + ResolvedDisplay, + ResolvedEnumParameters, + ResolvedField, + ResolvedFieldDescription, + ResolvedFieldParameters, + ResolvedFormat, + ResolvedNestedFields, +) + + +@final +class ERC7730InputToResolved(ERC7730Converter[InputERC7730Descriptor, ResolvedERC7730Descriptor]): + """Converts ERC-7730 descriptor input to resolved form.""" + + @override + def convert( + self, descriptor: InputERC7730Descriptor, error: ERC7730Converter.ErrorAdder + ) -> ResolvedERC7730Descriptor | None: + context = self._convert_context(descriptor.context, error) + display = self._convert_display(descriptor.display, error) + + if context is None or display is None: + return None + + return ResolvedERC7730Descriptor( + # FIXME schema_=descriptor.schema_, + context=context, + metadata=descriptor.metadata, + display=display, + ) + + @classmethod + def _convert_context( + cls, context: InputContractContext | InputEIP712Context, error: ERC7730Converter.ErrorAdder + ) -> ResolvedContractContext | ResolvedEIP712Context | None: + if isinstance(context, InputContractContext): + return cls._convert_context_contract(context, error) + + if isinstance(context, InputEIP712Context): + return cls._convert_context_eip712(context, error) + + return error.error(f"Invalid context type: {type(context)}") + + @classmethod + def _convert_context_contract( + cls, context: InputContractContext, error: ERC7730Converter.ErrorAdder + ) -> ResolvedContractContext | None: + contract = cls._convert_contract(context.contract, error) + + if contract is None: + return None + + return ResolvedContractContext(contract=contract) + + @classmethod + def _convert_contract(cls, contract: InputContract, error: ERC7730Converter.ErrorAdder) -> ResolvedContract | None: + abi = cls._convert_abis(contract.abi, error) + + if abi is None: + return None + + return ResolvedContract( + abi=abi, deployments=contract.deployments, addressMatcher=contract.addressMatcher, factory=contract.factory + ) + + @classmethod + def _convert_abis(cls, abis: list[ABI] | AnyUrl, error: ERC7730Converter.ErrorAdder) -> list[ABI] | None: + if isinstance(abis, AnyUrl): + resp = requests.get(cls._adapt_uri(abis), timeout=10) # type:ignore + resp.raise_for_status() + return RootModel[list[ABI]].model_validate(resp.json()).root + + if isinstance(abis, list): + return abis + + return error.error(f"Invalid ABIs type: {type(abis)}") + + @classmethod + def _convert_context_eip712( + cls, context: InputEIP712Context, error: ERC7730Converter.ErrorAdder + ) -> ResolvedEIP712Context | None: + eip712 = cls._convert_eip712(context.eip712, error) + + if eip712 is None: + return None + + return ResolvedEIP712Context(eip712=eip712) + + @classmethod + def _convert_eip712(cls, eip712: InputEIP712, error: ERC7730Converter.ErrorAdder) -> ResolvedEIP712 | None: + schemas = cls._convert_schemas(eip712.schemas, error) + + if schemas is None: + return None + + return ResolvedEIP712( + domain=eip712.domain, + schemas=schemas, + domainSeparator=eip712.domainSeparator, + deployments=eip712.deployments, + ) + + @classmethod + def _convert_schemas( + cls, schemas: list[EIP712JsonSchema | AnyUrl], error: ERC7730Converter.ErrorAdder + ) -> list[EIP712JsonSchema] | None: + resolved_schemas = [] + for schema in schemas: + if (resolved_schema := cls._convert_schema(schema, error)) is not None: + resolved_schemas.append(resolved_schema) + return resolved_schemas + + @classmethod + def _convert_schema( + cls, schema: EIP712JsonSchema | AnyUrl, error: ERC7730Converter.ErrorAdder + ) -> EIP712JsonSchema | None: + if isinstance(schema, AnyUrl): + resp = requests.get(cls._adapt_uri(abis), timeout=10) # type:ignore + resp.raise_for_status() + return EIP712JsonSchema.model_validate(resp.json()) + + if isinstance(schema, EIP712JsonSchema): + return schema + + return error.error(f"Invalid EIP-712 schema type: {type(schema)}") + + @classmethod + def _convert_display(cls, display: InputDisplay, error: ERC7730Converter.ErrorAdder) -> ResolvedDisplay | None: + definitions = ( + {key: cls._convert_field_description(value, error) for key, value in display.definitions.items()} + if display.definitions is not None + else None + ) + + formats = {key: cls._convert_format(value, error) for key, value in display.formats.items()} + + if formats is None: + return None + + return ResolvedDisplay(definitions=definitions, formats=formats) + + @classmethod + def _convert_field_description( + cls, definition: InputFieldDescription, error: ERC7730Converter.ErrorAdder + ) -> ResolvedFieldDescription | None: + params = cls._convert_field_parameters(definition.params, error) if definition.params is not None else None + + return ResolvedFieldDescription( + # FIXME id=definition.id, + path=definition.path, + label=definition.label, + format=FieldFormat(definition.format) if definition.format is not None else None, + params=params, + ) + + @classmethod + def _convert_field_parameters( + cls, params: InputFieldParameters, error: ERC7730Converter.ErrorAdder + ) -> ResolvedFieldParameters | None: + if isinstance(params, AddressNameParameters): + return params + if isinstance(params, CallDataParameters): + return params + if isinstance(params, TokenAmountParameters): + return params + if isinstance(params, NftNameParameters): + return params + if isinstance(params, DateParameters): + return params + if isinstance(params, UnitParameters): + return params + if isinstance(params, InputEnumParameters): + return params + return error.error(f"Invalid field parameters type: {type(params)}") + + @classmethod + def _convert_enum_parameters( + cls, params: InputEnumParameters, error: ERC7730Converter.ErrorAdder + ) -> ResolvedEnumParameters | None: + return ResolvedEnumParameters(ref=params.ref) # TODO must inline here + + @classmethod + def _convert_format(cls, format: InputFormat, error: ERC7730Converter.ErrorAdder) -> ResolvedFormat | None: + fields = cls._convert_fields(format.fields, error) + + return ResolvedFormat( + # FIXME id=format.id, + intent=format.intent, + fields=fields, + required=format.required, + screens=format.screens, + ) + + @classmethod + def _convert_fields( + cls, fields: list[InputField], error: ERC7730Converter.ErrorAdder + ) -> list[ResolvedField] | None: + resolved_fields = [] + for input_format in fields: + if (resolved_field := cls._convert_field(input_format, error)) is not None: + resolved_fields.append(resolved_field) + return resolved_fields + + @classmethod + def _convert_field(cls, field: InputField, error: ERC7730Converter.ErrorAdder) -> ResolvedField | None: + if isinstance(field.root, InputReference): + raise NotImplementedError() # TODO + if isinstance(field.root, InputFieldDescription): + return cls._convert_field_description(field.root, error) + if isinstance(field.root, InputNestedFields): + return cls._convert_nested_fields(field.root, error) + return error.error(f"Invalid field type: {type(field)}") + + @classmethod + def _convert_nested_fields( + cls, fields: InputNestedFields, error: ERC7730Converter.ErrorAdder + ) -> ResolvedNestedFields | None: + resolved_fields = cls._convert_fields(fields.fields, error) + + return ResolvedNestedFields(path=fields.path, fields=resolved_fields) + + @classmethod + def _adapt_uri(cls, url: AnyUrl) -> AnyUrl: + return AnyUrl( + str(url).replace("https://github.com/", "https://raw.githubusercontent.com/").replace("/blob/", "/") + ) diff --git a/src/erc7730/convert/convert_erc7730_to_eip712.py b/src/erc7730/convert/convert_erc7730_to_eip712.py index 69a0e36..deb78e6 100644 --- a/src/erc7730/convert/convert_erc7730_to_eip712.py +++ b/src/erc7730/convert/convert_erc7730_to_eip712.py @@ -13,39 +13,36 @@ from erc7730.common.ledger import ledger_network_id from erc7730.common.pydantic import model_from_json_bytes -from erc7730.convert import ERC7730Converter, FromERC7730Converter +from erc7730.convert import ERC7730Converter from erc7730.model.context import EIP712Context, EIP712JsonSchema, NameType -from erc7730.model.descriptor import ERC7730InputDescriptor from erc7730.model.display import ( CallDataParameters, - Display, - Field, - FieldDescription, FieldFormat, - NestedFields, NftNameParameters, - Reference, TokenAmountParameters, ) +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor +from erc7730.model.resolved.display import ( + ResolvedDisplay, + ResolvedField, + ResolvedFieldDescription, + ResolvedNestedFields, +) @final -class ERC7730toEIP712Converter(FromERC7730Converter[EIP712DAppDescriptor]): +class ERC7730toEIP712Converter(ERC7730Converter[ResolvedERC7730Descriptor, EIP712DAppDescriptor]): """Converts ERC-7730 descriptor to Ledger legacy EIP-712 descriptor.""" @override def convert( - self, descriptor: ERC7730InputDescriptor, error: ERC7730Converter.ErrorAdder + self, descriptor: ResolvedERC7730Descriptor, error: ERC7730Converter.ErrorAdder ) -> EIP712DAppDescriptor | None: # FIXME to debug and split in smaller methods context = descriptor.context if not isinstance(context, EIP712Context): - return error( - ERC7730Converter.Error( - level=ERC7730Converter.Error.Level.FATAL, message="context is None or is not EIP712" - ) - ) + return error.error("context is None or is not EIP712") eip712_schema = dict[str, list[NameType]]() for schema_or_url in context.eip712.schemas: @@ -55,20 +52,18 @@ def convert( response = requests.get(str(schema_or_url), timeout=10) erc7730_schema = model_from_json_bytes(response.content, model=EIP712JsonSchema) except Exception as e: - return error(ERC7730Converter.Error(level=ERC7730Converter.Error.Level.FATAL, message=str(e))) + return error.error(str(e)) else: erc7730_schema = schema_or_url if erc7730_schema is not None: try: eip712_schema = erc7730_schema.types except Exception as e: - return error(ERC7730Converter.Error(level=ERC7730Converter.Error.Level.FATAL, message=str(e))) + return error.error(str(e)) messages = list[EIP712MessageDescriptor]() if context.eip712.domain is None: - return error( - ERC7730Converter.Error(level=ERC7730Converter.Error.Level.FATAL, message="domain is undefined") - ) + return error.error("domain is undefined") chain_id = context.eip712.domain.chainId if chain_id is None and context.eip712.deployments is not None: @@ -79,18 +74,12 @@ def convert( for deployment in context.eip712.deployments.root: contract_address = deployment.address if chain_id is None: - return error( - ERC7730Converter.Error(level=ERC7730Converter.Error.Level.FATAL, message="chain id is undefined") - ) + return error.error("chain id is undefined") name = "" if context.eip712.domain.name is not None: name = context.eip712.domain.name if contract_address is None: - return error( - ERC7730Converter.Error( - level=ERC7730Converter.Error.Level.FATAL, message="verifying contract is undefined" - ) - ) + return error.error("verifying contract is undefined") for format_label, format in descriptor.display.formats.items(): eip712_fields = list[EIP712Field]() @@ -108,32 +97,23 @@ def convert( ) if (network := ledger_network_id(chain_id)) is None: - return error( - ERC7730Converter.Error( - level=ERC7730Converter.Error.Level.FATAL, message=f"network id {chain_id} not supported" - ) - ) + return error.error(f"network id {chain_id} not supported") return EIP712DAppDescriptor(blockchainName=network, chainId=chain_id, name=name, contracts=contracts) @classmethod - def parse_field(cls, display: Display, field: Field) -> list[EIP712Field]: + def parse_field(cls, display: ResolvedDisplay, field: ResolvedField) -> list[EIP712Field]: output = list[EIP712Field]() field_root = field.root - if isinstance(field_root, Reference): - # get field from definition section - if display.definitions is not None: - f = display.definitions[field_root.ref] - output.append(cls.convert_field(f)) - elif isinstance(field_root, NestedFields): - for f in field_root.fields: # type: ignore - output.extend(cls.parse_field(display, field=f)) # type: ignore + if isinstance(field_root, ResolvedNestedFields): + for f in field_root.fields: + output.extend(cls.parse_field(display, field=f)) else: output.append(cls.convert_field(field_root)) return output @classmethod - def convert_field(cls, field: FieldDescription) -> EIP712Field: + def convert_field(cls, field: ResolvedFieldDescription) -> EIP712Field: name = field.label asset_path = None field_format = None diff --git a/src/erc7730/lint/__init__.py b/src/erc7730/lint/__init__.py index 09b5861..1b33e25 100644 --- a/src/erc7730/lint/__init__.py +++ b/src/erc7730/lint/__init__.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, FilePath -from erc7730.model.descriptor import ERC7730InputDescriptor +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor class ERC7730Linter(ABC): @@ -16,7 +16,7 @@ class ERC7730Linter(ABC): """ @abstractmethod - def lint(self, descriptor: ERC7730InputDescriptor, out: "OutputAdder") -> None: + def lint(self, descriptor: ResolvedERC7730Descriptor, out: "OutputAdder") -> None: raise NotImplementedError() class Output(BaseModel): diff --git a/src/erc7730/lint/classifier/__init__.py b/src/erc7730/lint/classifier/__init__.py index cccd306..f857341 100644 --- a/src/erc7730/lint/classifier/__init__.py +++ b/src/erc7730/lint/classifier/__init__.py @@ -2,7 +2,8 @@ from enum import StrEnum, auto from typing import Generic, TypeVar -from erc7730.model.context import AbiJsonSchema, EIP712JsonSchema +from erc7730.model.abi import ABI +from erc7730.model.context import EIP712JsonSchema class TxClass(StrEnum): @@ -12,7 +13,7 @@ class TxClass(StrEnum): WITHDRAW = auto() -Schema = TypeVar("Schema", AbiJsonSchema, EIP712JsonSchema) +Schema = TypeVar("Schema", ABI, EIP712JsonSchema) class Classifier(ABC, Generic[Schema]): diff --git a/src/erc7730/lint/classifier/abi_classifier.py b/src/erc7730/lint/classifier/abi_classifier.py index 1cf889e..6872d50 100644 --- a/src/erc7730/lint/classifier/abi_classifier.py +++ b/src/erc7730/lint/classifier/abi_classifier.py @@ -1,15 +1,15 @@ from typing import final, override from erc7730.lint.classifier import Classifier, TxClass -from erc7730.model.context import AbiJsonSchema +from erc7730.model.abi import ABI @final -class ABIClassifier(Classifier[AbiJsonSchema]): - """Given an EIP712 schema, classify the transaction type with some predefined ruleset. +class ABIClassifier(Classifier[ABI]): + """Given an ABI, classify the transaction type with some predefined ruleset. (not implemented) """ @override - def classify(self, schema: AbiJsonSchema) -> TxClass | None: + def classify(self, schema: ABI) -> TxClass | None: pass diff --git a/src/erc7730/lint/common/paths.py b/src/erc7730/lint/common/paths.py index 5a58edd..bcd3af6 100644 --- a/src/erc7730/lint/common/paths.py +++ b/src/erc7730/lint/common/paths.py @@ -1,7 +1,13 @@ from dataclasses import dataclass from erc7730.model.context import EIP712JsonSchema, NameType -from erc7730.model.display import Field, FieldDescription, Format, NestedFields, Reference, TokenAmountParameters +from erc7730.model.resolved.display import ( + ResolvedField, + ResolvedFieldDescription, + ResolvedFormat, + ResolvedNestedFields, + TokenAmountParameters, +) ARRAY_SUFFIX = "[]" @@ -45,7 +51,7 @@ class FormatPaths: container_paths: set[str] # References to values in the container -def compute_format_paths(format: Format) -> FormatPaths: +def compute_format_paths(format: ResolvedFormat) -> FormatPaths: """Compute the sets of paths referred in an ERC7730 Format.""" paths = FormatPaths(data_paths=set(), format_paths=set(), container_paths=set()) @@ -59,17 +65,15 @@ def add_path(root: str, path: str) -> None: else: paths.data_paths.add(_append_path(root, path)) - def append_paths(path: str, fields: Field | None) -> None: + def append_paths(path: str, fields: ResolvedField | None) -> None: if fields is not None: field = fields.root match field: - case Reference(): - pass # FIXME - case FieldDescription(): + case ResolvedFieldDescription(): add_path(path, field.label) if field.params and isinstance(field.params, TokenAmountParameters): # FIXME model is not correct add_path(path, _remove_slicing(field.params.tokenPath)) - case NestedFields(): + case ResolvedNestedFields(): append_paths(_append_path(path, field.path), field.fields) # type: ignore if format.fields is not None: diff --git a/src/erc7730/lint/lint.py b/src/erc7730/lint/lint.py index 2e0fcf9..fd55579 100644 --- a/src/erc7730/lint/lint.py +++ b/src/erc7730/lint/lint.py @@ -4,13 +4,14 @@ from rich import print from erc7730 import ERC_7730_REGISTRY_CALLDATA_PREFIX, ERC_7730_REGISTRY_EIP712_PREFIX +from erc7730.convert import ERC7730Converter +from erc7730.convert.convert_erc7730_input_to_resolved import ERC7730InputToResolved from erc7730.lint import ERC7730Linter from erc7730.lint.lint_base import MultiLinter from erc7730.lint.lint_transaction_type_classifier import ClassifyTransactionTypeLinter from erc7730.lint.lint_validate_abi import ValidateABILinter from erc7730.lint.lint_validate_display_fields import ValidateDisplayFieldsLinter -from erc7730.model.descriptor import ERC7730InputDescriptor -from erc7730.model.utils import resolve_external_references +from erc7730.model.input.descriptor import InputERC7730Descriptor def lint_all_and_print_errors( @@ -91,12 +92,26 @@ def adder(output: ERC7730Linter.Output) -> None: out(output.model_copy(update={"file": path})) try: - descriptor = ERC7730InputDescriptor.load(path) - descriptor = resolve_external_references(descriptor) - linter.lint(descriptor, adder) + input_descriptor = InputERC7730Descriptor.load(path) + resolved_descriptor = ERC7730InputToResolved().convert(input_descriptor, _output_adapter(adder)) + if resolved_descriptor is not None: + linter.lint(resolved_descriptor, adder) except Exception as e: out( ERC7730Linter.Output( - file=path, title="Failed to parse", message=str(e), level=ERC7730Linter.Output.Level.ERROR + file=path, title="Failed to parse descriptor", message=str(e), level=ERC7730Linter.Output.Level.ERROR ) ) + + +def _output_adapter(out: ERC7730Linter.OutputAdder) -> ERC7730Converter.ErrorAdder: + def adder(error: ERC7730Converter.Error) -> None: + out( + ERC7730Linter.Output( + title="Resolution error", + message=error.message, + level=ERC7730Converter.Error.Level.WARNING, + ) + ) + + return adder diff --git a/src/erc7730/lint/lint_base.py b/src/erc7730/lint/lint_base.py index 274ef16..94600f9 100644 --- a/src/erc7730/lint/lint_base.py +++ b/src/erc7730/lint/lint_base.py @@ -1,7 +1,7 @@ from typing import final, override from erc7730.lint import ERC7730Linter -from erc7730.model.descriptor import ERC7730InputDescriptor +from erc7730.model.descriptor import InputERC7730Descriptor @final @@ -12,6 +12,6 @@ def __init__(self, linters: list[ERC7730Linter]): self.lints = linters @override - def lint(self, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: + def lint(self, descriptor: InputERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: for linter in self.lints: linter.lint(descriptor, out) diff --git a/src/erc7730/lint/lint_transaction_type_classifier.py b/src/erc7730/lint/lint_transaction_type_classifier.py index 73526ae..8686e3e 100644 --- a/src/erc7730/lint/lint_transaction_type_classifier.py +++ b/src/erc7730/lint/lint_transaction_type_classifier.py @@ -7,8 +7,8 @@ from erc7730.lint.classifier.abi_classifier import ABIClassifier from erc7730.lint.classifier.eip712_classifier import EIP712Classifier from erc7730.model.context import ContractContext, EIP712Context, EIP712JsonSchema -from erc7730.model.descriptor import ERC7730InputDescriptor -from erc7730.model.display import Display, Format +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor +from erc7730.model.resolved.display import ResolvedDisplay, ResolvedFormat @final @@ -19,7 +19,7 @@ class ClassifyTransactionTypeLinter(ERC7730Linter): """ @override - def lint(self, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: + def lint(self, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: if descriptor.context is None: return None if (tx_class := self._determine_tx_class(descriptor)) is None: @@ -38,7 +38,7 @@ def lint(self, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdde out(linter_output) @classmethod - def _determine_tx_class(cls, descriptor: ERC7730InputDescriptor) -> TxClass | None: + def _determine_tx_class(cls, descriptor: ResolvedERC7730Descriptor) -> TxClass | None: if isinstance(descriptor.context, EIP712Context): classifier = EIP712Classifier() if descriptor.context.eip712.schemas is not None: @@ -62,7 +62,7 @@ class DisplayFormatChecker: If a field is missing emit an error. """ - def __init__(self, tx_class: TxClass, display: Display): + def __init__(self, tx_class: TxClass, display: ResolvedDisplay): self.tx_class = tx_class self.display = display @@ -105,7 +105,7 @@ def check(self) -> list[ERC7730Linter.Output]: return res @classmethod - def _get_all_displayed_fields(cls, formats: dict[str, Format]) -> set[str]: + def _get_all_displayed_fields(cls, formats: dict[str, ResolvedFormat]) -> set[str]: fields: set[str] = set() for format in formats.values(): if format.fields is not None: diff --git a/src/erc7730/lint/lint_validate_abi.py b/src/erc7730/lint/lint_validate_abi.py index b83f764..ca4d5d4 100644 --- a/src/erc7730/lint/lint_validate_abi.py +++ b/src/erc7730/lint/lint_validate_abi.py @@ -4,7 +4,7 @@ from erc7730.common.client.etherscan import get_contract_abis from erc7730.lint import ERC7730Linter from erc7730.model.context import ContractContext, EIP712Context -from erc7730.model.descriptor import ERC7730InputDescriptor +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor @final @@ -16,7 +16,7 @@ class ValidateABILinter(ERC7730Linter): """ @override - def lint(self, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: + def lint(self, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: if isinstance(descriptor.context, EIP712Context): return self._validate_eip712_schemas(descriptor.context, out) if isinstance(descriptor.context, ContractContext): diff --git a/src/erc7730/lint/lint_validate_display_fields.py b/src/erc7730/lint/lint_validate_display_fields.py index d88bdc2..a312c3f 100644 --- a/src/erc7730/lint/lint_validate_display_fields.py +++ b/src/erc7730/lint/lint_validate_display_fields.py @@ -4,7 +4,7 @@ from erc7730.lint import ERC7730Linter from erc7730.lint.common.paths import compute_eip712_paths, compute_format_paths from erc7730.model.context import ContractContext, EIP712Context, EIP712JsonSchema -from erc7730.model.descriptor import ERC7730InputDescriptor +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor @final @@ -15,12 +15,12 @@ class ValidateDisplayFieldsLinter(ERC7730Linter): """ @override - def lint(self, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: + def lint(self, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: self._validate_eip712_paths(descriptor, out) self._validate_abi_paths(descriptor, out) @classmethod - def _validate_eip712_paths(cls, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: + def _validate_eip712_paths(cls, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: if isinstance(descriptor.context, EIP712Context) and descriptor.context.eip712.schemas is not None: primary_types: set[str] = set() for schema in descriptor.context.eip712.schemas: @@ -84,7 +84,7 @@ def _validate_eip712_paths(cls, descriptor: ERC7730InputDescriptor, out: ERC7730 ) @classmethod - def _validate_abi_paths(cls, descriptor: ERC7730InputDescriptor, out: ERC7730Linter.OutputAdder) -> None: + def _validate_abi_paths(cls, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: if ( descriptor.context is not None and descriptor.display is not None diff --git a/src/erc7730/main.py b/src/erc7730/main.py index e3f48f4..8b1b54e 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -9,7 +9,7 @@ from erc7730.convert.convert_eip712_to_erc7730 import EIP712toERC7730Converter from erc7730.convert.convert_erc7730_to_eip712 import ERC7730toEIP712Converter from erc7730.lint.lint import lint_all_and_print_errors -from erc7730.model.descriptor import ERC7730InputDescriptor +from erc7730.model.descriptor import InputERC7730Descriptor app = Typer( name="erc7730", @@ -75,7 +75,7 @@ def convert_erc7730_to_eip712( output_eip712_path: Annotated[Path, Argument(help="The output EIP-712 file path")], ) -> None: if not convert_to_file_and_print_errors( - input_descriptor=ERC7730InputDescriptor.load(input_erc7730_path), + input_descriptor=InputERC7730Descriptor.load(input_erc7730_path), output_file=output_eip712_path, converter=ERC7730toEIP712Converter(), ): diff --git a/src/erc7730/model/input/display.py b/src/erc7730/model/input/display.py index 70f6d95..e0a698a 100644 --- a/src/erc7730/model/input/display.py +++ b/src/erc7730/model/input/display.py @@ -29,7 +29,7 @@ class InputReference(InputFieldsBase): class InputEnumParameters(Model): - field_ref: str = PydanticField(alias="$ref") + ref: str = PydanticField(alias="$ref") def get_param_discriminator(v: Any) -> str | None: @@ -87,7 +87,7 @@ class InputFieldDescription(Model): class InputNestedFields(InputFieldsBase): - fields: list[ForwardRef("InputField")] | None = None # type: ignore + fields: list[ForwardRef("InputField")] def get_field_discriminator(v: Any) -> str | None: @@ -125,9 +125,9 @@ class InputField( class InputFormat(Model): - field_id: Id | None = PydanticField(None, alias="$id") + id: Id | None = PydanticField(None, alias="$id") intent: str | dict[str, str] | None = None - fields: list[InputField] | None = None + fields: list[InputField] required: list[str] | None = None screens: dict[str, list[Screen]] | None = None diff --git a/src/erc7730/model/resolved/descriptor.py b/src/erc7730/model/resolved/descriptor.py index 26a97be..3f31e01 100644 --- a/src/erc7730/model/resolved/descriptor.py +++ b/src/erc7730/model/resolved/descriptor.py @@ -4,6 +4,8 @@ This model represents descriptors after resolution phase: - URLs have been fetched - References have been inlined + - Constants have been inlined + - Enums have been inlined - Selectors have been converted to 4 bytes form """ @@ -32,6 +34,8 @@ class ResolvedERC7730Descriptor(Model): This model represents descriptors after resolution phase: - URLs have been fetched - References have been inlined + - Constants have been inlined + - Enums have been inlined - Selectors have been converted to 4 bytes form Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs diff --git a/src/erc7730/model/resolved/display.py b/src/erc7730/model/resolved/display.py index fad8b14..162e25a 100644 --- a/src/erc7730/model/resolved/display.py +++ b/src/erc7730/model/resolved/display.py @@ -19,12 +19,12 @@ # ruff: noqa: N815 - camel case field names are tolerated to match schema -class ResolvedFieldsParent(Model): +class ResolvedFieldsBase(Model): path: str class ResolvedEnumParameters(Model): - field_ref: str = PydanticField(alias="$ref") + ref: str = PydanticField(alias="$ref") # TODO must be inlined here def get_param_discriminator(v: Any) -> str | None: @@ -81,8 +81,8 @@ class ResolvedFieldDescription(Model): params: ResolvedFieldParameters | None = None -class ResolvedNestedFields(ResolvedFieldsParent): - fields: list[ForwardRef("Field")] | None = None # type: ignore +class ResolvedNestedFields(ResolvedFieldsBase): + fields: list[ForwardRef("ResolvedField")] def get_field_discriminator(v: Any) -> str | None: @@ -99,7 +99,7 @@ def get_field_discriminator(v: Any) -> str | None: return None -class Field( +class ResolvedField( RootModel[ Annotated[ Annotated[ResolvedFieldDescription, Tag("field_description")] @@ -115,9 +115,9 @@ class Field( class ResolvedFormat(Model): - field_id: Id | None = PydanticField(None, alias="$id") + id: Id | None = PydanticField(None, alias="$id") intent: str | dict[str, str] | None = None - fields: list[Field] | None = None + fields: list[ResolvedField] required: list[str] | None = None screens: dict[str, list[Screen]] | None = None diff --git a/src/erc7730/model/utils.py b/src/erc7730/model/utils.py index 69ba462..a409edd 100644 --- a/src/erc7730/model/utils.py +++ b/src/erc7730/model/utils.py @@ -2,78 +2,21 @@ Utilities for manipulating ERC-7730 descriptors. """ -import requests -from pydantic import AnyUrl, RootModel +from erc7730.model.context import ContractContext, Deployments, EIP712Context +from erc7730.model.input.descriptor import InputERC7730Descriptor -from erc7730.model.abi import ABI -from erc7730.model.context import ContractContext, Deployments, EIP712Context, EIP712JsonSchema -from erc7730.model.descriptor import ERC7730InputDescriptor - -def get_chain_ids(descriptor: ERC7730InputDescriptor) -> set[int] | None: +def get_chain_ids(descriptor: InputERC7730Descriptor) -> set[int] | None: """Get deployment chaind ids for a descriptor.""" if (deployments := get_deployments(descriptor)) is None: return None return {d.chainId for d in deployments.root} -def get_deployments(descriptor: ERC7730InputDescriptor) -> Deployments | None: +def get_deployments(descriptor: InputERC7730Descriptor) -> Deployments | None: """Get deployments section for a descriptor.""" if isinstance(context := descriptor.context, EIP712Context): return context.eip712.deployments if isinstance(context := descriptor.context, ContractContext): return context.contract.deployments raise ValueError(f"Invalid context type {type(descriptor.context)}") - - -def resolve_external_references(descriptor: ERC7730InputDescriptor) -> ERC7730InputDescriptor: - if isinstance(descriptor.context, EIP712Context): - return _resolve_external_references_eip712(descriptor) - if isinstance(descriptor.context, ContractContext): - return _resolve_external_references_contract(descriptor) - raise ValueError("Invalid context type") - - -def _resolve_external_references_eip712(descriptor: ERC7730InputDescriptor) -> ERC7730InputDescriptor: - schemas: list[EIP712JsonSchema | AnyUrl] = descriptor.context.eip712.schemas # type:ignore - schemas_resolved = [] - for schema in schemas: - if isinstance(schemas, AnyUrl): - resp = requests.get(_adapt_uri(schema), timeout=10) # type:ignore - resp.raise_for_status() - model: type[RootModel[EIP712JsonSchema]] = RootModel[EIP712JsonSchema] - json = resp.json() - schema_resolved = model.model_validate(json).root - else: - schema_resolved = schema # type:ignore - schemas_resolved.append(schema_resolved) - return descriptor.model_copy( - update={ - "context": descriptor.context.model_copy( - update={"eip712": descriptor.context.eip712.model_copy(update={"schemas": schemas_resolved})} # type:ignore - ) - } - ) - - -def _resolve_external_references_contract(descriptor: ERC7730InputDescriptor) -> ERC7730InputDescriptor: - abis: AnyUrl | list[ABI] = descriptor.context.contract.abi # type:ignore - if isinstance(abis, AnyUrl): - resp = requests.get(_adapt_uri(abis), timeout=10) # type:ignore - resp.raise_for_status() - json = resp.json() - model: type[RootModel[list[ABI]]] = RootModel[list[ABI]] - abis_resolved = model.model_validate(json).root - else: - abis_resolved = abis - return descriptor.model_copy( - update={ - "context": descriptor.context.model_copy( - update={"contract": descriptor.context.contract.model_copy(update={"abi": abis_resolved})} # type:ignore - ) - } - ) - - -def _adapt_uri(url: AnyUrl) -> AnyUrl: - return AnyUrl(str(url).replace("https://github.com/", "https://raw.githubusercontent.com/").replace("/blob/", "/")) diff --git a/tests/convert/test_convert_eip712_round_trip.py b/tests/convert/test_convert_eip712_round_trip.py index b169033..b94db87 100644 --- a/tests/convert/test_convert_eip712_round_trip.py +++ b/tests/convert/test_convert_eip712_round_trip.py @@ -6,6 +6,7 @@ from erc7730.common.pydantic import model_from_json_file_with_includes from erc7730.convert.convert import convert_and_print_errors from erc7730.convert.convert_eip712_to_erc7730 import EIP712toERC7730Converter +from erc7730.convert.convert_erc7730_input_to_resolved import ERC7730InputToResolved from erc7730.convert.convert_erc7730_to_eip712 import ERC7730toEIP712Converter from erc7730.model.input.descriptor import InputERC7730Descriptor from tests.assertions import assert_model_json_equals @@ -16,7 +17,9 @@ @pytest.mark.parametrize("input_file", ERC7730_EIP712_DESCRIPTORS, ids=path_id) def test_roundtrip_from_erc7730(input_file: Path) -> None: input_erc7730_descriptor = InputERC7730Descriptor.load(input_file) - legacy_eip712_descriptor = convert_and_print_errors(input_erc7730_descriptor, ERC7730toEIP712Converter()) + resolved_erc7730_descriptor = convert_and_print_errors(input_erc7730_descriptor, ERC7730InputToResolved()) + assert resolved_erc7730_descriptor is not None + legacy_eip712_descriptor = convert_and_print_errors(resolved_erc7730_descriptor, ERC7730toEIP712Converter()) assert legacy_eip712_descriptor is not None output_erc7730_descriptor = convert_and_print_errors(legacy_eip712_descriptor, EIP712toERC7730Converter()) assert output_erc7730_descriptor is not None @@ -26,8 +29,10 @@ def test_roundtrip_from_erc7730(input_file: Path) -> None: @pytest.mark.parametrize("input_file", LEGACY_EIP712_DESCRIPTORS, ids=path_id) def test_roundtrip_from_legacy_eip712(input_file: Path) -> None: input_legacy_eip712_descriptor = model_from_json_file_with_includes(input_file, EIP712DAppDescriptor) - erc7730_descriptor = convert_and_print_errors(input_legacy_eip712_descriptor, EIP712toERC7730Converter()) - assert erc7730_descriptor is not None - output_legacy_eip712_descriptor = convert_and_print_errors(erc7730_descriptor, ERC7730toEIP712Converter()) + input_erc7730_descriptor = convert_and_print_errors(input_legacy_eip712_descriptor, EIP712toERC7730Converter()) + assert input_erc7730_descriptor is not None + resolved_erc7730_descriptor = convert_and_print_errors(input_erc7730_descriptor, ERC7730InputToResolved()) + assert resolved_erc7730_descriptor is not None + output_legacy_eip712_descriptor = convert_and_print_errors(resolved_erc7730_descriptor, ERC7730toEIP712Converter()) assert output_legacy_eip712_descriptor is not None assert_model_json_equals(input_legacy_eip712_descriptor, output_legacy_eip712_descriptor) diff --git a/tests/convert/test_convert_erc7730_to_eip712.py b/tests/convert/test_convert_erc7730_to_eip712.py index c2e7aaf..a679a6c 100644 --- a/tests/convert/test_convert_erc7730_to_eip712.py +++ b/tests/convert/test_convert_erc7730_to_eip712.py @@ -4,7 +4,7 @@ from erc7730.convert.convert import convert_and_print_errors from erc7730.convert.convert_erc7730_to_eip712 import ERC7730toEIP712Converter -from erc7730.model.descriptor import ERC7730InputDescriptor +from erc7730.model.descriptor import InputERC7730Descriptor from tests.cases import path_id from tests.files import ERC7730_EIP712_DESCRIPTORS from tests.schemas import assert_valid_legacy_eip_712 @@ -12,7 +12,7 @@ @pytest.mark.parametrize("input_file", ERC7730_EIP712_DESCRIPTORS, ids=path_id) def test_convert_erc7730_registry_files(input_file: Path) -> None: - input_descriptor = ERC7730InputDescriptor.load(input_file) + input_descriptor = InputERC7730Descriptor.load(input_file) output_descriptor = convert_and_print_errors(input_descriptor, ERC7730toEIP712Converter()) assert output_descriptor is not None assert_valid_legacy_eip_712(output_descriptor) diff --git a/tests/model/test_model_serialization.py b/tests/model/test_model_serialization.py index 687b894..bd8696c 100644 --- a/tests/model/test_model_serialization.py +++ b/tests/model/test_model_serialization.py @@ -2,13 +2,13 @@ from pathlib import Path import pytest -from pydantic import ValidationError +from pydantic import RootModel, ValidationError from erc7730.common.json import read_json_with_includes from erc7730.common.pydantic import model_from_json_str, model_to_json_str -from erc7730.model.context import AbiJsonSchemaItem -from erc7730.model.descriptor import ERC7730InputDescriptor -from erc7730.model.display import Display +from erc7730.model.abi import ABI +from erc7730.model.input.descriptor import InputERC7730Descriptor +from erc7730.model.input.display import InputDisplay from tests.assertions import assert_dict_equals from tests.cases import path_id from tests.files import ERC7730_DESCRIPTORS, TEST_RESOURCES @@ -18,13 +18,13 @@ @pytest.mark.parametrize("input_file", ERC7730_DESCRIPTORS, ids=path_id) def test_schema(input_file: Path) -> None: """Test model serializes to JSON that matches the schema.""" - assert_valid_erc_7730(ERC7730InputDescriptor.load(input_file)) + assert_valid_erc_7730(InputERC7730Descriptor.load(input_file)) @pytest.mark.parametrize("input_file", ERC7730_DESCRIPTORS, ids=path_id) def test_round_trip(input_file: Path) -> None: """Test model serializes back to same JSON.""" - actual = json.loads(ERC7730InputDescriptor.load(input_file).to_json_string()) + actual = json.loads(InputERC7730Descriptor.load(input_file).to_json_string()) expected = read_json_with_includes(input_file) assert_dict_equals(expected, actual) @@ -43,7 +43,7 @@ def test_unset_attributes_must_not_be_serialized_as_set() -> None: "]," '"type":"function"}' ) - output_json_str = model_to_json_str(model_from_json_str(input_json_str, AbiJsonSchemaItem)) + output_json_str = model_to_json_str(model_from_json_str(input_json_str, RootModel[ABI]).root) assert_dict_equals(json.loads(input_json_str), json.loads(output_json_str)) @@ -60,7 +60,7 @@ def test_22_screens_serialization_not_symmetric() -> None: "}" "}" ) - output_json_str = model_to_json_str(model_from_json_str(input_json_str, Display)) + output_json_str = model_to_json_str(model_from_json_str(input_json_str, InputDisplay)) assert_dict_equals(json.loads(input_json_str), json.loads(output_json_str)) @@ -68,4 +68,4 @@ def test_22_screens_serialization_not_symmetric() -> None: @pytest.mark.raises(exception=ValidationError) def test_invalid_paths() -> None: """Test deserialization does not allow invalid paths.""" - ERC7730InputDescriptor.load(TEST_RESOURCES / "eip712_wrong_path.json") + InputERC7730Descriptor.load(TEST_RESOURCES / "eip712_wrong_path.json") From 4378b2e180417ac78ba8aefb4d53cf83a0e962f7 Mon Sep 17 00:00:00 2001 From: Julien Nicoulaud Date: Tue, 1 Oct 2024 19:11:25 +0200 Subject: [PATCH 4/8] lint/fixes --- src/erc7730/convert/__init__.py | 2 ++ .../convert_erc7730_input_to_resolved.py | 34 ++++++++++++------- .../convert/convert_erc7730_to_eip712.py | 7 ++-- src/erc7730/lint/common/paths.py | 5 ++- src/erc7730/lint/lint.py | 26 +++++++++----- src/erc7730/model/input/display.py | 2 +- src/erc7730/model/resolved/display.py | 20 ++++------- 7 files changed, 54 insertions(+), 42 deletions(-) diff --git a/src/erc7730/convert/__init__.py b/src/erc7730/convert/__init__.py index 3cf766c..0bb4092 100644 --- a/src/erc7730/convert/__init__.py +++ b/src/erc7730/convert/__init__.py @@ -48,8 +48,10 @@ class Level(IntEnum): class ErrorAdder(ABC): """ERC7730Converter output sink.""" + @abstractmethod def warning(self, message: str) -> None: raise NotImplementedError() + @abstractmethod def error(self, message: str) -> None: raise NotImplementedError() diff --git a/src/erc7730/convert/convert_erc7730_input_to_resolved.py b/src/erc7730/convert/convert_erc7730_input_to_resolved.py index 841605f..e10a81d 100644 --- a/src/erc7730/convert/convert_erc7730_input_to_resolved.py +++ b/src/erc7730/convert/convert_erc7730_input_to_resolved.py @@ -152,7 +152,7 @@ def _convert_schema( cls, schema: EIP712JsonSchema | AnyUrl, error: ERC7730Converter.ErrorAdder ) -> EIP712JsonSchema | None: if isinstance(schema, AnyUrl): - resp = requests.get(cls._adapt_uri(abis), timeout=10) # type:ignore + resp = requests.get(cls._adapt_uri(schema), timeout=10) # type:ignore resp.raise_for_status() return EIP712JsonSchema.model_validate(resp.json()) @@ -163,16 +163,18 @@ def _convert_schema( @classmethod def _convert_display(cls, display: InputDisplay, error: ERC7730Converter.ErrorAdder) -> ResolvedDisplay | None: - definitions = ( - {key: cls._convert_field_description(value, error) for key, value in display.definitions.items()} - if display.definitions is not None - else None - ) - - formats = {key: cls._convert_format(value, error) for key, value in display.formats.items()} - - if formats is None: - return None + if display.definitions is None: + definitions = None + else: + definitions = {} + for definition_key, definition in display.definitions.items(): + if (resolved_definition := cls._convert_field_description(definition, error)) is not None: + definitions[definition_key] = resolved_definition + + formats = {} + for format_key, format in display.formats.items(): + if (resolved_format := cls._convert_format(format, error)) is not None: + formats[format_key] = resolved_format return ResolvedDisplay(definitions=definitions, formats=formats) @@ -207,19 +209,22 @@ def _convert_field_parameters( if isinstance(params, UnitParameters): return params if isinstance(params, InputEnumParameters): - return params + return cls._convert_enum_parameters(params, error) return error.error(f"Invalid field parameters type: {type(params)}") @classmethod def _convert_enum_parameters( cls, params: InputEnumParameters, error: ERC7730Converter.ErrorAdder ) -> ResolvedEnumParameters | None: - return ResolvedEnumParameters(ref=params.ref) # TODO must inline here + return ResolvedEnumParameters.model_validate({"$ref": params.ref}) # TODO must inline here @classmethod def _convert_format(cls, format: InputFormat, error: ERC7730Converter.ErrorAdder) -> ResolvedFormat | None: fields = cls._convert_fields(format.fields, error) + if fields is None: + return None + return ResolvedFormat( # FIXME id=format.id, intent=format.intent, @@ -254,6 +259,9 @@ def _convert_nested_fields( ) -> ResolvedNestedFields | None: resolved_fields = cls._convert_fields(fields.fields, error) + if resolved_fields is None: + return None + return ResolvedNestedFields(path=fields.path, fields=resolved_fields) @classmethod diff --git a/src/erc7730/convert/convert_erc7730_to_eip712.py b/src/erc7730/convert/convert_erc7730_to_eip712.py index deb78e6..17b0ae0 100644 --- a/src/erc7730/convert/convert_erc7730_to_eip712.py +++ b/src/erc7730/convert/convert_erc7730_to_eip712.py @@ -104,12 +104,11 @@ def convert( @classmethod def parse_field(cls, display: ResolvedDisplay, field: ResolvedField) -> list[EIP712Field]: output = list[EIP712Field]() - field_root = field.root - if isinstance(field_root, ResolvedNestedFields): - for f in field_root.fields: + if isinstance(field, ResolvedNestedFields): + for f in field.fields: output.extend(cls.parse_field(display, field=f)) else: - output.append(cls.convert_field(field_root)) + output.append(cls.convert_field(field)) return output @classmethod diff --git a/src/erc7730/lint/common/paths.py b/src/erc7730/lint/common/paths.py index bcd3af6..6795e2c 100644 --- a/src/erc7730/lint/common/paths.py +++ b/src/erc7730/lint/common/paths.py @@ -65,9 +65,8 @@ def add_path(root: str, path: str) -> None: else: paths.data_paths.add(_append_path(root, path)) - def append_paths(path: str, fields: ResolvedField | None) -> None: - if fields is not None: - field = fields.root + def append_paths(path: str, field: ResolvedField | None) -> None: + if field is not None: match field: case ResolvedFieldDescription(): add_path(path, field.label) diff --git a/src/erc7730/lint/lint.py b/src/erc7730/lint/lint.py index fd55579..893c5ad 100644 --- a/src/erc7730/lint/lint.py +++ b/src/erc7730/lint/lint.py @@ -105,13 +105,23 @@ def adder(output: ERC7730Linter.Output) -> None: def _output_adapter(out: ERC7730Linter.OutputAdder) -> ERC7730Converter.ErrorAdder: - def adder(error: ERC7730Converter.Error) -> None: - out( - ERC7730Linter.Output( - title="Resolution error", - message=error.message, - level=ERC7730Converter.Error.Level.WARNING, + class ErrorAdder(ERC7730Converter.ErrorAdder): + def warning(self, message: str) -> None: + out( + ERC7730Linter.Output( + title="Resolution error", + message=message, + level=ERC7730Linter.Output.Level.WARNING, + ) + ) + + def error(self, message: str) -> None: + out( + ERC7730Linter.Output( + title="Resolution error", + message=message, + level=ERC7730Linter.Output.Level.ERROR, + ) ) - ) - return adder + return ErrorAdder() diff --git a/src/erc7730/model/input/display.py b/src/erc7730/model/input/display.py index e0a698a..a10ff0e 100644 --- a/src/erc7730/model/input/display.py +++ b/src/erc7730/model/input/display.py @@ -87,7 +87,7 @@ class InputFieldDescription(Model): class InputNestedFields(InputFieldsBase): - fields: list[ForwardRef("InputField")] + fields: list[ForwardRef("InputField")] # type: ignore def get_field_discriminator(v: Any) -> str | None: diff --git a/src/erc7730/model/resolved/display.py b/src/erc7730/model/resolved/display.py index 162e25a..452b3b0 100644 --- a/src/erc7730/model/resolved/display.py +++ b/src/erc7730/model/resolved/display.py @@ -1,6 +1,6 @@ from typing import Annotated, Any, ForwardRef -from pydantic import Discriminator, RootModel, Tag +from pydantic import Discriminator, Tag from pydantic import Field as PydanticField from erc7730.model.base import Model @@ -82,7 +82,7 @@ class ResolvedFieldDescription(Model): class ResolvedNestedFields(ResolvedFieldsBase): - fields: list[ForwardRef("ResolvedField")] + fields: list[ForwardRef("ResolvedField")] # type: ignore def get_field_discriminator(v: Any) -> str | None: @@ -99,17 +99,11 @@ def get_field_discriminator(v: Any) -> str | None: return None -class ResolvedField( - RootModel[ - Annotated[ - Annotated[ResolvedFieldDescription, Tag("field_description")] - | Annotated[ResolvedNestedFields, Tag("nested_fields")], - Discriminator(get_field_discriminator), - ] - ] -): - """Field""" - +ResolvedField = Annotated[ + Annotated[ResolvedFieldDescription, Tag("field_description")] + | Annotated[ResolvedNestedFields, Tag("nested_fields")], + Discriminator(get_field_discriminator), +] ResolvedNestedFields.model_rebuild() From 579b2901dfc633661700bf474178e57957564604 Mon Sep 17 00:00:00 2001 From: Julien Nicoulaud Date: Tue, 1 Oct 2024 19:22:20 +0200 Subject: [PATCH 5/8] lint/fixes --- .../convert/convert_erc7730_to_eip712.py | 26 +++----------- src/erc7730/lint/classifier/__init__.py | 2 +- src/erc7730/lint/classifier/abi_classifier.py | 4 +-- .../lint/lint_transaction_type_classifier.py | 13 +++---- src/erc7730/lint/lint_validate_abi.py | 10 +++--- .../lint/lint_validate_display_fields.py | 11 ++---- src/erc7730/model/context.py | 35 ++----------------- src/erc7730/model/utils.py | 6 ++-- 8 files changed, 24 insertions(+), 83 deletions(-) diff --git a/src/erc7730/convert/convert_erc7730_to_eip712.py b/src/erc7730/convert/convert_erc7730_to_eip712.py index 17b0ae0..f5cd2a4 100644 --- a/src/erc7730/convert/convert_erc7730_to_eip712.py +++ b/src/erc7730/convert/convert_erc7730_to_eip712.py @@ -1,6 +1,5 @@ from typing import assert_never, final, override -import requests from eip712 import ( EIP712ContractDescriptor, EIP712DAppDescriptor, @@ -9,18 +8,16 @@ EIP712Mapper, EIP712MessageDescriptor, ) -from pydantic import AnyUrl from erc7730.common.ledger import ledger_network_id -from erc7730.common.pydantic import model_from_json_bytes from erc7730.convert import ERC7730Converter -from erc7730.model.context import EIP712Context, EIP712JsonSchema, NameType from erc7730.model.display import ( CallDataParameters, FieldFormat, NftNameParameters, TokenAmountParameters, ) +from erc7730.model.resolved.context import ResolvedEIP712Context from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor from erc7730.model.resolved.display import ( ResolvedDisplay, @@ -41,25 +38,10 @@ def convert( # FIXME to debug and split in smaller methods context = descriptor.context - if not isinstance(context, EIP712Context): + if not isinstance(context, ResolvedEIP712Context): return error.error("context is None or is not EIP712") - eip712_schema = dict[str, list[NameType]]() - for schema_or_url in context.eip712.schemas: - erc7730_schema: EIP712JsonSchema | None = None - if isinstance(schema_or_url, AnyUrl): - try: - response = requests.get(str(schema_or_url), timeout=10) - erc7730_schema = model_from_json_bytes(response.content, model=EIP712JsonSchema) - except Exception as e: - return error.error(str(e)) - else: - erc7730_schema = schema_or_url - if erc7730_schema is not None: - try: - eip712_schema = erc7730_schema.types - except Exception as e: - return error.error(str(e)) + schemas = context.eip712.schemas messages = list[EIP712MessageDescriptor]() if context.eip712.domain is None: @@ -87,7 +69,7 @@ def convert( for field in format.fields: eip712_fields.extend(self.parse_field(descriptor.display, field)) mapper = EIP712Mapper(label=format_label, fields=eip712_fields) - messages.append(EIP712MessageDescriptor(schema=eip712_schema, mapper=mapper)) + messages.append(EIP712MessageDescriptor(schema=schemas[0].types, mapper=mapper)) contracts = list[EIP712ContractDescriptor]() contract_name = name if descriptor.metadata.owner is not None: diff --git a/src/erc7730/lint/classifier/__init__.py b/src/erc7730/lint/classifier/__init__.py index f857341..cc2fc26 100644 --- a/src/erc7730/lint/classifier/__init__.py +++ b/src/erc7730/lint/classifier/__init__.py @@ -13,7 +13,7 @@ class TxClass(StrEnum): WITHDRAW = auto() -Schema = TypeVar("Schema", ABI, EIP712JsonSchema) +Schema = TypeVar("Schema", list[ABI], EIP712JsonSchema) class Classifier(ABC, Generic[Schema]): diff --git a/src/erc7730/lint/classifier/abi_classifier.py b/src/erc7730/lint/classifier/abi_classifier.py index 6872d50..e1df5b2 100644 --- a/src/erc7730/lint/classifier/abi_classifier.py +++ b/src/erc7730/lint/classifier/abi_classifier.py @@ -5,11 +5,11 @@ @final -class ABIClassifier(Classifier[ABI]): +class ABIClassifier(Classifier[list[ABI]]): """Given an ABI, classify the transaction type with some predefined ruleset. (not implemented) """ @override - def classify(self, schema: ABI) -> TxClass | None: + def classify(self, schema: list[ABI]) -> TxClass | None: pass diff --git a/src/erc7730/lint/lint_transaction_type_classifier.py b/src/erc7730/lint/lint_transaction_type_classifier.py index 8686e3e..2c88662 100644 --- a/src/erc7730/lint/lint_transaction_type_classifier.py +++ b/src/erc7730/lint/lint_transaction_type_classifier.py @@ -1,12 +1,10 @@ from typing import final, override -from pydantic import AnyUrl - from erc7730.lint import ERC7730Linter from erc7730.lint.classifier import TxClass from erc7730.lint.classifier.abi_classifier import ABIClassifier from erc7730.lint.classifier.eip712_classifier import EIP712Classifier -from erc7730.model.context import ContractContext, EIP712Context, EIP712JsonSchema +from erc7730.model.resolved.context import EIP712JsonSchema, ResolvedContractContext, ResolvedEIP712Context from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor from erc7730.model.resolved.display import ResolvedDisplay, ResolvedFormat @@ -39,20 +37,17 @@ def lint(self, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputA @classmethod def _determine_tx_class(cls, descriptor: ResolvedERC7730Descriptor) -> TxClass | None: - if isinstance(descriptor.context, EIP712Context): + if isinstance(descriptor.context, ResolvedEIP712Context): classifier = EIP712Classifier() if descriptor.context.eip712.schemas is not None: first_schema = descriptor.context.eip712.schemas[0] if isinstance(first_schema, EIP712JsonSchema): return classifier.classify(first_schema) # url should have been resolved earlier - elif isinstance(descriptor.context, ContractContext): + elif isinstance(descriptor.context, ResolvedContractContext): abi_classifier = ABIClassifier() if descriptor.context.contract.abi is not None: - abi_schema = descriptor.context.contract.abi - if not isinstance(abi_schema, AnyUrl): - return abi_classifier.classify(abi_schema) - # url should have been resolved earlier + return abi_classifier.classify(descriptor.context.contract.abi) return None diff --git a/src/erc7730/lint/lint_validate_abi.py b/src/erc7730/lint/lint_validate_abi.py index ca4d5d4..2aa8a5b 100644 --- a/src/erc7730/lint/lint_validate_abi.py +++ b/src/erc7730/lint/lint_validate_abi.py @@ -3,7 +3,7 @@ from erc7730.common.abi import compute_signature, get_functions from erc7730.common.client.etherscan import get_contract_abis from erc7730.lint import ERC7730Linter -from erc7730.model.context import ContractContext, EIP712Context +from erc7730.model.resolved.context import ResolvedContractContext, ResolvedEIP712Context from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor @@ -17,18 +17,18 @@ class ValidateABILinter(ERC7730Linter): @override def lint(self, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: - if isinstance(descriptor.context, EIP712Context): + if isinstance(descriptor.context, ResolvedEIP712Context): return self._validate_eip712_schemas(descriptor.context, out) - if isinstance(descriptor.context, ContractContext): + if isinstance(descriptor.context, ResolvedContractContext): return self._validate_contract_abis(descriptor.context, out) raise ValueError("Invalid context type") @classmethod - def _validate_eip712_schemas(cls, context: EIP712Context, out: ERC7730Linter.OutputAdder) -> None: + def _validate_eip712_schemas(cls, context: ResolvedEIP712Context, out: ERC7730Linter.OutputAdder) -> None: pass # not implemented @classmethod - def _validate_contract_abis(cls, context: ContractContext, out: ERC7730Linter.OutputAdder) -> None: + def _validate_contract_abis(cls, context: ResolvedContractContext, out: ERC7730Linter.OutputAdder) -> None: if not isinstance(context.contract.abi, list): raise ValueError("Contract ABIs should have been resolved") diff --git a/src/erc7730/lint/lint_validate_display_fields.py b/src/erc7730/lint/lint_validate_display_fields.py index a312c3f..6ad395a 100644 --- a/src/erc7730/lint/lint_validate_display_fields.py +++ b/src/erc7730/lint/lint_validate_display_fields.py @@ -3,7 +3,7 @@ from erc7730.common.abi import compute_paths, compute_selector from erc7730.lint import ERC7730Linter from erc7730.lint.common.paths import compute_eip712_paths, compute_format_paths -from erc7730.model.context import ContractContext, EIP712Context, EIP712JsonSchema +from erc7730.model.resolved.context import EIP712JsonSchema, ResolvedContractContext, ResolvedEIP712Context from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor @@ -21,7 +21,7 @@ def lint(self, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputA @classmethod def _validate_eip712_paths(cls, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: - if isinstance(descriptor.context, EIP712Context) and descriptor.context.eip712.schemas is not None: + if isinstance(descriptor.context, ResolvedEIP712Context) and descriptor.context.eip712.schemas is not None: primary_types: set[str] = set() for schema in descriptor.context.eip712.schemas: if isinstance(schema, EIP712JsonSchema): @@ -85,12 +85,7 @@ def _validate_eip712_paths(cls, descriptor: ResolvedERC7730Descriptor, out: ERC7 @classmethod def _validate_abi_paths(cls, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: - if ( - descriptor.context is not None - and descriptor.display is not None - and isinstance(descriptor.context, ContractContext) - and isinstance(descriptor.context.contract.abi, list) - ): + if isinstance(descriptor.context, ResolvedContractContext): abi_paths_by_selector: dict[str, set[str]] = {} for abi in descriptor.context.contract.abi: if abi.type == "function": diff --git a/src/erc7730/model/context.py b/src/erc7730/model/context.py index b001519..700e210 100644 --- a/src/erc7730/model/context.py +++ b/src/erc7730/model/context.py @@ -1,8 +1,7 @@ -from pydantic import AnyUrl, Field, RootModel, field_validator +from pydantic import AnyUrl, RootModel, field_validator -from erc7730.model.abi import ABI from erc7730.model.base import Model -from erc7730.model.types import ContractAddress, Id +from erc7730.model.types import ContractAddress # ruff: noqa: N815 - camel case field names are tolerated to match schema @@ -43,36 +42,6 @@ class Deployments(RootModel[list[Deployment]]): """deployments""" -class EIP712(Model): - domain: Domain | None = None - schemas: list[EIP712JsonSchema | AnyUrl] - domainSeparator: str | None = None - deployments: Deployments - - -class EIP712DomainBinding(Model): - eip712: EIP712 - - class Factory(Model): deployments: Deployments deployEvent: str - - -class Contract(Model): - abi: AnyUrl | list[ABI] - deployments: Deployments - addressMatcher: AnyUrl | None = None - factory: Factory | None = None - - -class ContractBinding(Model): - contract: Contract - - -class ContractContext(ContractBinding): - id: Id | None = Field(None, alias="$id") - - -class EIP712Context(EIP712DomainBinding): - id: Id | None = Field(None, alias="$id") diff --git a/src/erc7730/model/utils.py b/src/erc7730/model/utils.py index a409edd..bc0acc1 100644 --- a/src/erc7730/model/utils.py +++ b/src/erc7730/model/utils.py @@ -2,7 +2,7 @@ Utilities for manipulating ERC-7730 descriptors. """ -from erc7730.model.context import ContractContext, Deployments, EIP712Context +from erc7730.model.input.context import Deployments, InputContractContext, InputEIP712Context from erc7730.model.input.descriptor import InputERC7730Descriptor @@ -15,8 +15,8 @@ def get_chain_ids(descriptor: InputERC7730Descriptor) -> set[int] | None: def get_deployments(descriptor: InputERC7730Descriptor) -> Deployments | None: """Get deployments section for a descriptor.""" - if isinstance(context := descriptor.context, EIP712Context): + if isinstance(context := descriptor.context, InputEIP712Context): return context.eip712.deployments - if isinstance(context := descriptor.context, ContractContext): + if isinstance(context := descriptor.context, InputContractContext): return context.contract.deployments raise ValueError(f"Invalid context type {type(descriptor.context)}") From 272f04911fbf08e72ac286554b2abe596d7659c3 Mon Sep 17 00:00:00 2001 From: Julien Nicoulaud Date: Tue, 1 Oct 2024 19:30:27 +0200 Subject: [PATCH 6/8] lint/fixes --- tests/convert/test_convert_erc7730_to_eip712.py | 9 ++++++--- tests/model/test_model_serialization.py | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/convert/test_convert_erc7730_to_eip712.py b/tests/convert/test_convert_erc7730_to_eip712.py index a679a6c..beb585a 100644 --- a/tests/convert/test_convert_erc7730_to_eip712.py +++ b/tests/convert/test_convert_erc7730_to_eip712.py @@ -3,8 +3,9 @@ import pytest from erc7730.convert.convert import convert_and_print_errors +from erc7730.convert.convert_erc7730_input_to_resolved import ERC7730InputToResolved from erc7730.convert.convert_erc7730_to_eip712 import ERC7730toEIP712Converter -from erc7730.model.descriptor import InputERC7730Descriptor +from erc7730.model.input.descriptor import InputERC7730Descriptor from tests.cases import path_id from tests.files import ERC7730_EIP712_DESCRIPTORS from tests.schemas import assert_valid_legacy_eip_712 @@ -12,7 +13,9 @@ @pytest.mark.parametrize("input_file", ERC7730_EIP712_DESCRIPTORS, ids=path_id) def test_convert_erc7730_registry_files(input_file: Path) -> None: - input_descriptor = InputERC7730Descriptor.load(input_file) - output_descriptor = convert_and_print_errors(input_descriptor, ERC7730toEIP712Converter()) + input_erc7730_descriptor = InputERC7730Descriptor.load(input_file) + resolved_erc7730_descriptor = convert_and_print_errors(input_erc7730_descriptor, ERC7730InputToResolved()) + assert resolved_erc7730_descriptor is not None + output_descriptor = convert_and_print_errors(resolved_erc7730_descriptor, ERC7730toEIP712Converter()) assert output_descriptor is not None assert_valid_legacy_eip_712(output_descriptor) diff --git a/tests/model/test_model_serialization.py b/tests/model/test_model_serialization.py index bd8696c..64a409e 100644 --- a/tests/model/test_model_serialization.py +++ b/tests/model/test_model_serialization.py @@ -53,6 +53,7 @@ def test_22_screens_serialization_not_symmetric() -> None: "{" '"formats":{' '"Permit":{' + '"fields": [],' '"screens":{' '"stax":[{"type":"propertyPage","label":"DAI Permit","content":["spender","value","deadline"]}]' "}" From 3662e4c6a78a7019d6586b4c2cb38468da4a87e1 Mon Sep 17 00:00:00 2001 From: Julien Nicoulaud Date: Tue, 1 Oct 2024 19:33:27 +0200 Subject: [PATCH 7/8] lint/fixes --- .../convert_erc7730_input_to_resolved.py | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/erc7730/convert/convert_erc7730_input_to_resolved.py b/src/erc7730/convert/convert_erc7730_input_to_resolved.py index e10a81d..0f6f631 100644 --- a/src/erc7730/convert/convert_erc7730_input_to_resolved.py +++ b/src/erc7730/convert/convert_erc7730_input_to_resolved.py @@ -59,11 +59,8 @@ def convert( if context is None or display is None: return None - return ResolvedERC7730Descriptor( - # FIXME schema_=descriptor.schema_, - context=context, - metadata=descriptor.metadata, - display=display, + return ResolvedERC7730Descriptor.model_validate( + {"$schema": descriptor.schema_, "context": context, "metadata": descriptor.metadata, "display": display} ) @classmethod @@ -184,12 +181,14 @@ def _convert_field_description( ) -> ResolvedFieldDescription | None: params = cls._convert_field_parameters(definition.params, error) if definition.params is not None else None - return ResolvedFieldDescription( - # FIXME id=definition.id, - path=definition.path, - label=definition.label, - format=FieldFormat(definition.format) if definition.format is not None else None, - params=params, + return ResolvedFieldDescription.model_validate( + { + "$id": definition.id, + "path": definition.path, + "label": definition.label, + "format": FieldFormat(definition.format) if definition.format is not None else None, + "params": params, + } ) @classmethod @@ -225,12 +224,14 @@ def _convert_format(cls, format: InputFormat, error: ERC7730Converter.ErrorAdder if fields is None: return None - return ResolvedFormat( - # FIXME id=format.id, - intent=format.intent, - fields=fields, - required=format.required, - screens=format.screens, + return ResolvedFormat.model_validate( + { + "$id": format.id, + "intent": format.intent, + "fields": fields, + "required": format.required, + "screens": format.screens, + } ) @classmethod From c704ad24443f09446d12232759fe35594bdc648c Mon Sep 17 00:00:00 2001 From: Julien Nicoulaud Date: Wed, 2 Oct 2024 09:48:54 +0200 Subject: [PATCH 8/8] lint/fixes --- src/erc7730/common/properties.py | 14 +++ .../convert/convert_eip712_to_erc7730.py | 21 +++- .../convert_erc7730_input_to_resolved.py | 35 ++++-- .../convert/convert_erc7730_to_eip712.py | 119 ++++++++++-------- src/erc7730/lint/__init__.py | 1 + src/erc7730/lint/lint.py | 2 + src/erc7730/lint/lint_validate_abi.py | 2 +- src/erc7730/model/base.py | 39 ++++++ src/erc7730/model/context.py | 10 +- src/erc7730/model/display.py | 13 ++ src/erc7730/model/input/context.py | 30 ++--- src/erc7730/model/input/descriptor.py | 68 ++++------ src/erc7730/model/input/display.py | 98 +++++---------- src/erc7730/model/resolved/context.py | 28 ++--- src/erc7730/model/resolved/descriptor.py | 68 ++++------ src/erc7730/model/resolved/display.py | 69 ++++------ src/erc7730/model/types.py | 8 +- src/erc7730/model/utils.py | 7 +- 18 files changed, 318 insertions(+), 314 deletions(-) create mode 100644 src/erc7730/common/properties.py diff --git a/src/erc7730/common/properties.py b/src/erc7730/common/properties.py new file mode 100644 index 0000000..7b7eae1 --- /dev/null +++ b/src/erc7730/common/properties.py @@ -0,0 +1,14 @@ +from typing import Any + + +def has_property(target: Any, name: str) -> bool: + """ + Check if the target has a property with the given name. + + :param target: object of dict like + :param name: attribute name + :return: true if the target has the property + """ + if isinstance(target, dict): + return name in target + return hasattr(target, name) diff --git a/src/erc7730/convert/convert_eip712_to_erc7730.py b/src/erc7730/convert/convert_eip712_to_erc7730.py index 8c7e04e..be1d5c9 100644 --- a/src/erc7730/convert/convert_eip712_to_erc7730.py +++ b/src/erc7730/convert/convert_eip712_to_erc7730.py @@ -9,14 +9,20 @@ from pydantic import AnyUrl from erc7730.convert import ERC7730Converter -from erc7730.model.context import Deployment, Deployments, Domain, EIP712JsonSchema, NameType +from erc7730.model.context import Deployment, Domain, EIP712JsonSchema, NameType from erc7730.model.display import ( FieldFormat, TokenAmountParameters, ) from erc7730.model.input.context import InputEIP712, InputEIP712Context from erc7730.model.input.descriptor import InputERC7730Descriptor -from erc7730.model.input.display import InputDisplay, InputField, InputFieldDescription, InputFormat +from erc7730.model.input.display import ( + InputDisplay, + InputFieldDescription, + InputFormat, + InputNestedFields, + InputReference, +) from erc7730.model.metadata import Metadata from erc7730.model.types import ContractAddress @@ -29,7 +35,10 @@ class EIP712toERC7730Converter(ERC7730Converter[EIP712DAppDescriptor, InputERC77 def convert( self, descriptor: EIP712DAppDescriptor, error: ERC7730Converter.ErrorAdder ) -> InputERC7730Descriptor | None: - # FIXME this code flattens all messages in first contract + # FIXME this code flattens all messages in first contract. + # converter must be changed to output a list[InputERC7730Descriptor] + # 1 output InputERC7730Descriptor per input contract + verifying_contract: ContractAddress | None = None contract_name = descriptor.name if len(descriptor.contracts) > 0: @@ -52,7 +61,7 @@ def convert( types=schema, ) ) - fields = [InputField(self._convert_field(field)) for field in mapper.fields] + fields = [self._convert_field(field) for field in mapper.fields] formats[mapper.label] = InputFormat( intent=None, # FIXME fields=fields, @@ -70,7 +79,7 @@ def convert( verifyingContract=verifying_contract, ), schemas=schemas, - deployments=Deployments([Deployment(chainId=descriptor.chain_id, address=verifying_contract)]), + deployments=[Deployment(chainId=descriptor.chain_id, address=verifying_contract)], ) ), metadata=Metadata( @@ -87,7 +96,7 @@ def convert( ) @classmethod - def _convert_field(cls, field: EIP712Field) -> InputFieldDescription: + def _convert_field(cls, field: EIP712Field) -> InputFieldDescription | InputReference | InputNestedFields: match field.format: case EIP712Format.AMOUNT if field.assetPath is not None: return InputFieldDescription( diff --git a/src/erc7730/convert/convert_erc7730_input_to_resolved.py b/src/erc7730/convert/convert_erc7730_input_to_resolved.py index 0f6f631..f8efa0f 100644 --- a/src/erc7730/convert/convert_erc7730_input_to_resolved.py +++ b/src/erc7730/convert/convert_erc7730_input_to_resolved.py @@ -21,6 +21,7 @@ InputDisplay, InputEnumParameters, InputField, + InputFieldDefinition, InputFieldDescription, InputFieldParameters, InputFormat, @@ -38,6 +39,7 @@ ResolvedDisplay, ResolvedEnumParameters, ResolvedField, + ResolvedFieldDefinition, ResolvedFieldDescription, ResolvedFieldParameters, ResolvedFormat, @@ -165,7 +167,7 @@ def _convert_display(cls, display: InputDisplay, error: ERC7730Converter.ErrorAd else: definitions = {} for definition_key, definition in display.definitions.items(): - if (resolved_definition := cls._convert_field_description(definition, error)) is not None: + if (resolved_definition := cls._convert_field_definition(definition, error)) is not None: definitions[definition_key] = resolved_definition formats = {} @@ -175,6 +177,21 @@ def _convert_display(cls, display: InputDisplay, error: ERC7730Converter.ErrorAd return ResolvedDisplay(definitions=definitions, formats=formats) + @classmethod + def _convert_field_definition( + cls, definition: InputFieldDefinition, error: ERC7730Converter.ErrorAdder + ) -> ResolvedFieldDefinition | None: + params = cls._convert_field_parameters(definition.params, error) if definition.params is not None else None + + return ResolvedFieldDefinition.model_validate( + { + "$id": definition.id, + "label": definition.label, + "format": FieldFormat(definition.format) if definition.format is not None else None, + "params": params, + } + ) + @classmethod def _convert_field_description( cls, definition: InputFieldDescription, error: ERC7730Converter.ErrorAdder @@ -246,12 +263,12 @@ def _convert_fields( @classmethod def _convert_field(cls, field: InputField, error: ERC7730Converter.ErrorAdder) -> ResolvedField | None: - if isinstance(field.root, InputReference): - raise NotImplementedError() # TODO - if isinstance(field.root, InputFieldDescription): - return cls._convert_field_description(field.root, error) - if isinstance(field.root, InputNestedFields): - return cls._convert_nested_fields(field.root, error) + if isinstance(field, InputReference): + return cls._convert_reference(field, error) + if isinstance(field, InputFieldDescription): + return cls._convert_field_description(field, error) + if isinstance(field, InputNestedFields): + return cls._convert_nested_fields(field, error) return error.error(f"Invalid field type: {type(field)}") @classmethod @@ -265,6 +282,10 @@ def _convert_nested_fields( return ResolvedNestedFields(path=fields.path, fields=resolved_fields) + @classmethod + def _convert_reference(cls, reference: InputReference, error: ERC7730Converter.ErrorAdder) -> ResolvedField | None: + raise NotImplementedError() # TODO + @classmethod def _adapt_uri(cls, url: AnyUrl) -> AnyUrl: return AnyUrl( diff --git a/src/erc7730/convert/convert_erc7730_to_eip712.py b/src/erc7730/convert/convert_erc7730_to_eip712.py index f5cd2a4..be15484 100644 --- a/src/erc7730/convert/convert_erc7730_to_eip712.py +++ b/src/erc7730/convert/convert_erc7730_to_eip712.py @@ -12,15 +12,12 @@ from erc7730.common.ledger import ledger_network_id from erc7730.convert import ERC7730Converter from erc7730.model.display import ( - CallDataParameters, FieldFormat, - NftNameParameters, TokenAmountParameters, ) from erc7730.model.resolved.context import ResolvedEIP712Context from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor from erc7730.model.resolved.display import ( - ResolvedDisplay, ResolvedField, ResolvedFieldDescription, ResolvedNestedFields, @@ -35,96 +32,108 @@ class ERC7730toEIP712Converter(ERC7730Converter[ResolvedERC7730Descriptor, EIP71 def convert( self, descriptor: ResolvedERC7730Descriptor, error: ERC7730Converter.ErrorAdder ) -> EIP712DAppDescriptor | None: + # note: model_construct() needs to be used here due to bad conception of EIP-712 library, + # which adds computed fields on validation + # FIXME to debug and split in smaller methods + # FIXME this converter must be changed to output a list[EIP712DAppDescriptor] + # 1 output EIP712DAppDescriptor per chain id + context = descriptor.context if not isinstance(context, ResolvedEIP712Context): - return error.error("context is None or is not EIP712") + return error.error("context is not EIP712") schemas = context.eip712.schemas - messages = list[EIP712MessageDescriptor]() - if context.eip712.domain is None: + if (domain := context.eip712.domain) is None: return error.error("domain is undefined") - chain_id = context.eip712.domain.chainId - if chain_id is None and context.eip712.deployments is not None: - for deployment in context.eip712.deployments.root: - chain_id = deployment.chainId - contract_address = context.eip712.domain.verifyingContract - if contract_address is None and context.eip712.deployments is not None: - for deployment in context.eip712.deployments.root: - contract_address = deployment.address + chain_id = domain.chainId + contract_address = domain.verifyingContract + + name = "" + if domain.name is not None: + name = domain.name + + for deployment in context.eip712.deployments: + if chain_id is not None and contract_address is not None: + break + chain_id = deployment.chainId + contract_address = deployment.address + if chain_id is None: return error.error("chain id is undefined") - name = "" - if context.eip712.domain.name is not None: - name = context.eip712.domain.name if contract_address is None: return error.error("verifying contract is undefined") - for format_label, format in descriptor.display.formats.items(): - eip712_fields = list[EIP712Field]() - if format.fields is not None: - for field in format.fields: - eip712_fields.extend(self.parse_field(descriptor.display, field)) - mapper = EIP712Mapper(label=format_label, fields=eip712_fields) - messages.append(EIP712MessageDescriptor(schema=schemas[0].types, mapper=mapper)) - contracts = list[EIP712ContractDescriptor]() + messages = [ + EIP712MessageDescriptor.model_construct( + schema=schemas[0].types, # FIXME + mapper=EIP712Mapper.model_construct( + label=format_label, + fields=[out_field for in_field in format.fields for out_field in self.convert_field(in_field)], + ), + ) + for format_label, format in descriptor.display.formats.items() + ] + contract_name = name if descriptor.metadata.owner is not None: contract_name = descriptor.metadata.owner - contracts.append( - EIP712ContractDescriptor(address=contract_address, contractName=contract_name, messages=messages) - ) + contracts = [ + EIP712ContractDescriptor.model_construct( + address=contract_address.lower(), contractName=contract_name, messages=messages + ) + ] if (network := ledger_network_id(chain_id)) is None: return error.error(f"network id {chain_id} not supported") - return EIP712DAppDescriptor(blockchainName=network, chainId=chain_id, name=name, contracts=contracts) + return EIP712DAppDescriptor.model_construct( + blockchainName=network, chainId=chain_id, name=name, contracts=contracts + ) @classmethod - def parse_field(cls, display: ResolvedDisplay, field: ResolvedField) -> list[EIP712Field]: - output = list[EIP712Field]() + def convert_field(cls, field: ResolvedField) -> list[EIP712Field]: if isinstance(field, ResolvedNestedFields): - for f in field.fields: - output.extend(cls.parse_field(display, field=f)) - else: - output.append(cls.convert_field(field)) - return output + return [out_field for in_field in field.fields for out_field in cls.convert_field(in_field)] + return [cls.convert_field_description(field)] @classmethod - def convert_field(cls, field: ResolvedFieldDescription) -> EIP712Field: - name = field.label - asset_path = None - field_format = None + def convert_field_description(cls, field: ResolvedFieldDescription) -> EIP712Field: + asset_path: str | None = None + field_format: EIP712Format | None = None match field.format: - case FieldFormat.NFT_NAME: - if field.params is not None and isinstance(field.params, NftNameParameters): - asset_path = field.params.collectionPath case FieldFormat.TOKEN_AMOUNT: if field.params is not None and isinstance(field.params, TokenAmountParameters): asset_path = field.params.tokenPath field_format = EIP712Format.AMOUNT - case FieldFormat.CALL_DATA: - if field.params is not None and isinstance(field.params, CallDataParameters): - asset_path = field.params.calleePath case FieldFormat.AMOUNT: field_format = EIP712Format.AMOUNT case FieldFormat.DATE: field_format = EIP712Format.DATETIME - case FieldFormat.RAW: - field_format = EIP712Format.RAW case FieldFormat.ADDRESS_NAME: - field_format = EIP712Format.RAW # TODO not implemented - case FieldFormat.DURATION: - field_format = EIP712Format.RAW # TODO not implemented + field_format = EIP712Format.RAW case FieldFormat.ENUM: - field_format = EIP712Format.RAW # TODO not implemented + field_format = EIP712Format.RAW case FieldFormat.UNIT: - field_format = EIP712Format.RAW # TODO not implemented + field_format = EIP712Format.RAW + case FieldFormat.DURATION: + field_format = EIP712Format.RAW + case FieldFormat.NFT_NAME: + field_format = EIP712Format.RAW + case FieldFormat.CALL_DATA: + field_format = EIP712Format.RAW + case FieldFormat.RAW: + field_format = EIP712Format.RAW case None: - field_format = EIP712Format.RAW # TODO not implemented + field_format = None case _: assert_never(field.format) - return EIP712Field(path=field.path, label=name, assetPath=asset_path, format=field_format, coinRef=None) + return EIP712Field( + path=field.path, + label=field.label, + assetPath=asset_path, + format=field_format, + ) diff --git a/src/erc7730/lint/__init__.py b/src/erc7730/lint/__init__.py index 1b33e25..5224271 100644 --- a/src/erc7730/lint/__init__.py +++ b/src/erc7730/lint/__init__.py @@ -35,5 +35,6 @@ class Level(IntEnum): message: str level: Level = Level.ERROR + # TODO: use same kind of interface as converter to make it easier OutputAdder = Callable[[Output], None] """ERC7730Linter output sink.""" diff --git a/src/erc7730/lint/lint.py b/src/erc7730/lint/lint.py index 893c5ad..d5b7afe 100644 --- a/src/erc7730/lint/lint.py +++ b/src/erc7730/lint/lint.py @@ -97,6 +97,8 @@ def adder(output: ERC7730Linter.Output) -> None: if resolved_descriptor is not None: linter.lint(resolved_descriptor, adder) except Exception as e: + # TODO unwrap pydantic validation errors here to provide more user-friendly error messages + out( ERC7730Linter.Output( file=path, title="Failed to parse descriptor", message=str(e), level=ERC7730Linter.Output.Level.ERROR diff --git a/src/erc7730/lint/lint_validate_abi.py b/src/erc7730/lint/lint_validate_abi.py index 2aa8a5b..f3e15cd 100644 --- a/src/erc7730/lint/lint_validate_abi.py +++ b/src/erc7730/lint/lint_validate_abi.py @@ -34,7 +34,7 @@ def _validate_contract_abis(cls, context: ResolvedContractContext, out: ERC7730L if (deployments := context.contract.deployments) is None: return - for deployment in deployments.root: + for deployment in deployments: if (abis := get_contract_abis(deployment.chainId, deployment.address)) is None: continue diff --git a/src/erc7730/model/base.py b/src/erc7730/model/base.py index c452286..118ce23 100644 --- a/src/erc7730/model/base.py +++ b/src/erc7730/model/base.py @@ -4,8 +4,17 @@ See https://docs.pydantic.dev """ +from pathlib import Path +from typing import Self + from pydantic import BaseModel, ConfigDict +from erc7730.common.pydantic import ( + model_from_json_file_with_includes, + model_from_json_file_with_includes_or_none, + model_to_json_str, +) + class Model(BaseModel): """ @@ -25,3 +34,33 @@ class Model(BaseModel): arbitrary_types_allowed=False, allow_inf_nan=False, ) + + @classmethod + def load(cls, path: Path) -> Self: + """ + Load a model from a JSON file. + + :param path: file path + :return: validated in-memory representation of model + :raises Exception: if the file does not exist or has validation errors + """ + return model_from_json_file_with_includes(path, cls) + + @classmethod + def load_or_none(cls, path: Path) -> Self | None: + """ + Load a model from a JSON file. + + :param path: file path + :return: validated in-memory representation of descriptor, or None if file does not exist + :raises Exception: if the file has validation errors + """ + return model_from_json_file_with_includes_or_none(path, cls) + + def to_json_string(self) -> str: + """ + Serialize the model to a JSON string. + + :return: JSON representation of model, serialized as a string + """ + return model_to_json_str(self) diff --git a/src/erc7730/model/context.py b/src/erc7730/model/context.py index 700e210..3c71b62 100644 --- a/src/erc7730/model/context.py +++ b/src/erc7730/model/context.py @@ -1,4 +1,4 @@ -from pydantic import AnyUrl, RootModel, field_validator +from pydantic import AnyUrl, field_validator from erc7730.model.base import Model from erc7730.model.types import ContractAddress @@ -35,13 +35,9 @@ class Domain(Model): class Deployment(Model): chainId: int - address: str - - -class Deployments(RootModel[list[Deployment]]): - """deployments""" + address: ContractAddress class Factory(Model): - deployments: Deployments + deployments: list[Deployment] deployEvent: str diff --git a/src/erc7730/model/display.py b/src/erc7730/model/display.py index 07807fc..971aeae 100644 --- a/src/erc7730/model/display.py +++ b/src/erc7730/model/display.py @@ -1,9 +1,11 @@ from enum import Enum from typing import Any +from pydantic import Field as PydanticField from pydantic import RootModel from erc7730.model.base import Model +from erc7730.model.types import Id # ruff: noqa: N815 - camel case field names are tolerated to match schema @@ -80,3 +82,14 @@ class UnitParameters(Model): class Screen(RootModel[dict[str, Any]]): """Screen""" + + +class FieldsBase(Model): + path: str + + +class FormatBase(Model): + id: Id | None = PydanticField(None, alias="$id") + intent: str | dict[str, str] | None = None + required: list[str] | None = None + screens: dict[str, list[Screen]] | None = None diff --git a/src/erc7730/model/input/context.py b/src/erc7730/model/input/context.py index ebe0ec1..8e3d041 100644 --- a/src/erc7730/model/input/context.py +++ b/src/erc7730/model/input/context.py @@ -2,37 +2,31 @@ from erc7730.model.abi import ABI from erc7730.model.base import Model -from erc7730.model.context import Deployments, Domain, EIP712JsonSchema, Factory +from erc7730.model.context import Deployment, Domain, EIP712JsonSchema, Factory from erc7730.model.types import Id # ruff: noqa: N815 - camel case field names are tolerated to match schema -class InputEIP712(Model): - domain: Domain | None = None - schemas: list[EIP712JsonSchema | AnyUrl] - domainSeparator: str | None = None - deployments: Deployments - - -class InputEIP712DomainBinding(Model): - eip712: InputEIP712 - - class InputContract(Model): - abi: AnyUrl | list[ABI] - deployments: Deployments + abi: list[ABI] | AnyUrl + deployments: list[Deployment] addressMatcher: AnyUrl | None = None factory: Factory | None = None -class InputContractBinding(Model): - contract: InputContract +class InputEIP712(Model): + domain: Domain | None = None + schemas: list[EIP712JsonSchema | AnyUrl] + domainSeparator: str | None = None + deployments: list[Deployment] -class InputContractContext(InputContractBinding): +class InputContractContext(Model): id: Id | None = Field(None, alias="$id") + contract: InputContract -class InputEIP712Context(InputEIP712DomainBinding): +class InputEIP712Context(Model): id: Id | None = Field(None, alias="$id") + eip712: InputEIP712 diff --git a/src/erc7730/model/input/descriptor.py b/src/erc7730/model/input/descriptor.py index 36bc143..91ab651 100644 --- a/src/erc7730/model/input/descriptor.py +++ b/src/erc7730/model/input/descriptor.py @@ -7,16 +7,8 @@ - Selectors have not been converted to 4 bytes form """ -from pathlib import Path -from typing import Optional - from pydantic import Field -from erc7730.common.pydantic import ( - model_from_json_file_with_includes, - model_from_json_file_with_includes_or_none, - model_to_json_str, -) from erc7730.model.base import Model from erc7730.model.input.context import InputContractContext, InputEIP712Context from erc7730.model.input.display import InputDisplay @@ -34,37 +26,29 @@ class InputERC7730Descriptor(Model): JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v1.schema.json """ - schema_: str | None = Field(None, alias="$schema") - context: InputContractContext | InputEIP712Context - metadata: Metadata - display: InputDisplay - - @classmethod - def load(cls, path: Path) -> "InputERC7730Descriptor": - """ - Load an ERC7730 descriptor from a JSON file. - - :param path: file path - :return: validated in-memory representation of descriptor - :raises Exception: if the file does not exist or has validation errors - """ - return model_from_json_file_with_includes(path, InputERC7730Descriptor) - - @classmethod - def load_or_none(cls, path: Path) -> Optional["InputERC7730Descriptor"]: - """ - Load an ERC7730 descriptor from a JSON file. - - :param path: file path - :return: validated in-memory representation of descriptor, or None if file does not exist - :raises Exception: if the file has validation errors - """ - return model_from_json_file_with_includes_or_none(path, InputERC7730Descriptor) - - def to_json_string(self) -> str: - """ - Serialize the descriptor to a JSON string. - - :return: JSON representation of descriptor, serialized as a string - """ - return model_to_json_str(self) + schema_: str | None = Field( + None, + alias="$schema", + description="The schema that the document should conform to. This should be the URL of a version of the clear " + "signing JSON schemas available under " + "https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs", + ) + + context: InputContractContext | InputEIP712Context = Field( + title="Binding Context Section", + description="The binding context is a set of constraints that are used to bind the ERC7730 file to a specific" + "structured data being displayed. Currently, supported contexts include contract-specific" + "constraints or EIP712 message specific constraints.", + ) + + metadata: Metadata = Field( + title="Metadata Section", + description="The metadata section contains information about constant values relevant in the scope of the" + "current contract / message (as matched by the `context` section)", + ) + + display: InputDisplay = Field( + title="Display Formatting Info Section", + description="The display section contains all the information needed to format the data in a human readable" + "way. It contains the constants and formatters used to display the data contained in the bound structure.", + ) diff --git a/src/erc7730/model/input/display.py b/src/erc7730/model/input/display.py index a10ff0e..9f087db 100644 --- a/src/erc7730/model/input/display.py +++ b/src/erc7730/model/input/display.py @@ -1,31 +1,29 @@ from typing import Annotated, Any, ForwardRef -from pydantic import Discriminator, RootModel, Tag +from pydantic import Discriminator, Tag from pydantic import Field as PydanticField +from erc7730.common.properties import has_property from erc7730.model.base import Model from erc7730.model.display import ( AddressNameParameters, CallDataParameters, DateParameters, FieldFormat, + FieldsBase, + FormatBase, NftNameParameters, - Screen, TokenAmountParameters, UnitParameters, ) -from erc7730.model.types import Id, Path +from erc7730.model.types import Id # ruff: noqa: N815 - camel case field names are tolerated to match schema -class InputFieldsBase(Model): - path: str - - -class InputReference(InputFieldsBase): +class InputReference(FieldsBase): ref: str = PydanticField(alias="$ref") - params: dict[str, str] | None = None + params: dict[str, str] | None = None # FIXME wrong class InputEnumParameters(Model): @@ -33,35 +31,19 @@ class InputEnumParameters(Model): def get_param_discriminator(v: Any) -> str | None: - if isinstance(v, dict): - if v.get("tokenPath") is not None: - return "token_amount" - if v.get("collectionPath") is not None: - return "nft_name" - if v.get("encoding") is not None: - return "date" - if v.get("base") is not None: - return "unit" - if v.get("$ref") is not None: - return "enum" - if v.get("type") is not None or v.get("sources") is not None: - return "address_name" - if v.get("selector") is not None or v.get("calleePath") is not None: - return "call_data" - return None - if getattr(v, "tokenPath", None) is not None: + if has_property(v, "tokenPath"): return "token_amount" - if getattr(v, "encoding", None) is not None: + if has_property(v, "encoding"): return "date" - if getattr(v, "collectionPath", None) is not None: + if has_property(v, "collectionPath"): return "nft_name" - if getattr(v, "base", None) is not None: + if has_property(v, "base"): return "unit" - if getattr(v, "$ref", None) is not None: + if has_property(v, "$ref"): return "enum" - if getattr(v, "type", None) is not None: + if has_property(v, "type"): return "address_name" - if getattr(v, "selector", None) is not None: + if has_property(v, "selector"): return "call_data" return None @@ -78,60 +60,46 @@ def get_param_discriminator(v: Any) -> str | None: ] -class InputFieldDescription(Model): +class InputFieldDefinition(Model): id: Id | None = PydanticField(None, alias="$id") - path: Path label: str format: FieldFormat | None params: InputFieldParameters | None = None -class InputNestedFields(InputFieldsBase): +class InputFieldDescription(InputFieldDefinition, FieldsBase): + pass + + +class InputNestedFields(FieldsBase): fields: list[ForwardRef("InputField")] # type: ignore def get_field_discriminator(v: Any) -> str | None: - if isinstance(v, dict): - if v.get("label") is not None and v.get("format") is not None: - return "field_description" - if v.get("fields") is not None: - return "nested_fields" - if v.get("$ref") is not None: - return "reference" - return None - if getattr(v, "label", None) is not None: - return "field_description" - if getattr(v, "fields", None) is not None: - return "nested_fields" - if getattr(v, "ref", None) is not None: + if has_property(v, "$ref"): return "reference" + if has_property(v, "fields"): + return "nested_fields" + if has_property(v, "label"): + return "field_description" return None -class InputField( - RootModel[ - Annotated[ - Annotated[InputReference, Tag("reference")] - | Annotated[InputFieldDescription, Tag("field_description")] - | Annotated[InputNestedFields, Tag("nested_fields")], - Discriminator(get_field_discriminator), - ] - ] -): - """Field""" +InputField = Annotated[ + Annotated[InputReference, Tag("reference")] + | Annotated[InputFieldDescription, Tag("field_description")] + | Annotated[InputNestedFields, Tag("nested_fields")], + Discriminator(get_field_discriminator), +] InputNestedFields.model_rebuild() -class InputFormat(Model): - id: Id | None = PydanticField(None, alias="$id") - intent: str | dict[str, str] | None = None +class InputFormat(FormatBase): fields: list[InputField] - required: list[str] | None = None - screens: dict[str, list[Screen]] | None = None class InputDisplay(Model): - definitions: dict[str, InputFieldDescription] | None = None + definitions: dict[str, InputFieldDefinition] | None = None formats: dict[str, InputFormat] diff --git a/src/erc7730/model/resolved/context.py b/src/erc7730/model/resolved/context.py index 5973207..faf993f 100644 --- a/src/erc7730/model/resolved/context.py +++ b/src/erc7730/model/resolved/context.py @@ -2,37 +2,31 @@ from erc7730.model.abi import ABI from erc7730.model.base import Model -from erc7730.model.context import Deployments, Domain, EIP712JsonSchema, Factory +from erc7730.model.context import Deployment, Domain, EIP712JsonSchema, Factory from erc7730.model.types import Id # ruff: noqa: N815 - camel case field names are tolerated to match schema -class ResolvedEIP712(Model): - domain: Domain | None = None - schemas: list[EIP712JsonSchema] - domainSeparator: str | None = None - deployments: Deployments - - -class ResolvedEIP712DomainBinding(Model): - eip712: ResolvedEIP712 - - class ResolvedContract(Model): abi: list[ABI] - deployments: Deployments + deployments: list[Deployment] addressMatcher: AnyUrl | None = None factory: Factory | None = None -class ResolvedContractBinding(Model): - contract: ResolvedContract +class ResolvedEIP712(Model): + domain: Domain | None = None + schemas: list[EIP712JsonSchema] + domainSeparator: str | None = None + deployments: list[Deployment] -class ResolvedContractContext(ResolvedContractBinding): +class ResolvedContractContext(Model): id: Id | None = Field(None, alias="$id") + contract: ResolvedContract -class ResolvedEIP712Context(ResolvedEIP712DomainBinding): +class ResolvedEIP712Context(Model): id: Id | None = Field(None, alias="$id") + eip712: ResolvedEIP712 diff --git a/src/erc7730/model/resolved/descriptor.py b/src/erc7730/model/resolved/descriptor.py index 3f31e01..852b451 100644 --- a/src/erc7730/model/resolved/descriptor.py +++ b/src/erc7730/model/resolved/descriptor.py @@ -9,16 +9,8 @@ - Selectors have been converted to 4 bytes form """ -from pathlib import Path -from typing import Optional - from pydantic import Field -from erc7730.common.pydantic import ( - model_from_json_file_with_includes, - model_from_json_file_with_includes_or_none, - model_to_json_str, -) from erc7730.model.base import Model from erc7730.model.metadata import Metadata from erc7730.model.resolved.context import ResolvedContractContext, ResolvedEIP712Context @@ -43,37 +35,29 @@ class ResolvedERC7730Descriptor(Model): JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v1.schema.json """ - schema_: str | None = Field(None, alias="$schema") - context: ResolvedContractContext | ResolvedEIP712Context - metadata: Metadata - display: ResolvedDisplay - - @classmethod - def load(cls, path: Path) -> "ResolvedERC7730Descriptor": - """ - Load an ERC7730 descriptor from a JSON file. - - :param path: file path - :return: validated in-memory representation of descriptor - :raises Exception: if the file does not exist or has validation errors - """ - return model_from_json_file_with_includes(path, ResolvedERC7730Descriptor) - - @classmethod - def load_or_none(cls, path: Path) -> Optional["ResolvedERC7730Descriptor"]: - """ - Load an ERC7730 descriptor from a JSON file. - - :param path: file path - :return: validated in-memory representation of descriptor, or None if file does not exist - :raises Exception: if the file has validation errors - """ - return model_from_json_file_with_includes_or_none(path, ResolvedERC7730Descriptor) - - def to_json_string(self) -> str: - """ - Serialize the descriptor to a JSON string. - - :return: JSON representation of descriptor, serialized as a string - """ - return model_to_json_str(self) + schema_: str | None = Field( + None, + alias="$schema", + description="The schema that the document should conform to. This should be the URL of a version of the clear " + "signing JSON schemas available under " + "https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs", + ) + + context: ResolvedContractContext | ResolvedEIP712Context = Field( + title="Binding Context Section", + description="The binding context is a set of constraints that are used to bind the ERC7730 file to a specific" + "structured data being displayed. Currently, supported contexts include contract-specific" + "constraints or EIP712 message specific constraints.", + ) + + metadata: Metadata = Field( + title="Metadata Section", + description="The metadata section contains information about constant values relevant in the scope of the" + "current contract / message (as matched by the `context` section)", + ) + + display: ResolvedDisplay = Field( + title="Display Formatting Info Section", + description="The display section contains all the information needed to format the data in a human readable" + "way. It contains the constants and formatters used to display the data contained in the bound structure.", + ) diff --git a/src/erc7730/model/resolved/display.py b/src/erc7730/model/resolved/display.py index 452b3b0..eddb092 100644 --- a/src/erc7730/model/resolved/display.py +++ b/src/erc7730/model/resolved/display.py @@ -3,60 +3,42 @@ from pydantic import Discriminator, Tag from pydantic import Field as PydanticField +from erc7730.common.properties import has_property from erc7730.model.base import Model from erc7730.model.display import ( AddressNameParameters, CallDataParameters, DateParameters, FieldFormat, + FieldsBase, + FormatBase, NftNameParameters, - Screen, TokenAmountParameters, UnitParameters, ) -from erc7730.model.types import Id, Path +from erc7730.model.types import Id # ruff: noqa: N815 - camel case field names are tolerated to match schema -class ResolvedFieldsBase(Model): - path: str - - class ResolvedEnumParameters(Model): ref: str = PydanticField(alias="$ref") # TODO must be inlined here def get_param_discriminator(v: Any) -> str | None: - if isinstance(v, dict): - if v.get("tokenPath") is not None: - return "token_amount" - if v.get("collectionPath") is not None: - return "nft_name" - if v.get("encoding") is not None: - return "date" - if v.get("base") is not None: - return "unit" - if v.get("$ref") is not None: - return "enum" - if v.get("type") is not None or v.get("sources") is not None: - return "address_name" - if v.get("selector") is not None or v.get("calleePath") is not None: - return "call_data" - return None - if getattr(v, "tokenPath", None) is not None: + if has_property(v, "tokenPath"): return "token_amount" - if getattr(v, "encoding", None) is not None: + if has_property(v, "encoding"): return "date" - if getattr(v, "collectionPath", None) is not None: + if has_property(v, "collectionPath"): return "nft_name" - if getattr(v, "base", None) is not None: + if has_property(v, "base"): return "unit" - if getattr(v, "$ref", None) is not None: + if has_property(v, "$ref"): return "enum" - if getattr(v, "type", None) is not None: + if has_property(v, "type"): return "address_name" - if getattr(v, "selector", None) is not None: + if has_property(v, "selector"): return "call_data" return None @@ -73,29 +55,26 @@ def get_param_discriminator(v: Any) -> str | None: ] -class ResolvedFieldDescription(Model): +class ResolvedFieldDefinition(Model): id: Id | None = PydanticField(None, alias="$id") - path: Path label: str format: FieldFormat | None params: ResolvedFieldParameters | None = None -class ResolvedNestedFields(ResolvedFieldsBase): +class ResolvedFieldDescription(ResolvedFieldDefinition, FieldsBase): + pass + + +class ResolvedNestedFields(FieldsBase): fields: list[ForwardRef("ResolvedField")] # type: ignore def get_field_discriminator(v: Any) -> str | None: - if isinstance(v, dict): - if v.get("label") is not None and v.get("format") is not None: - return "field_description" - if v.get("fields") is not None: - return "nested_fields" - return None - if getattr(v, "label", None) is not None: - return "field_description" - if getattr(v, "fields", None) is not None: + if has_property(v, "fields"): return "nested_fields" + if has_property(v, "label"): + return "field_description" return None @@ -108,14 +87,10 @@ def get_field_discriminator(v: Any) -> str | None: ResolvedNestedFields.model_rebuild() -class ResolvedFormat(Model): - id: Id | None = PydanticField(None, alias="$id") - intent: str | dict[str, str] | None = None +class ResolvedFormat(FormatBase): fields: list[ResolvedField] - required: list[str] | None = None - screens: dict[str, list[Screen]] | None = None class ResolvedDisplay(Model): - definitions: dict[str, ResolvedFieldDescription] | None = None + definitions: dict[str, ResolvedFieldDefinition] | None = None formats: dict[str, ResolvedFormat] diff --git a/src/erc7730/model/types.py b/src/erc7730/model/types.py index 051b31d..0ecdfed 100644 --- a/src/erc7730/model/types.py +++ b/src/erc7730/model/types.py @@ -5,10 +5,10 @@ JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v1.schema.json """ -from typing import Annotated as Ann +from typing import Annotated from pydantic import Field -Id = Ann[str, Field(min_length=1)] -ContractAddress = Ann[str, Field(min_length=0, max_length=64, pattern=r"^[a-zA-Z0-9_\-]+$")] -Path = Ann[str, Field(pattern=r"^[a-zA-Z0-9.\[\]_@\$\#]+")] +Id = Annotated[str, Field(min_length=1)] +ContractAddress = Annotated[str, Field(min_length=0, max_length=64, pattern=r"^[a-zA-Z0-9_\-]+$")] +Path = Annotated[str, Field(pattern=r"^[a-zA-Z0-9.\[\]_@\$\#]+")] diff --git a/src/erc7730/model/utils.py b/src/erc7730/model/utils.py index bc0acc1..1de2d9b 100644 --- a/src/erc7730/model/utils.py +++ b/src/erc7730/model/utils.py @@ -2,7 +2,8 @@ Utilities for manipulating ERC-7730 descriptors. """ -from erc7730.model.input.context import Deployments, InputContractContext, InputEIP712Context +from erc7730.model.context import Deployment +from erc7730.model.input.context import InputContractContext, InputEIP712Context from erc7730.model.input.descriptor import InputERC7730Descriptor @@ -10,10 +11,10 @@ def get_chain_ids(descriptor: InputERC7730Descriptor) -> set[int] | None: """Get deployment chaind ids for a descriptor.""" if (deployments := get_deployments(descriptor)) is None: return None - return {d.chainId for d in deployments.root} + return {d.chainId for d in deployments} -def get_deployments(descriptor: InputERC7730Descriptor) -> Deployments | None: +def get_deployments(descriptor: InputERC7730Descriptor) -> list[Deployment] | None: """Get deployments section for a descriptor.""" if isinstance(context := descriptor.context, InputEIP712Context): return context.eip712.deployments