Source code for erc7730.convert.resolved.v2.convert_erc7730_input_to_resolved

"""
Converter for ERC-7730 v2 input descriptors to resolved form.

This module provides conversion from input v2 descriptors to resolved v2 descriptors.
"""

from typing import Any, assert_never, final, override

from pydantic_string_url import HttpUrl

from erc7730.common import client
from erc7730.common.abi import reduce_signature, signature_to_selector
from erc7730.common.output import ExceptionsToOutput, OutputAdder
from erc7730.convert import ERC7730Converter
from erc7730.convert.resolved.v2.constants import ConstantProvider, DefaultConstantProvider
from erc7730.convert.resolved.v2.parameters import resolve_field_parameters
from erc7730.convert.resolved.v2.references import resolve_reference
from erc7730.convert.resolved.v2.values import resolve_field_value
from erc7730.model.input.v2.context import (
    InputContract,
    InputContractContext,
    InputDeployment,
    InputDomain,
    InputEIP712,
    InputEIP712Context,
    InputFactory,
)
from erc7730.model.input.v2.descriptor import InputERC7730Descriptor
from erc7730.model.input.v2.display import (
    InputDisplay,
    InputField,
    InputFieldDefinition,
    InputFieldDescription,
    InputFieldGroup,
    InputFormat,
    InputReference,
)
from erc7730.model.input.v2.format import FieldFormat
from erc7730.model.input.v2.metadata import InputMetadata
from erc7730.model.paths import ROOT_DATA_PATH, Array, ArrayElement, ArraySlice, ContainerPath, DataPath, Field
from erc7730.model.paths.path_ops import data_path_concat
from erc7730.model.resolved.display import ResolvedValueConstant, ResolvedValuePath
from erc7730.model.resolved.metadata import EnumDefinition
from erc7730.model.resolved.v2.context import (
    ResolvedContract,
    ResolvedContractContext,
    ResolvedDeployment,
    ResolvedDomain,
    ResolvedEIP712,
    ResolvedEIP712Context,
    ResolvedFactory,
)
from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor
from erc7730.model.resolved.v2.display import (
    ResolvedDisplay,
    ResolvedField,
    ResolvedFieldDescription,
    ResolvedFieldGroup,
    ResolvedFormat,
)
from erc7730.model.resolved.v2.metadata import ResolvedMapDefinition, ResolvedMetadata, ResolvedOwnerInfo
from erc7730.model.types import Address, Id, Selector


