From d268342dcb7e5a984c1591592e40ca6700813fb8 Mon Sep 17 00:00:00 2001 From: jnicoulaud-ledger <102984500+jnicoulaud-ledger@users.noreply.github.com> Date: Wed, 2 Oct 2024 10:35:54 +0200 Subject: [PATCH] refactor: split model in input/resolved (#40) * chore: create packages * split model in input/resolved * split model in input/resolved (WIP) * lint/fixes * lint/fixes * lint/fixes * lint/fixes * lint/fixes --- src/erc7730/common/properties.py | 14 + src/erc7730/convert/__init__.py | 25 +- src/erc7730/convert/convert.py | 13 +- .../convert/convert_eip712_to_erc7730.py | 90 +++--- .../convert_erc7730_input_to_resolved.py | 293 ++++++++++++++++++ .../convert/convert_erc7730_to_eip712.py | 194 +++++------- src/erc7730/lint/__init__.py | 5 +- src/erc7730/lint/classifier/__init__.py | 5 +- src/erc7730/lint/classifier/abi_classifier.py | 8 +- src/erc7730/lint/common/paths.py | 21 +- src/erc7730/lint/lint.py | 39 ++- src/erc7730/lint/lint_base.py | 4 +- .../lint/lint_transaction_type_classifier.py | 25 +- src/erc7730/lint/lint_validate_abi.py | 16 +- .../lint/lint_validate_display_fields.py | 19 +- src/erc7730/main.py | 4 +- src/erc7730/model/__init__.py | 2 +- src/erc7730/model/abi.py | 6 + src/erc7730/model/base.py | 51 +++ src/erc7730/model/context.py | 83 +---- src/erc7730/model/descriptor.py | 61 ---- src/erc7730/model/display.py | 125 +------- src/erc7730/model/input/__init__.py | 8 + src/erc7730/model/input/context.py | 32 ++ src/erc7730/model/input/descriptor.py | 54 ++++ src/erc7730/model/input/display.py | 105 +++++++ src/erc7730/model/metadata.py | 7 + src/erc7730/model/resolved/__init__.py | 8 + src/erc7730/model/resolved/context.py | 32 ++ src/erc7730/model/resolved/descriptor.py | 63 ++++ src/erc7730/model/resolved/display.py | 96 ++++++ src/erc7730/model/types.py | 15 +- src/erc7730/model/utils.py | 74 +---- .../convert/test_convert_eip712_round_trip.py | 17 +- .../convert/test_convert_erc7730_to_eip712.py | 9 +- tests/files.py | 2 - tests/model/test_model_serialization.py | 19 +- tests/schemas.py | 4 +- 38 files changed, 1068 insertions(+), 580 deletions(-) create mode 100644 src/erc7730/common/properties.py create mode 100644 src/erc7730/convert/convert_erc7730_input_to_resolved.py delete mode 100644 src/erc7730/model/descriptor.py create mode 100644 src/erc7730/model/input/__init__.py create mode 100644 src/erc7730/model/input/context.py create mode 100644 src/erc7730/model/input/descriptor.py create mode 100644 src/erc7730/model/input/display.py create mode 100644 src/erc7730/model/resolved/__init__.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/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/__init__.py b/src/erc7730/convert/__init__.py index 4360c04..0bb4092 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 ERC7730Descriptor - InputType = TypeVar("InputType", bound=BaseModel) OutputType = TypeVar("OutputType", bound=BaseModel) @@ -39,22 +36,22 @@ 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[ERC7730Descriptor, OutputType], ABC): - """Converter from ERC-7730 to another format.""" + class ErrorAdder(ABC): + """ERC7730Converter output sink.""" + @abstractmethod + def warning(self, message: str) -> None: + raise NotImplementedError() -class ToERC7730Converter(ERC7730Converter[InputType, ERC7730Descriptor], ABC): - """Converter from another format to ERC-7730.""" + @abstractmethod + 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 26bd9bd..be1d5c9 100644 --- a/src/erc7730/convert/convert_eip712_to_erc7730.py +++ b/src/erc7730/convert/convert_eip712_to_erc7730.py @@ -8,28 +8,37 @@ ) 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 ERC7730Descriptor +from erc7730.convert import ERC7730Converter +from erc7730.model.context import Deployment, 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, + InputFieldDescription, + InputFormat, + InputNestedFields, + InputReference, +) 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) -> ERC7730Descriptor | None: - # FIXME this code flattens all messages in first contract + def convert( + self, descriptor: EIP712DAppDescriptor, error: ERC7730Converter.ErrorAdder + ) -> InputERC7730Descriptor | None: + # 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: @@ -37,13 +46,9 @@ def convert(self, descriptor: EIP712DAppDescriptor, error: ERC7730Converter.Erro 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: @@ -56,27 +61,25 @@ def convert(self, descriptor: EIP712DAppDescriptor, error: ERC7730Converter.Erro types=schema, ) ) - fields = [Field(self._convert_field(field)) for field in mapper.fields] - formats[mapper.label] = Format( + fields = [self._convert_field(field) for field in mapper.fields] + formats[mapper.label] = InputFormat( intent=None, # FIXME fields=fields, required=None, # FIXME screens=None, ) - return ERC7730Descriptor( - 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=[Deployment(chainId=descriptor.chain_id, address=verifying_contract)], ) ), metadata=Metadata( @@ -86,28 +89,27 @@ def convert(self, descriptor: EIP712DAppDescriptor, error: ERC7730Converter.Erro 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 | InputReference | InputNestedFields: 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..f8efa0f --- /dev/null +++ b/src/erc7730/convert/convert_erc7730_input_to_resolved.py @@ -0,0 +1,293 @@ +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, + InputFieldDefinition, + 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, + ResolvedFieldDefinition, + 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.model_validate( + {"$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(schema), 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: + if display.definitions is None: + definitions = None + else: + definitions = {} + for definition_key, definition in display.definitions.items(): + if (resolved_definition := cls._convert_field_definition(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) + + @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 + ) -> ResolvedFieldDescription | None: + params = cls._convert_field_parameters(definition.params, error) if definition.params is not None else None + + 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 + 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 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.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.model_validate( + { + "$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, 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 + def _convert_nested_fields( + cls, fields: InputNestedFields, error: ERC7730Converter.ErrorAdder + ) -> 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 + def _convert_reference(cls, reference: InputReference, error: ERC7730Converter.ErrorAdder) -> ResolvedField | None: + raise NotImplementedError() # TODO + + @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 515051e..be15484 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,159 +8,132 @@ 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, FromERC7730Converter -from erc7730.model.context import EIP712Context, EIP712JsonSchema, NameType -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.convert import ERC7730Converter from erc7730.model.display import ( - CallDataParameters, - Display, - Field, - FieldDescription, FieldFormat, - NestedFields, - NftNameParameters, - Reference, TokenAmountParameters, ) +from erc7730.model.resolved.context import ResolvedEIP712Context +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor +from erc7730.model.resolved.display import ( + 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: ERC7730Descriptor, error: ERC7730Converter.ErrorAdder) -> EIP712DAppDescriptor | None: + 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, EIP712Context): - return error( - ERC7730Converter.Error( - level=ERC7730Converter.Error.Level.FATAL, message="context is None or is not EIP712" - ) - ) + if not isinstance(context, ResolvedEIP712Context): + return error.error("context 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(ERC7730Converter.Error(level=ERC7730Converter.Error.Level.FATAL, message=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))) - - messages = list[EIP712MessageDescriptor]() - if context.eip712.domain is None: - return error( - ERC7730Converter.Error(level=ERC7730Converter.Error.Level.FATAL, message="domain is undefined") - ) + schemas = context.eip712.schemas + + if (domain := context.eip712.domain) is None: + return error.error("domain is undefined") + + chain_id = domain.chainId + contract_address = domain.verifyingContract - 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 - if chain_id is None: - return error( - ERC7730Converter.Error(level=ERC7730Converter.Error.Level.FATAL, message="chain id is undefined") - ) name = "" - if context.eip712.domain.name is not None: - name = context.eip712.domain.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") 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") + + 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() + ] - 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=eip712_schema, mapper=mapper)) - contracts = list[EIP712ContractDescriptor]() 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( - 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) + return EIP712DAppDescriptor.model_construct( + blockchainName=network, chainId=chain_id, name=name, contracts=contracts + ) @classmethod - def parse_field(cls, display: Display, field: Field) -> 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 - else: - output.append(cls.convert_field(field_root)) - return output + def convert_field(cls, field: ResolvedField) -> list[EIP712Field]: + if isinstance(field, ResolvedNestedFields): + 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: FieldDescription) -> 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 99b4d89..5224271 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.resolved.descriptor import ResolvedERC7730Descriptor class ERC7730Linter(ABC): @@ -16,7 +16,7 @@ class ERC7730Linter(ABC): """ @abstractmethod - def lint(self, descriptor: ERC7730Descriptor, out: "OutputAdder") -> None: + def lint(self, descriptor: ResolvedERC7730Descriptor, out: "OutputAdder") -> None: raise NotImplementedError() class Output(BaseModel): @@ -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/classifier/__init__.py b/src/erc7730/lint/classifier/__init__.py index cccd306..cc2fc26 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", 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 1cf889e..e1df5b2 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[list[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: list[ABI]) -> TxClass | None: pass diff --git a/src/erc7730/lint/common/paths.py b/src/erc7730/lint/common/paths.py index 5a58edd..6795e2c 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,14 @@ 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: - 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 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 cd32c18..d5b7afe 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 ERC7730Descriptor -from erc7730.model.utils import resolve_external_references +from erc7730.model.input.descriptor import InputERC7730Descriptor def lint_all_and_print_errors( @@ -91,12 +92,38 @@ def adder(output: ERC7730Linter.Output) -> None: out(output.model_copy(update={"file": path})) try: - descriptor = ERC7730Descriptor.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: + # TODO unwrap pydantic validation errors here to provide more user-friendly error messages + 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: + 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 ErrorAdder() diff --git a/src/erc7730/lint/lint_base.py b/src/erc7730/lint/lint_base.py index aa02823..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 ERC7730Descriptor +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: ERC7730Descriptor, 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 d71cffb..2c88662 100644 --- a/src/erc7730/lint/lint_transaction_type_classifier.py +++ b/src/erc7730/lint/lint_transaction_type_classifier.py @@ -1,14 +1,12 @@ 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.descriptor import ERC7730Descriptor -from erc7730.model.display import Display, Format +from erc7730.model.resolved.context import EIP712JsonSchema, ResolvedContractContext, ResolvedEIP712Context +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor +from erc7730.model.resolved.display import ResolvedDisplay, ResolvedFormat @final @@ -19,7 +17,7 @@ class ClassifyTransactionTypeLinter(ERC7730Linter): """ @override - def lint(self, descriptor: ERC7730Descriptor, 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,21 +36,18 @@ def lint(self, descriptor: ERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> out(linter_output) @classmethod - def _determine_tx_class(cls, descriptor: ERC7730Descriptor) -> TxClass | None: - if isinstance(descriptor.context, EIP712Context): + def _determine_tx_class(cls, descriptor: ResolvedERC7730Descriptor) -> TxClass | None: + 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 @@ -62,7 +57,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 +100,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 e79c865..f3e15cd 100644 --- a/src/erc7730/lint/lint_validate_abi.py +++ b/src/erc7730/lint/lint_validate_abi.py @@ -3,8 +3,8 @@ 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.descriptor import ERC7730Descriptor +from erc7730.model.resolved.context import ResolvedContractContext, ResolvedEIP712Context +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor @final @@ -16,25 +16,25 @@ class ValidateABILinter(ERC7730Linter): """ @override - def lint(self, descriptor: ERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: - if isinstance(descriptor.context, EIP712Context): + def lint(self, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: + 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") 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/lint/lint_validate_display_fields.py b/src/erc7730/lint/lint_validate_display_fields.py index edd140e..6ad395a 100644 --- a/src/erc7730/lint/lint_validate_display_fields.py +++ b/src/erc7730/lint/lint_validate_display_fields.py @@ -3,8 +3,8 @@ 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.descriptor import ERC7730Descriptor +from erc7730.model.resolved.context import EIP712JsonSchema, ResolvedContractContext, ResolvedEIP712Context +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor @final @@ -15,13 +15,13 @@ class ValidateDisplayFieldsLinter(ERC7730Linter): """ @override - def lint(self, descriptor: ERC7730Descriptor, 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: ERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: - if isinstance(descriptor.context, EIP712Context) and descriptor.context.eip712.schemas is not None: + def _validate_eip712_paths(cls, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> 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): @@ -84,13 +84,8 @@ def _validate_eip712_paths(cls, descriptor: ERC7730Descriptor, out: ERC7730Linte ) @classmethod - def _validate_abi_paths(cls, descriptor: ERC7730Descriptor, 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) - ): + def _validate_abi_paths(cls, descriptor: ResolvedERC7730Descriptor, out: ERC7730Linter.OutputAdder) -> None: + 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/main.py b/src/erc7730/main.py index 8ed1fd6..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 ERC7730Descriptor +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=ERC7730Descriptor.load(input_erc7730_path), + input_descriptor=InputERC7730Descriptor.load(input_erc7730_path), output_file=output_eip712_path, converter=ERC7730toEIP712Converter(), ): 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/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..118ce23 100644 --- a/src/erc7730/model/base.py +++ b/src/erc7730/model/base.py @@ -1,7 +1,28 @@ +""" +Base model for library, using pydantic. + +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): + """ + Base model for library, using pydantic. + + See https://docs.pydantic.dev + """ + model_config = ConfigDict( strict=True, frozen=True, @@ -13,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 52a5ed4..3c71b62 100644 --- a/src/erc7730/model/context.py +++ b/src/erc7730/model/context.py @@ -1,10 +1,7 @@ -from enum import Enum -from typing import ForwardRef - -from pydantic import AnyUrl, Field, RootModel, field_validator +from pydantic import AnyUrl, field_validator 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 @@ -38,81 +35,9 @@ class Domain(Model): class Deployment(Model): chainId: int - address: str - - -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 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""" + address: ContractAddress class Factory(Model): - deployments: Deployments + deployments: list[Deployment] deployEvent: str - - -class Contract(Model): - abi: AnyUrl | AbiJsonSchema - 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/descriptor.py b/src/erc7730/model/descriptor.py deleted file mode 100644 index c34c861..0000000 --- a/src/erc7730/model/descriptor.py +++ /dev/null @@ -1,61 +0,0 @@ -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.context import ContractContext, EIP712Context -from erc7730.model.display import Display -from erc7730.model.metadata import Metadata - -# ruff: noqa: N815 - camel case field names are tolerated to match schema - - -class ERC7730Descriptor(Model): - """ - An ERC7730 Clear Signing descriptor. - - 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: ContractContext | EIP712Context - metadata: Metadata - display: Display - - @classmethod - def load(cls, path: Path) -> "ERC7730Descriptor": - """ - 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, ERC7730Descriptor) - - @classmethod - def load_or_none(cls, path: Path) -> Optional["ERC7730Descriptor"]: - """ - 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, ERC7730Descriptor) - - 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/display.py b/src/erc7730/model/display.py index 299a142..971aeae 100644 --- a/src/erc7730/model/display.py +++ b/src/erc7730/model/display.py @@ -1,11 +1,11 @@ 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 +from erc7730.model.types import Id # ruff: noqa: N815 - camel case field names are tolerated to match schema @@ -31,15 +31,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 +80,16 @@ 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") +class FieldsBase(Model): + path: str + + +class FormatBase(Model): + 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 new file mode 100644 index 0000000..744873b --- /dev/null +++ b/src/erc7730/model/input/__init__.py @@ -0,0 +1,8 @@ +""" +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 +""" diff --git a/src/erc7730/model/input/context.py b/src/erc7730/model/input/context.py new file mode 100644 index 0000000..8e3d041 --- /dev/null +++ b/src/erc7730/model/input/context.py @@ -0,0 +1,32 @@ +from pydantic import AnyUrl, Field + +from erc7730.model.abi import ABI +from erc7730.model.base import Model +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 InputContract(Model): + abi: list[ABI] | AnyUrl + deployments: list[Deployment] + addressMatcher: AnyUrl | None = None + factory: Factory | None = None + + +class InputEIP712(Model): + domain: Domain | None = None + schemas: list[EIP712JsonSchema | AnyUrl] + domainSeparator: str | None = None + deployments: list[Deployment] + + +class InputContractContext(Model): + id: Id | None = Field(None, alias="$id") + contract: InputContract + + +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 new file mode 100644 index 0000000..91ab651 --- /dev/null +++ b/src/erc7730/model/input/descriptor.py @@ -0,0 +1,54 @@ +""" +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 pydantic import Field + +from erc7730.model.base import Model +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 InputERC7730Descriptor(Model): + """ + An ERC7730 Clear Signing descriptor. + + 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", + 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 new file mode 100644 index 0000000..9f087db --- /dev/null +++ b/src/erc7730/model/input/display.py @@ -0,0 +1,105 @@ +from typing import Annotated, Any, ForwardRef + +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, + TokenAmountParameters, + UnitParameters, +) +from erc7730.model.types import Id + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class InputReference(FieldsBase): + ref: str = PydanticField(alias="$ref") + params: dict[str, str] | None = None # FIXME wrong + + +class InputEnumParameters(Model): + ref: str = PydanticField(alias="$ref") + + +def get_param_discriminator(v: Any) -> str | None: + if has_property(v, "tokenPath"): + return "token_amount" + if has_property(v, "encoding"): + return "date" + if has_property(v, "collectionPath"): + return "nft_name" + if has_property(v, "base"): + return "unit" + if has_property(v, "$ref"): + return "enum" + if has_property(v, "type"): + return "address_name" + if has_property(v, "selector"): + 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 InputFieldDefinition(Model): + id: Id | None = PydanticField(None, alias="$id") + label: str + format: FieldFormat | None + params: InputFieldParameters | None = None + + +class InputFieldDescription(InputFieldDefinition, FieldsBase): + pass + + +class InputNestedFields(FieldsBase): + fields: list[ForwardRef("InputField")] # type: ignore + + +def get_field_discriminator(v: Any) -> str | 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 + + +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(FormatBase): + fields: list[InputField] + + +class InputDisplay(Model): + definitions: dict[str, InputFieldDefinition] | 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 new file mode 100644 index 0000000..f4440b7 --- /dev/null +++ b/src/erc7730/model/resolved/__init__.py @@ -0,0 +1,8 @@ +""" +Package 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 +""" diff --git a/src/erc7730/model/resolved/context.py b/src/erc7730/model/resolved/context.py new file mode 100644 index 0000000..faf993f --- /dev/null +++ b/src/erc7730/model/resolved/context.py @@ -0,0 +1,32 @@ +from pydantic import AnyUrl, Field + +from erc7730.model.abi import ABI +from erc7730.model.base import Model +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 ResolvedContract(Model): + abi: list[ABI] + deployments: list[Deployment] + addressMatcher: AnyUrl | None = None + factory: Factory | None = None + + +class ResolvedEIP712(Model): + domain: Domain | None = None + schemas: list[EIP712JsonSchema] + domainSeparator: str | None = None + deployments: list[Deployment] + + +class ResolvedContractContext(Model): + id: Id | None = Field(None, alias="$id") + contract: ResolvedContract + + +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 new file mode 100644 index 0000000..852b451 --- /dev/null +++ b/src/erc7730/model/resolved/descriptor.py @@ -0,0 +1,63 @@ +""" +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 + - Constants have been inlined + - Enums have been inlined + - Selectors have been converted to 4 bytes form +""" + +from pydantic import Field + +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 + - 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 + + JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v1.schema.json + """ + + 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 new file mode 100644 index 0000000..eddb092 --- /dev/null +++ b/src/erc7730/model/resolved/display.py @@ -0,0 +1,96 @@ +from typing import Annotated, Any, ForwardRef + +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, + TokenAmountParameters, + UnitParameters, +) +from erc7730.model.types import Id + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class ResolvedEnumParameters(Model): + ref: str = PydanticField(alias="$ref") # TODO must be inlined here + + +def get_param_discriminator(v: Any) -> str | None: + if has_property(v, "tokenPath"): + return "token_amount" + if has_property(v, "encoding"): + return "date" + if has_property(v, "collectionPath"): + return "nft_name" + if has_property(v, "base"): + return "unit" + if has_property(v, "$ref"): + return "enum" + if has_property(v, "type"): + return "address_name" + if has_property(v, "selector"): + 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 ResolvedFieldDefinition(Model): + id: Id | None = PydanticField(None, alias="$id") + label: str + format: FieldFormat | None + params: ResolvedFieldParameters | None = None + + +class ResolvedFieldDescription(ResolvedFieldDefinition, FieldsBase): + pass + + +class ResolvedNestedFields(FieldsBase): + fields: list[ForwardRef("ResolvedField")] # type: ignore + + +def get_field_discriminator(v: Any) -> str | None: + if has_property(v, "fields"): + return "nested_fields" + if has_property(v, "label"): + return "field_description" + return None + + +ResolvedField = Annotated[ + Annotated[ResolvedFieldDescription, Tag("field_description")] + | Annotated[ResolvedNestedFields, Tag("nested_fields")], + Discriminator(get_field_discriminator), +] + +ResolvedNestedFields.model_rebuild() + + +class ResolvedFormat(FormatBase): + fields: list[ResolvedField] + + +class ResolvedDisplay(Model): + 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 4f153c5..0ecdfed 100644 --- a/src/erc7730/model/types.py +++ b/src/erc7730/model/types.py @@ -1,7 +1,14 @@ -from typing import Annotated as Ann +""" +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 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 483094d..1de2d9b 100644 --- a/src/erc7730/model/utils.py +++ b/src/erc7730/model/utils.py @@ -1,75 +1,23 @@ -import requests -from pydantic import AnyUrl, RootModel +""" +Utilities for manipulating ERC-7730 descriptors. +""" -from erc7730.model.abi import ABI -from erc7730.model.context import ContractContext, Deployments, EIP712Context, EIP712JsonSchema -from erc7730.model.descriptor import ERC7730Descriptor +from erc7730.model.context import Deployment +from erc7730.model.input.context import InputContractContext, InputEIP712Context +from erc7730.model.input.descriptor import InputERC7730Descriptor -def get_chain_ids(descriptor: ERC7730Descriptor) -> 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} + return {d.chainId for d in deployments} -def get_deployments(descriptor: ERC7730Descriptor) -> Deployments | None: +def get_deployments(descriptor: InputERC7730Descriptor) -> list[Deployment] | 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)}") - - -def resolve_external_references(descriptor: ERC7730Descriptor) -> ERC7730Descriptor: - 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: ERC7730Descriptor) -> ERC7730Descriptor: - 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: ERC7730Descriptor) -> ERC7730Descriptor: - 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 276cce4..b94db87 100644 --- a/tests/convert/test_convert_eip712_round_trip.py +++ b/tests/convert/test_convert_eip712_round_trip.py @@ -6,8 +6,9 @@ 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.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,8 +16,10 @@ @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) - legacy_eip712_descriptor = convert_and_print_errors(input_erc7730_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 + 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 39a2e9f..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 ERC7730Descriptor +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 = ERC7730Descriptor.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/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..64a409e 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 ERC7730Descriptor -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(ERC7730Descriptor.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(ERC7730Descriptor.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)) @@ -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"]}]' "}" @@ -60,7 +61,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 +69,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") + InputERC7730Descriptor.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)