[docs] @final class ERC7730InputToResolved(ERC7730Converter[InputERC7730Descriptor, ResolvedERC7730Descriptor]): """ Converts ERC-7730 v2 descriptor input to resolved form. After conversion, the descriptor is in resolved form: - URLs have been fetched (deprecated ABI and schemas fields are ignored) - Contract addresses have been normalized to lowercase - References have been inlined - Constants have been inlined - Field definitions have been inlined - Field groups have been processed - Selectors have been converted to 4 bytes form - Maps have been resolved """
[docs] @override def convert(self, descriptor: InputERC7730Descriptor, out: OutputAdder) -> ResolvedERC7730Descriptor | None: with ExceptionsToOutput(out): constants = DefaultConstantProvider(descriptor) if (context := self._resolve_context(descriptor.context, out)) is None: return None if (metadata := self._resolve_metadata(descriptor.metadata, out)) is None: return None if (display := self._resolve_display(descriptor.display, context, metadata.enums, constants, out)) is None: return None return ResolvedERC7730Descriptor.model_validate( { "$schema": descriptor.schema_, "$comment": descriptor.comment, "context": context, "metadata": metadata, "display": display, } ) # noinspection PyUnreachableCode return None
@classmethod def _resolve_context( cls, context: InputContractContext | InputEIP712Context, out: OutputAdder ) -> ResolvedContractContext | ResolvedEIP712Context | None: match context: case InputContractContext(): return cls._resolve_context_contract(context, out) case InputEIP712Context(): return cls._resolve_context_eip712(context, out) case _: assert_never(context) @classmethod def _resolve_metadata(cls, metadata: InputMetadata, out: OutputAdder) -> ResolvedMetadata | None: resolved_enums = {} if metadata.enums is not None: for enum_id, enum in metadata.enums.items(): if (resolved_enum := cls._resolve_enum(enum, out)) is not None: resolved_enums[enum_id] = resolved_enum resolved_maps = {} if metadata.maps is not None: for map_id, map_def in metadata.maps.items(): resolved_maps[map_id] = ResolvedMapDefinition.model_validate( {"$keyType": map_def.keyType, "values": map_def.values} ) # Convert InputOwnerInfo to ResolvedOwnerInfo if present resolved_info = None if metadata.info is not None: resolved_info = ResolvedOwnerInfo( legalName=metadata.info.legalName, lastUpdate=metadata.info.lastUpdate, deploymentDate=metadata.info.deploymentDate, url=metadata.info.url, ) return ResolvedMetadata( owner=metadata.owner, contractName=metadata.contractName, info=resolved_info, token=metadata.token, constants=metadata.constants, enums=resolved_enums or None, maps=resolved_maps or None, ) @classmethod def _resolve_enum(cls, enum: HttpUrl | EnumDefinition, out: OutputAdder) -> dict[str, str] | None: match enum: case HttpUrl() as url: try: return client.get(url=url, model=EnumDefinition) except Exception as e: return out.error( title="Failed to fetch enum definition from URL", message=f'Failed to fetch enum definition from URL "{url}": {e}', ) case dict(): return enum case _: assert_never(enum) @classmethod def _resolve_context_contract( cls, context: InputContractContext, out: OutputAdder ) -> ResolvedContractContext | None: if (contract := cls._resolve_contract(context.contract, out)) is None: return None return ResolvedContractContext.model_validate({"$id": context.id, "contract": contract}) @classmethod def _resolve_contract(cls, contract: InputContract, out: OutputAdder) -> ResolvedContract | None: # Note: In v2, ABI field is deprecated and ignored during resolution if (deployments := cls._resolve_deployments(contract.deployments, out)) is None: return None if contract.factory is None: factory = None elif (factory := cls._resolve_factory(contract.factory, out)) is None: return None return ResolvedContract( deployments=deployments, addressMatcher=str(contract.addressMatcher) if contract.addressMatcher is not None else None, factory=factory, ) @classmethod def _resolve_deployments( cls, deployments: list[InputDeployment], out: OutputAdder ) -> list[ResolvedDeployment] | None: resolved_deployments = [] for deployment in deployments: if (resolved_deployment := cls._resolve_deployment(deployment, out)) is not None: resolved_deployments.append(resolved_deployment) return resolved_deployments @classmethod def _resolve_deployment(cls, deployment: InputDeployment, out: OutputAdder) -> ResolvedDeployment | None: return ResolvedDeployment(chainId=deployment.chainId, address=Address(deployment.address)) @classmethod def _resolve_factory(cls, factory: InputFactory, out: OutputAdder) -> ResolvedFactory | None: if (deployments := cls._resolve_deployments(factory.deployments, out)) is None: return None return ResolvedFactory(deployments=deployments, deployEvent=factory.deployEvent) @classmethod def _resolve_context_eip712(cls, context: InputEIP712Context, out: OutputAdder) -> ResolvedEIP712Context | None: if (eip712 := cls._resolve_eip712(context.eip712, out)) is None: return None return ResolvedEIP712Context.model_validate({"$id": context.id, "eip712": eip712}) @classmethod def _resolve_eip712(cls, eip712: InputEIP712, out: OutputAdder) -> ResolvedEIP712 | None: if eip712.domain is None: domain = None elif (domain := cls._resolve_domain(eip712.domain, out)) is None: return None # Note: In v2, schemas field is deprecated and ignored during resolution if (deployments := cls._resolve_deployments(eip712.deployments, out)) is None: return None return ResolvedEIP712( domain=domain, domainSeparator=eip712.domainSeparator, deployments=deployments, ) @classmethod def _resolve_domain(cls, domain: InputDomain, out: OutputAdder) -> ResolvedDomain | None: return ResolvedDomain( name=domain.name, version=domain.version, chainId=domain.chainId, verifyingContract=None if domain.verifyingContract is None else Address(domain.verifyingContract), salt=domain.salt, ) @classmethod def _resolve_display( cls, display: InputDisplay, context: ResolvedContractContext | ResolvedEIP712Context, enums: dict[Id, EnumDefinition] | None, constants: ConstantProvider, out: OutputAdder, ) -> ResolvedDisplay | None: definitions = display.definitions or {} enums = enums or {} formats = {} for format_id, format in display.formats.items(): if (resolved_format_id := cls._resolve_format_id(format_id, context, out)) is None: return None if (resolved_format := cls._resolve_format(format, definitions, enums, constants, out)) is None: return None if resolved_format_id in formats: return out.error( title="Duplicate format", message=f"Descriptor contains 2 formats sections for {resolved_format_id}", ) formats[resolved_format_id] = resolved_format return ResolvedDisplay(definitions=None, formats=formats) @classmethod def _resolve_field_description( cls, prefix: DataPath, definition: InputFieldDescription, enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, ) -> ResolvedFieldDescription | None: match definition.format: case None | FieldFormat.RAW | FieldFormat.AMOUNT | FieldFormat.TOKEN_AMOUNT | FieldFormat.DURATION: pass case ( FieldFormat.ADDRESS_NAME | FieldFormat.INTEROPERABLE_ADDRESS_NAME | FieldFormat.TOKEN_TICKER | FieldFormat.CALL_DATA | FieldFormat.NFT_NAME | FieldFormat.DATE | FieldFormat.UNIT | FieldFormat.ENUM ): if definition.params is None: return out.error( title="Missing parameters", message=f"""Field format "{definition.format.value}" requires parameters to be defined, """ f"""they are missing for field "{definition.path}".""", ) case FieldFormat.CHAIN_ID: pass case _: assert_never(definition.format) params = resolve_field_parameters(prefix, definition.params, enums, constants, out) if (value_or_path := resolve_field_value(prefix, definition, definition.format, constants, out)) is None: return None # Convert InputEncryptionParameters to ResolvedEncryptionParameters if present resolved_encryption = None if definition.encryption is not None: from erc7730.model.resolved.v2.display import ResolvedEncryptionParameters resolved_encryption = ResolvedEncryptionParameters( scheme=definition.encryption.scheme, plaintextType=definition.encryption.plaintextType, fallbackLabel=definition.encryption.fallbackLabel, ) # Convert InputVisibilityConditions to resolved dict for discriminator compatibility resolved_visible: str | dict[str, Any] | None if definition.visible is not None and not isinstance(definition.visible, str): from erc7730.model.input.v2.display import InputVisibilityConditions if isinstance(definition.visible, InputVisibilityConditions): visibility_dict: dict[str, Any] = {} if definition.visible.ifNotIn is not None: visibility_dict["ifNotIn"] = definition.visible.ifNotIn if definition.visible.mustBe is not None: visibility_dict["mustBe"] = definition.visible.mustBe resolved_visible = visibility_dict else: resolved_visible = None else: resolved_visible = definition.visible # In v2, value_or_path is a ResolvedValue (ResolvedValuePath | ResolvedValueConstant) # Convert to v2's simpler path/value model # Convert params/encryption to dicts so discriminated unions work properly params_dict = params.model_dump(by_alias=True, exclude_none=True) if params is not None else None encryption_dict = ( resolved_encryption.model_dump(by_alias=True, exclude_none=True) if resolved_encryption is not None else None ) field_dict: dict[str, Any] = { "$id": definition.id, "visible": resolved_visible, "label": constants.resolve(definition.label, out), "format": FieldFormat(definition.format) if definition.format is not None else None, "params": params_dict, "separator": definition.separator, "encryption": encryption_dict, } # Set either path or value based on the ResolvedValue type if isinstance(value_or_path, ResolvedValuePath): field_dict["path"] = str(value_or_path.path) field_dict["value"] = None elif isinstance(value_or_path, ResolvedValueConstant): field_dict["path"] = None field_dict["value"] = value_or_path.value else: return out.error( title="Invalid value type", message=f"Unexpected value type: {type(value_or_path)}", ) return ResolvedFieldDescription.model_validate(field_dict) @classmethod def _resolve_format_id( cls, format_id: str, context: ResolvedContractContext | ResolvedEIP712Context, out: OutputAdder, ) -> str | Selector | None: match context: case ResolvedContractContext(): if format_id.startswith("0x"): return Selector(format_id) if (reduced_signature := reduce_signature(format_id)) is not None: return Selector(signature_to_selector(reduced_signature)) return out.error( title="Invalid selector", message=f""""{format_id}" is not a valid function signature or selector.""", ) case ResolvedEIP712Context(): return format_id case _: assert_never(context) @classmethod def _resolve_format( cls, format: InputFormat, definitions: dict[Id, InputFieldDefinition], enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, ) -> ResolvedFormat | None: if (fields := cls._resolve_fields(ROOT_DATA_PATH, format.fields, definitions, enums, constants, out)) is None: return None return ResolvedFormat.model_validate( { "$id": format.id, "intent": format.intent, "interpolatedIntent": format.interpolatedIntent, "fields": [f.model_dump(by_alias=True, exclude_none=True) for f in fields], } ) @classmethod def _resolve_fields( cls, prefix: DataPath, fields: list[InputField], definitions: dict[Id, InputFieldDefinition], enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, ) -> list[ResolvedField] | None: resolved_fields = [] for input_format in fields: if (resolved_field := cls._resolve_field(prefix, input_format, definitions, enums, constants, out)) is None: return None resolved_fields.extend(resolved_field) return resolved_fields @classmethod def _resolve_field( cls, prefix: DataPath, field: InputField, definitions: dict[Id, InputFieldDefinition], enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, ) -> list[ResolvedField] | None: resolved_fields: list[ResolvedField] = [] match field: case InputReference(): if (resolved_field := resolve_reference(prefix, field, definitions, enums, constants, out)) is None: return None resolved_fields.append(resolved_field) case InputFieldDescription(): if (resolved_field := cls._resolve_field_description(prefix, field, enums, constants, out)) is None: return None resolved_fields.append(resolved_field) case InputFieldGroup(): if ( resolved_field_group := cls._resolve_field_group(prefix, field, definitions, enums, constants, out) ) is None: return None resolved_fields.extend(resolved_field_group) case _: assert_never(field) return resolved_fields @classmethod def _resolve_field_group( cls, prefix: DataPath, group: InputFieldGroup, definitions: dict[Id, InputFieldDefinition], enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, ) -> list[ResolvedFieldGroup | ResolvedFieldDescription] | None: if group.path is None: # No path = logical grouping only, resolve fields with current prefix if ( resolved_fields := cls._resolve_fields( prefix=prefix, fields=group.fields, definitions=definitions, enums=enums, constants=constants, out=out, ) ) is None: return None return [ ResolvedFieldGroup.model_validate( { "$id": group.id, "label": group.label, "iteration": group.iteration, "fields": [f.model_dump(by_alias=True, exclude_none=True) for f in resolved_fields], } ) ] path: DataPath match constants.resolve_path(group.path, out): case None: return None case DataPath() as data_path: path = data_path_concat(prefix, data_path) case ContainerPath() as container_path: return out.error( title="Invalid path type", message=f"Container path {container_path} cannot be used with field groups.", ) case _: assert_never(group.path) if ( resolved_fields := cls._resolve_fields( prefix=path, fields=group.fields, definitions=definitions, enums=enums, constants=constants, out=out ) ) is None: return None match path.elements[-1]: case Field() | ArrayElement(): return resolved_fields case ArraySlice(): return out.error( title="Invalid field group", message="Using field groups on an array slice is not allowed.", ) case Array(): return [ ResolvedFieldGroup.model_validate( { "$id": group.id, "path": str(path), "label": group.label, "iteration": group.iteration, "fields": [f.model_dump(by_alias=True, exclude_none=True) for f in resolved_fields], } ) ] case _: assert_never(path.elements[-1])