From 06bcd366524c6c414ed3c3738a20e6532188a790 Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Fri, 27 Mar 2026 11:03:38 +0100 Subject: [PATCH] feat: support constants and map references in v2 schema fields Align v2 linter with updated ERC-7730 schema that widens many fields to accept constant references ($.metadata.constants.*) and map references alongside literal values. - Add InputMapReference model and update Pydantic input models for display, context and metadata fields (token, callee, selector, label, owner, etc.) - Thread strict_maps flag through the resolution pipeline: lint mode validates map references but drops them for resolution, calldata/convert modes error - Validate map references: check map path exists in metadata.maps, check keyPath is a valid container/data/descriptor path - Reject structured data paths (#.) in keyPath for context/metadata sections - Add @.chainId to container path grammar per spec - Handle empty resolved params dict after map reference drop Made-with: Cursor --- .../convert_erc7730_v2_input_to_calldata.py | 3 +- src/erc7730/convert/calldata/v1/path.py | 5 + .../eip712/convert_erc7730_v2_to_eip712.py | 3 +- src/erc7730/convert/resolved/v2/constants.py | 31 +-- .../v2/convert_erc7730_input_to_resolved.py | 243 +++++++++++++++--- src/erc7730/convert/resolved/v2/parameters.py | 228 ++++++++++------ src/erc7730/convert/resolved/v2/references.py | 31 ++- src/erc7730/convert/resolved/v2/values.py | 18 +- src/erc7730/lint/v2/lint.py | 2 +- src/erc7730/model/input/v2/common.py | 29 +++ src/erc7730/model/input/v2/context.py | 10 +- src/erc7730/model/input/v2/display.py | 43 +--- src/erc7730/model/input/v2/metadata.py | 6 +- src/erc7730/model/paths/__init__.py | 3 + src/erc7730/model/paths/path_parser.py | 2 +- 15 files changed, 474 insertions(+), 183 deletions(-) create mode 100644 src/erc7730/model/input/v2/common.py diff --git a/src/erc7730/convert/calldata/convert_erc7730_v2_input_to_calldata.py b/src/erc7730/convert/calldata/convert_erc7730_v2_input_to_calldata.py index 33ee3aba..1f24e798 100644 --- a/src/erc7730/convert/calldata/convert_erc7730_v2_input_to_calldata.py +++ b/src/erc7730/convert/calldata/convert_erc7730_v2_input_to_calldata.py @@ -131,8 +131,7 @@ def erc7730_v2_descriptor_to_calldata_descriptors( if chain_id not in deployment_chain_ids: return [] - # Resolve the v2 descriptor - if (resolved_descriptor := ERC7730InputToResolved().convert(input_descriptor, out)) is None: + if (resolved_descriptor := ERC7730InputToResolved().convert(input_descriptor, out, strict_maps=True)) is None: return [] context = cast(ResolvedContractContext, resolved_descriptor.context) diff --git a/src/erc7730/convert/calldata/v1/path.py b/src/erc7730/convert/calldata/v1/path.py index 02781a2c..76eb5984 100644 --- a/src/erc7730/convert/calldata/v1/path.py +++ b/src/erc7730/convert/calldata/v1/path.py @@ -136,6 +136,11 @@ def convert_container_path( field = CalldataDescriptorContainerPathValueV1.VALUE type_family = CalldataDescriptorTypeFamily.UINT type_size = 32 + case ContainerField.CHAINID: + return out.error( + title="Unsupported container field", + message="Container field @.chainId is not supported in calldata descriptor v1 format.", + ) case _: assert_never(path.field) return CalldataDescriptorValuePathV1( diff --git a/src/erc7730/convert/ledger/eip712/convert_erc7730_v2_to_eip712.py b/src/erc7730/convert/ledger/eip712/convert_erc7730_v2_to_eip712.py index c7a2731c..0690e98d 100644 --- a/src/erc7730/convert/ledger/eip712/convert_erc7730_v2_to_eip712.py +++ b/src/erc7730/convert/ledger/eip712/convert_erc7730_v2_to_eip712.py @@ -459,8 +459,7 @@ def convert( message="Descriptor context is not EIP-712; only EIP-712 descriptors can be converted.", ) - # Resolve the v2 descriptor - resolved = ERC7730InputToResolved().convert(input_descriptor, out) + resolved = ERC7730InputToResolved().convert(input_descriptor, out, strict_maps=True) if resolved is None: return None diff --git a/src/erc7730/convert/resolved/v2/constants.py b/src/erc7730/convert/resolved/v2/constants.py index 7fdcbd39..71b97712 100644 --- a/src/erc7730/convert/resolved/v2/constants.py +++ b/src/erc7730/convert/resolved/v2/constants.py @@ -8,8 +8,9 @@ from erc7730.common.output import OutputAdder from erc7730.common.properties import get_property from erc7730.model.input.path import ContainerPathStr, DataPathStr +from erc7730.model.input.v2.common import InputMapReference from erc7730.model.input.v2.descriptor import InputERC7730Descriptor -from erc7730.model.input.v2.display import InputMapReference +from erc7730.model.input.v2.metadata import InputMapDefinition from erc7730.model.paths import ROOT_DESCRIPTOR_PATH, ArrayElement, ContainerPath, DataPath, DescriptorPath, Field from erc7730.model.paths.path_ops import descriptor_path_append, to_absolute from erc7730.model.types import MixedCaseAddress @@ -196,37 +197,25 @@ def get(self, path: DescriptorPath, out: OutputAdder) -> Any: @override def resolve_map_reference(self, prefix: DataPath, map_ref: InputMapReference, out: OutputAdder) -> Any: """ - Resolve a map reference to its value by looking up the map and resolving the keyPath. + Validate a map reference: check that the map path resolves to a valid map definition in metadata.maps, + and that the keyPath is a valid data/container path. :param prefix: current path prefix :param map_ref: map reference with map descriptor path and keyPath :param out: error handler - :return: resolved value from map, or None if not found + :return: the map reference if valid, or None if validation fails """ - # Get the map definition if (map_def := self.get(map_ref.map, out)) is None: - return out.error( - title="Invalid map reference", - message=f"Map at {map_ref.map} does not exist.", - ) + return None - # Ensure map has the expected structure - if not hasattr(map_def, "values") or not isinstance(map_def.values, dict): + if not isinstance(map_def, InputMapDefinition): return out.error( title="Invalid map reference", - message=f"Map at {map_ref.map} is not a valid map definition.", + message=f"Map at {map_ref.map} is not a valid map definition (expected a map object, " + f"got {type(map_def).__name__}).", ) - # Resolve the key path to get the key value - if (self.resolve_path(map_ref.keyPath, out)) is None: + if self.resolve_path(map_ref.keyPath, out) is None: return None - # For map references, the key path is either a DataPath or ContainerPath - # We can't actually resolve the runtime value here during conversion, - # so we store the path for runtime resolution. However, for constant validation - # we could check if it's a constant path. - # Since this is input-to-resolved conversion, we pass through the structure. - # The actual key lookup happens at display time, not conversion time. - - # Return the map reference as-is for the resolved model to handle at runtime return map_ref diff --git a/src/erc7730/convert/resolved/v2/convert_erc7730_input_to_resolved.py b/src/erc7730/convert/resolved/v2/convert_erc7730_input_to_resolved.py index 45aecfaf..a5f3d4e0 100644 --- a/src/erc7730/convert/resolved/v2/convert_erc7730_input_to_resolved.py +++ b/src/erc7730/convert/resolved/v2/convert_erc7730_input_to_resolved.py @@ -16,6 +16,7 @@ from erc7730.convert.resolved.v2.parameters import resolve_field_parameters from erc7730.convert.resolved.v2.references import is_field_hidden, resolve_reference from erc7730.convert.resolved.v2.values import resolve_field_value +from erc7730.model.input.v2.common import InputMapReference from erc7730.model.input.v2.context import ( InputContract, InputContractContext, @@ -37,7 +38,16 @@ ) 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 import ( + ROOT_DATA_PATH, + Array, + ArrayElement, + ArraySlice, + ContainerPath, + DataPath, + DescriptorPath, + 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 @@ -79,15 +89,23 @@ class ERC7730InputToResolved(ERC7730Converter[InputERC7730Descriptor, ResolvedER """ @override - def convert(self, descriptor: InputERC7730Descriptor, out: OutputAdder) -> ResolvedERC7730Descriptor | None: + def convert( + self, descriptor: InputERC7730Descriptor, out: OutputAdder, *, strict_maps: bool = False + ) -> ResolvedERC7730Descriptor | None: with ExceptionsToOutput(out): constants = DefaultConstantProvider(descriptor) - if (context := self._resolve_context(descriptor.context, out)) is None: + if (context := self._resolve_context(descriptor.context, constants, out, strict_maps=strict_maps)) is None: return None - if (metadata := self._resolve_metadata(descriptor.metadata, out)) is None: + if ( + metadata := self._resolve_metadata(descriptor.metadata, constants, out, strict_maps=strict_maps) + ) is None: return None - if (display := self._resolve_display(descriptor.display, context, metadata.enums, constants, out)) is None: + if ( + display := self._resolve_display( + descriptor.display, context, metadata.enums, constants, out, strict_maps=strict_maps + ) + ) is None: return None return ResolvedERC7730Descriptor.model_validate( @@ -105,18 +123,30 @@ def convert(self, descriptor: InputERC7730Descriptor, out: OutputAdder) -> Resol @classmethod def _resolve_context( - cls, context: InputContractContext | InputEIP712Context, out: OutputAdder + cls, + context: InputContractContext | InputEIP712Context, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedContractContext | ResolvedEIP712Context | None: match context: case InputContractContext(): return cls._resolve_context_contract(context, out) case InputEIP712Context(): - return cls._resolve_context_eip712(context, out) + return cls._resolve_context_eip712(context, constants, out, strict_maps=strict_maps) case _: assert_never(context) @classmethod - def _resolve_metadata(cls, metadata: InputMetadata, out: OutputAdder) -> ResolvedMetadata | None: + def _resolve_metadata( + cls, + metadata: InputMetadata, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, + ) -> ResolvedMetadata | None: resolved_enums = {} if metadata.enums is not None: for enum_id, enum in metadata.enums.items(): @@ -130,7 +160,6 @@ def _resolve_metadata(cls, metadata: InputMetadata, out: OutputAdder) -> Resolve {"$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( @@ -140,9 +169,20 @@ def _resolve_metadata(cls, metadata: InputMetadata, out: OutputAdder) -> Resolve url=metadata.info.url, ) + resolved_owner = cls._resolve_string_or_map( + metadata.owner, "owner", constants, out, strict_maps=strict_maps, allow_data_path_in_key=False + ) + if strict_maps and isinstance(metadata.owner, InputMapReference): + return None + resolved_contract_name = cls._resolve_string_or_map( + metadata.contractName, "contractName", constants, out, strict_maps=strict_maps, allow_data_path_in_key=False + ) + if strict_maps and isinstance(metadata.contractName, InputMapReference): + return None + return ResolvedMetadata( - owner=metadata.owner, - contractName=metadata.contractName, + owner=resolved_owner, + contractName=resolved_contract_name, info=resolved_info, token=metadata.token, constants=metadata.constants, @@ -166,6 +206,43 @@ def _resolve_enum(cls, enum: HttpUrl | EnumDefinition, out: OutputAdder) -> dict case _: assert_never(enum) + @classmethod + def _resolve_string_or_map( + cls, + value: DescriptorPath | str | InputMapReference | None, + field_name: str, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, + allow_data_path_in_key: bool = True, + ) -> str | None: + """Resolve a string field that may be a literal, a constant reference, or a map reference. + + :param allow_data_path_in_key: if False, reject structured data paths (#.) in the map keyPath. + Should be False for fields in the context and metadata sections where data paths have no meaning. + """ + if value is None: + return None + if isinstance(value, InputMapReference): + if not allow_data_path_in_key and isinstance(value.keyPath, DataPath): + out.error( + title="Invalid map keyPath", + message=f"Map reference for {field_name} uses a structured data path " + f'"{value.keyPath}" as keyPath, but structured data paths are not valid in ' + "context/metadata sections. Use a container path (@.) or descriptor path ($.) instead.", + ) + constants.resolve_map_reference(ROOT_DATA_PATH, value, out) + if strict_maps: + out.error( + title="Unsupported map reference", + message=f"Map references are not yet supported for {field_name}. Map at {value.map} with " + f"keyPath {value.keyPath} cannot be resolved.", + ) + return None + resolved = constants.resolve(value, out) + return str(resolved) if resolved is not None else None + @classmethod def _resolve_context_contract( cls, context: InputContractContext, out: OutputAdder @@ -214,17 +291,31 @@ def _resolve_factory(cls, factory: InputFactory, out: OutputAdder) -> ResolvedFa 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: + def _resolve_context_eip712( + cls, + context: InputEIP712Context, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, + ) -> ResolvedEIP712Context | None: + if (eip712 := cls._resolve_eip712(context.eip712, constants, out, strict_maps=strict_maps)) is None: return None return ResolvedEIP712Context.model_validate({"$id": context.id, "eip712": eip712}) @classmethod - def _resolve_eip712(cls, eip712: InputEIP712, out: OutputAdder) -> ResolvedEIP712 | None: + def _resolve_eip712( + cls, + eip712: InputEIP712, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, + ) -> ResolvedEIP712 | None: if eip712.domain is None: domain = None - elif (domain := cls._resolve_domain(eip712.domain, out)) is None: + elif (domain := cls._resolve_domain(eip712.domain, constants, out, strict_maps=strict_maps)) is None: return None # Note: In v2, schemas field is deprecated and ignored during resolution @@ -238,10 +329,28 @@ def _resolve_eip712(cls, eip712: InputEIP712, out: OutputAdder) -> ResolvedEIP71 ) @classmethod - def _resolve_domain(cls, domain: InputDomain, out: OutputAdder) -> ResolvedDomain | None: + def _resolve_domain( + cls, + domain: InputDomain, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, + ) -> ResolvedDomain | None: + resolved_name = cls._resolve_string_or_map( + domain.name, "domain.name", constants, out, strict_maps=strict_maps, allow_data_path_in_key=False + ) + if strict_maps and isinstance(domain.name, InputMapReference): + return None + resolved_version = cls._resolve_string_or_map( + domain.version, "domain.version", constants, out, strict_maps=strict_maps, allow_data_path_in_key=False + ) + if strict_maps and isinstance(domain.version, InputMapReference): + return None + return ResolvedDomain( - name=domain.name, - version=domain.version, + name=resolved_name, + version=resolved_version, chainId=domain.chainId, verifyingContract=None if domain.verifyingContract is None else Address(domain.verifyingContract), salt=domain.salt, @@ -255,6 +364,8 @@ def _resolve_display( enums: dict[Id, EnumDefinition] | None, constants: ConstantProvider, out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedDisplay | None: definitions = display.definitions or {} enums = enums or {} @@ -262,7 +373,11 @@ def _resolve_display( 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: + if ( + resolved_format := cls._resolve_format( + format, definitions, enums, constants, out, strict_maps=strict_maps + ) + ) is None: return None if resolved_format_id in formats: return out.error( @@ -281,6 +396,8 @@ def _resolve_field_description( enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedFieldDescription | None: match definition.format: case None | FieldFormat.RAW | FieldFormat.AMOUNT | FieldFormat.TOKEN_AMOUNT | FieldFormat.DURATION: @@ -306,9 +423,13 @@ def _resolve_field_description( case _: assert_never(definition.format) - params = resolve_field_parameters(prefix, definition.params, enums, constants, out) + params = resolve_field_parameters(prefix, definition.params, enums, constants, out, strict_maps=strict_maps) - if (value_or_path := resolve_field_value(prefix, definition, definition.format, constants, out)) is None: + if ( + value_or_path := resolve_field_value( + prefix, definition, definition.format, constants, out, strict_maps=strict_maps + ) + ) is None: return None # Convert InputEncryptionParameters to ResolvedEncryptionParameters if present @@ -339,16 +460,32 @@ def _resolve_field_description( else: resolved_visible = definition.visible - if definition.label is None and not is_field_hidden(resolved_visible): + resolved_label: str | None = None + if definition.label is not None: + if isinstance(definition.label, InputMapReference): + constants.resolve_map_reference(prefix, definition.label, out) + if strict_maps: + return out.error( + title="Unsupported map reference", + message=f"Map references are not yet supported for label. Map at {definition.label.map} " + f"with keyPath {definition.label.keyPath} cannot be resolved.", + ) + else: + resolved_label = constants.resolve(definition.label, out) + + if ( + resolved_label is None + and not is_field_hidden(resolved_visible) + and (definition.label is None or not isinstance(definition.label, InputMapReference)) + ): return out.error( title="Missing display field label", message=f'Label must be defined on the display field for path "{definition.path}".', ) - # 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 + if params_dict is not None and not params_dict: + params_dict = None encryption_dict = ( resolved_encryption.model_dump(by_alias=True, exclude_none=True) if resolved_encryption is not None @@ -358,7 +495,7 @@ def _resolve_field_description( field_dict: dict[str, Any] = { "$id": definition.id, "visible": resolved_visible, - "label": constants.resolve(definition.label, out) if definition.label is not None else None, + "label": resolved_label, "format": FieldFormat(definition.format) if definition.format is not None else None, "params": params_dict, "separator": definition.separator, @@ -412,15 +549,27 @@ def _resolve_format( enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedFormat | None: - if (fields := cls._resolve_fields(ROOT_DATA_PATH, format.fields, definitions, enums, constants, out)) is None: + if ( + fields := cls._resolve_fields( + ROOT_DATA_PATH, format.fields, definitions, enums, constants, out, strict_maps=strict_maps + ) + ) is None: + return None + + resolved_interpolated_intent = cls._resolve_string_or_map( + format.interpolatedIntent, "interpolatedIntent", constants, out, strict_maps=strict_maps + ) + if strict_maps and isinstance(format.interpolatedIntent, InputMapReference): return None return ResolvedFormat.model_validate( { "$id": format.id, "intent": format.intent, - "interpolatedIntent": format.interpolatedIntent, + "interpolatedIntent": resolved_interpolated_intent, "fields": [f.model_dump(by_alias=True, exclude_none=True) for f in fields], } ) @@ -434,10 +583,16 @@ def _resolve_fields( enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, + *, + strict_maps: bool = False, ) -> 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: + if ( + resolved_field := cls._resolve_field( + prefix, input_format, definitions, enums, constants, out, strict_maps=strict_maps + ) + ) is None: return None resolved_fields.extend(resolved_field) return resolved_fields @@ -451,20 +606,32 @@ def _resolve_field( enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, + *, + strict_maps: bool = False, ) -> list[ResolvedField] | None: resolved_fields: list[ResolvedField] = [] match field: case InputReference(): - if (resolved_field := resolve_reference(prefix, field, definitions, enums, constants, out)) is None: + if ( + resolved_field := resolve_reference( + prefix, field, definitions, enums, constants, out, strict_maps=strict_maps + ) + ) 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: + if ( + resolved_field := cls._resolve_field_description( + prefix, field, enums, constants, out, strict_maps=strict_maps + ) + ) 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) + resolved_field_group := cls._resolve_field_group( + prefix, field, definitions, enums, constants, out, strict_maps=strict_maps + ) ) is None: return None resolved_fields.extend(resolved_field_group) @@ -481,9 +648,10 @@ def _resolve_field_group( enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, + *, + strict_maps: bool = False, ) -> 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, @@ -492,6 +660,7 @@ def _resolve_field_group( enums=enums, constants=constants, out=out, + strict_maps=strict_maps, ) ) is None: return None @@ -522,7 +691,13 @@ def _resolve_field_group( if ( resolved_fields := cls._resolve_fields( - prefix=path, fields=group.fields, definitions=definitions, enums=enums, constants=constants, out=out + prefix=path, + fields=group.fields, + definitions=definitions, + enums=enums, + constants=constants, + out=out, + strict_maps=strict_maps, ) ) is None: return None diff --git a/src/erc7730/convert/resolved/v2/parameters.py b/src/erc7730/convert/resolved/v2/parameters.py index 2252d70e..0181c997 100644 --- a/src/erc7730/convert/resolved/v2/parameters.py +++ b/src/erc7730/convert/resolved/v2/parameters.py @@ -6,6 +6,7 @@ from erc7730.convert.resolved.v2.enums import get_enum, get_enum_id from erc7730.convert.resolved.v2.values import resolve_path_or_constant_value from erc7730.model.input.path import DescriptorPathStr +from erc7730.model.input.v2.common import InputMapReference from erc7730.model.input.v2.display import ( InputAddressNameParameters, InputCallDataParameters, @@ -14,7 +15,6 @@ InputEnumParameters, InputFieldParameters, InputInteroperableAddressNameParameters, - InputMapReference, InputNftNameParameters, InputTokenAmountParameters, InputTokenTickerParameters, @@ -40,28 +40,56 @@ from erc7730.model.types import Address, HexStr, Id, MixedCaseAddress +def _handle_map_reference( + map_ref: InputMapReference, + param_name: str, + prefix: DataPath, + constants: ConstantProvider, + strict_maps: bool, + out: OutputAdder, +) -> None: + """Validate and handle a map reference. + + Always validates that the map exists in metadata.maps and the keyPath is valid. + In strict mode (calldata/convert), emits an error. In lenient mode (lint), the + value will be dropped after validation. + """ + constants.resolve_map_reference(prefix, map_ref, out) + + if strict_maps: + out.error( + title="Unsupported map reference", + message=f"Map references are not yet supported for {param_name}. Map at {map_ref.map} with " + f"keyPath {map_ref.keyPath} cannot be resolved.", + ) + + def resolve_field_parameters( prefix: DataPath, params: InputFieldParameters | None, enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedFieldParameters | None: match params: case None: return None case InputAddressNameParameters(): - return resolve_address_name_parameters(prefix, params, constants, out) + return resolve_address_name_parameters(prefix, params, constants, out, strict_maps=strict_maps) case InputInteroperableAddressNameParameters(): - return resolve_interoperable_address_name_parameters(prefix, params, constants, out) + return resolve_interoperable_address_name_parameters( + prefix, params, constants, out, strict_maps=strict_maps + ) case InputCallDataParameters(): - return resolve_calldata_parameters(prefix, params, constants, out) + return resolve_calldata_parameters(prefix, params, constants, out, strict_maps=strict_maps) case InputTokenAmountParameters(): - return resolve_token_amount_parameters(prefix, params, constants, out) + return resolve_token_amount_parameters(prefix, params, constants, out, strict_maps=strict_maps) case InputTokenTickerParameters(): - return resolve_token_ticker_parameters(prefix, params, constants, out) + return resolve_token_ticker_parameters(prefix, params, constants, out, strict_maps=strict_maps) case InputNftNameParameters(): - return resolve_nft_parameters(prefix, params, constants, out) + return resolve_nft_parameters(prefix, params, constants, out, strict_maps=strict_maps) case InputDateParameters(): return resolve_date_parameters(prefix, params, constants, out) case InputUnitParameters(): @@ -73,17 +101,19 @@ def resolve_field_parameters( def resolve_address_name_parameters( - prefix: DataPath, params: InputAddressNameParameters, constants: ConstantProvider, out: OutputAdder + prefix: DataPath, + params: InputAddressNameParameters, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedAddressNameParameters | None: sender_address: list[Address] | None = None if (sender_addr_input := params.senderAddress) is not None: - # InputMapReference is passed through to resolved model for runtime resolution if isinstance(sender_addr_input, InputMapReference): - # Map references in senderAddress cannot be resolved at conversion time - out.warning( - title="Unresolved map reference", - message="Map reference in senderAddress cannot be resolved at conversion time and will be dropped.", - ) + _handle_map_reference(sender_addr_input, "senderAddress", prefix, constants, strict_maps, out) + if strict_maps: + return None sender_address = None else: resolved_sender = constants.resolve_or_none(sender_addr_input, out) @@ -104,16 +134,19 @@ def resolve_address_name_parameters( def resolve_interoperable_address_name_parameters( - prefix: DataPath, params: InputInteroperableAddressNameParameters, constants: ConstantProvider, out: OutputAdder + prefix: DataPath, + params: InputInteroperableAddressNameParameters, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedInteroperableAddressNameParameters | None: sender_address: list[Address] | None = None if (sender_addr_input := params.senderAddress) is not None: - # InputMapReference is passed through - similar to address_name if isinstance(sender_addr_input, InputMapReference): - out.warning( - title="Unresolved map reference", - message="Map reference in senderAddress cannot be resolved at conversion time and will be dropped.", - ) + _handle_map_reference(sender_addr_input, "senderAddress", prefix, constants, strict_maps, out) + if strict_maps: + return None sender_address = None else: resolved_sender = constants.resolve_or_none(sender_addr_input, out) @@ -134,15 +167,16 @@ def resolve_interoperable_address_name_parameters( def resolve_calldata_parameters( - prefix: DataPath, params: InputCallDataParameters, constants: ConstantProvider, out: OutputAdder + prefix: DataPath, + params: InputCallDataParameters, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedCallDataParameters | None: - # Resolve callee - can be path, constant, or map reference callee_resolved = None if params.callee is not None and isinstance(params.callee, InputMapReference): - out.warning( - title="Unresolved map reference", - message="Map reference in callee cannot be resolved at conversion time and will be dropped.", - ) + _handle_map_reference(params.callee, "callee", prefix, constants, strict_maps, out) return None elif params.callee is not None or params.calleePath is not None: callee_resolved = resolve_path_or_constant_value( @@ -156,13 +190,11 @@ def resolve_calldata_parameters( if callee_resolved is None: return None - # Resolve selector selector_resolved = None if params.selector is not None and isinstance(params.selector, InputMapReference): - out.warning( - title="Unresolved map reference", - message="Map reference in selector cannot be resolved at conversion time and will be dropped.", - ) + _handle_map_reference(params.selector, "selector", prefix, constants, strict_maps, out) + if strict_maps: + return None elif params.selector is not None or params.selectorPath is not None: selector_resolved = resolve_path_or_constant_value( prefix=prefix, @@ -173,13 +205,11 @@ def resolve_calldata_parameters( out=out, ) - # Resolve amount amount_resolved = None if params.amount is not None and isinstance(params.amount, InputMapReference): - out.warning( - title="Unresolved map reference", - message="Map reference in amount cannot be resolved at conversion time and will be dropped.", - ) + _handle_map_reference(params.amount, "amount", prefix, constants, strict_maps, out) + if strict_maps: + return None elif params.amount is not None or params.amountPath is not None: amount_resolved = resolve_path_or_constant_value( prefix=prefix, @@ -190,13 +220,11 @@ def resolve_calldata_parameters( out=out, ) - # Resolve spender spender_resolved = None if params.spender is not None and isinstance(params.spender, InputMapReference): - out.warning( - title="Unresolved map reference", - message="Map reference in spender cannot be resolved at conversion time and will be dropped.", - ) + _handle_map_reference(params.spender, "spender", prefix, constants, strict_maps, out) + if strict_maps: + return None elif params.spender is not None or params.spenderPath is not None: spender_resolved = resolve_path_or_constant_value( prefix=prefix, @@ -216,17 +244,19 @@ def resolve_calldata_parameters( def resolve_token_amount_parameters( - prefix: DataPath, params: InputTokenAmountParameters, constants: ConstantProvider, out: OutputAdder + prefix: DataPath, + params: InputTokenAmountParameters, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedTokenAmountParameters | None: - # Resolve token into a ResolvedValue (path or constant), like v1. token_value = params.token token_resolved = None if token_value is not None and isinstance(token_value, InputMapReference): - # Map reference needs runtime resolution and is dropped in resolved model - out.warning( - title="Unresolved map reference", - message="Map reference in token cannot be resolved at conversion time and will be dropped.", - ) + _handle_map_reference(token_value, "token", prefix, constants, strict_maps, out) + if strict_maps: + return None else: token_resolved = resolve_path_or_constant_value( prefix=prefix, @@ -237,42 +267,65 @@ def resolve_token_amount_parameters( out=out, ) - input_addresses = cast( - list[DescriptorPathStr | MixedCaseAddress] | MixedCaseAddress | None, - constants.resolve_or_none(params.nativeCurrencyAddress, out), - ) resolved_addresses: list[Address] | None - if input_addresses is None: + if isinstance(params.nativeCurrencyAddress, InputMapReference): + _handle_map_reference( + params.nativeCurrencyAddress, "nativeCurrencyAddress", prefix, constants, strict_maps, out + ) + if strict_maps: + return None resolved_addresses = None - elif isinstance(input_addresses, list): - resolved_addresses = [] - for input_address in input_addresses: - if (resolved_address := constants.resolve(input_address, out)) is None: - return None - resolved_addresses.append(Address(resolved_address)) - elif isinstance(input_addresses, str): - resolved_addresses = [Address(input_addresses)] else: - raise Exception("Invalid nativeCurrencyAddress type") + input_addresses = cast( + list[DescriptorPathStr | MixedCaseAddress] | MixedCaseAddress | None, + constants.resolve_or_none(params.nativeCurrencyAddress, out), + ) + if input_addresses is None: + resolved_addresses = None + elif isinstance(input_addresses, list): + resolved_addresses = [] + for input_address in input_addresses: + if (resolved_address := constants.resolve(input_address, out)) is None: + return None + resolved_addresses.append(Address(resolved_address)) + elif isinstance(input_addresses, str): + resolved_addresses = [Address(input_addresses)] + else: + raise Exception("Invalid nativeCurrencyAddress type") - input_threshold = cast(HexStr | int | None, constants.resolve_or_none(params.threshold, out)) - resolved_threshold: HexStr | None - if input_threshold is not None: - if isinstance(input_threshold, int): - resolved_threshold = "0x" + input_threshold.to_bytes(byteorder="big", signed=False).hex() + if isinstance(params.threshold, InputMapReference): + _handle_map_reference(params.threshold, "threshold", prefix, constants, strict_maps, out) + if strict_maps: + return None + resolved_threshold: HexStr | None = None + else: + input_threshold = cast(HexStr | int | None, constants.resolve_or_none(params.threshold, out)) + if input_threshold is not None: + if isinstance(input_threshold, int): + resolved_threshold = "0x" + input_threshold.to_bytes(byteorder="big", signed=False).hex() + else: + resolved_threshold = input_threshold else: - resolved_threshold = input_threshold + resolved_threshold = None + + if isinstance(params.message, InputMapReference): + _handle_map_reference(params.message, "message", prefix, constants, strict_maps, out) + if strict_maps: + return None + resolved_message: str | None = None else: - resolved_threshold = None + resolved_message = constants.resolve_or_none(params.message, out) - # Resolve chainId - can be int, descriptor path, or map reference chain_id_value = params.chainId resolved_chain_id: int | None = None - if chain_id_value is not None and not isinstance(chain_id_value, InputMapReference): + if chain_id_value is not None and isinstance(chain_id_value, InputMapReference): + _handle_map_reference(chain_id_value, "chainId", prefix, constants, strict_maps, out) + if strict_maps: + return None + elif chain_id_value is not None: if isinstance(chain_id_value, int): resolved_chain_id = chain_id_value else: - # Descriptor path resolved_value: Any = constants.resolve(chain_id_value, out) if isinstance(resolved_value, int): resolved_chain_id = resolved_value @@ -281,23 +334,30 @@ def resolve_token_amount_parameters( token=token_resolved, nativeCurrencyAddress=resolved_addresses, threshold=resolved_threshold, - message=constants.resolve_or_none(params.message, out), + message=resolved_message, chainId=resolved_chain_id, chainIdPath=params.chainIdPath, ) def resolve_token_ticker_parameters( - prefix: DataPath, params: InputTokenTickerParameters, constants: ConstantProvider, out: OutputAdder + prefix: DataPath, + params: InputTokenTickerParameters, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedTokenTickerParameters | None: - # Resolve chainId - can be int, descriptor path, or map reference chain_id_value = params.chainId resolved_chain_id: int | None = None - if chain_id_value is not None and not isinstance(chain_id_value, InputMapReference): + if chain_id_value is not None and isinstance(chain_id_value, InputMapReference): + _handle_map_reference(chain_id_value, "chainId", prefix, constants, strict_maps, out) + if strict_maps: + return None + elif chain_id_value is not None: if isinstance(chain_id_value, int): resolved_chain_id = chain_id_value else: - # Descriptor path resolved_value: Any = constants.resolve(chain_id_value, out) if isinstance(resolved_value, int): resolved_chain_id = resolved_value @@ -316,16 +376,16 @@ def resolve_token_ticker_parameters( def resolve_nft_parameters( - prefix: DataPath, params: InputNftNameParameters, constants: ConstantProvider, out: OutputAdder + prefix: DataPath, + params: InputNftNameParameters, + constants: ConstantProvider, + out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedNftNameParameters | None: - # Resolve collection - can be path, constant, or map reference collection_value = params.collection if collection_value is not None and isinstance(collection_value, InputMapReference): - # Map reference - needs runtime resolution - out.warning( - title="Unresolved map reference", - message="Map reference in collection cannot be resolved at conversion time and will be dropped.", - ) + _handle_map_reference(collection_value, "collection", prefix, constants, strict_maps, out) return None else: collection_resolved = resolve_path_or_constant_value( diff --git a/src/erc7730/convert/resolved/v2/references.py b/src/erc7730/convert/resolved/v2/references.py index e2d8c25c..78dfe07e 100644 --- a/src/erc7730/convert/resolved/v2/references.py +++ b/src/erc7730/convert/resolved/v2/references.py @@ -9,6 +9,7 @@ from erc7730.convert.resolved.v2.constants import ConstantProvider from erc7730.convert.resolved.v2.parameters import resolve_field_parameters from erc7730.convert.resolved.v2.values import resolve_field_value +from erc7730.model.input.v2.common import InputMapReference from erc7730.model.input.v2.display import ( InputFieldDefinition, InputFieldParameters, @@ -38,6 +39,8 @@ def resolve_reference( enums: dict[Id, EnumDefinition], constants: ConstantProvider, out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedField | None: if (definition := _get_definition(reference.ref, definitions, out)) is None: return None @@ -62,17 +65,37 @@ def resolve_reference( if params: input_params: InputFieldParameters = TypeAdapter(InputFieldParameters).validate_json(json.dumps(params)) - if (resolved_params := resolve_field_parameters(prefix, input_params, enums, constants, out)) is None: + if ( + resolved_params := resolve_field_parameters( + prefix, input_params, enums, constants, out, strict_maps=strict_maps + ) + ) is None: return None - if (value_or_path := resolve_field_value(prefix, reference, definition.format, constants, out)) is None: + if ( + value_or_path := resolve_field_value( + prefix, reference, definition.format, constants, out, strict_maps=strict_maps + ) + ) is None: return None encryption = first_not_none(reference.encryption, definition.encryption) - # Build field dict for model_validate to handle aliases and discriminated unions + resolved_label: str | None = None + if label is not None: + if isinstance(label, InputMapReference): + constants.resolve_map_reference(prefix, label, out) + if strict_maps: + return out.error( + title="Unsupported map reference", + message=f"Map references are not yet supported for label. Map at {label.map} with " + f"keyPath {label.keyPath} cannot be resolved.", + ) + else: + resolved_label = str(constants.resolve(label, out)) + field_dict: dict[str, Any] = { - "label": str(constants.resolve(label, out)) if label is not None else None, + "label": resolved_label, "format": FieldFormat(definition.format) if definition.format is not None else None, "visible": resolved_visible, "separator": reference.separator, diff --git a/src/erc7730/convert/resolved/v2/values.py b/src/erc7730/convert/resolved/v2/values.py index 55ef0739..5f643538 100644 --- a/src/erc7730/convert/resolved/v2/values.py +++ b/src/erc7730/convert/resolved/v2/values.py @@ -5,6 +5,7 @@ from erc7730.common.abi import ABIDataType from erc7730.common.output import OutputAdder from erc7730.convert.resolved.v2.constants import ConstantProvider +from erc7730.model.input.v2.common import InputMapReference from erc7730.model.input.v2.display import InputFieldBase from erc7730.model.input.v2.format import FieldFormat from erc7730.model.paths import ContainerPath, DataPath, DescriptorPath @@ -19,6 +20,8 @@ def resolve_field_value( input_field_format: FieldFormat | None, constants: ConstantProvider, out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedValue | None: """ Resolve value, as a data path or constant value, for a field or reference. @@ -62,6 +65,7 @@ def resolve_field_value( abi_type=abi_type, constants=constants, out=out, + strict_maps=strict_maps, ) ) is None: return out.error(title="Invalid field", message="Field must have either a path or a value.") @@ -71,10 +75,12 @@ def resolve_field_value( def resolve_path_or_constant_value( prefix: DataPath, input_path: DescriptorPath | DataPath | ContainerPath | None, - input_value: DescriptorPath | ScalarType | None, + input_value: DescriptorPath | ScalarType | InputMapReference | None, abi_type: ABIDataType, constants: ConstantProvider, out: OutputAdder, + *, + strict_maps: bool = False, ) -> ResolvedValue | None: """ Resolve value, as a data path or constant value. @@ -87,6 +93,16 @@ def resolve_path_or_constant_value( :param out: error handler :return: resolved value or None if error or value resolves to None """ + if isinstance(input_value, InputMapReference): + constants.resolve_map_reference(prefix, input_value, out) + if strict_maps: + return out.error( + title="Unsupported map reference", + message=f"Map references are not yet supported. Map at {input_value.map} with " + f"keyPath {input_value.keyPath} cannot be resolved.", + ) + return None + if input_path is not None: if input_value is not None: return out.error( diff --git a/src/erc7730/lint/v2/lint.py b/src/erc7730/lint/v2/lint.py index 5f0cd630..559aec10 100644 --- a/src/erc7730/lint/v2/lint.py +++ b/src/erc7730/lint/v2/lint.py @@ -85,6 +85,6 @@ def lint_file(path: Path, linter: ERC7730Linter, out: OutputAdder, show_as: Path with BufferAdder(file_out, prolog=f"➡️ checking [bold]{label}[/bold]…", epilog="") as out, ExceptionsToOutput(out): input_descriptor = InputERC7730Descriptor.load(path) - resolved_descriptor = ERC7730InputToResolved().convert(input_descriptor, out) + resolved_descriptor = ERC7730InputToResolved().convert(input_descriptor, out, strict_maps=False) if resolved_descriptor is not None: linter.lint(resolved_descriptor, out) diff --git a/src/erc7730/model/input/v2/common.py b/src/erc7730/model/input/v2/common.py new file mode 100644 index 00000000..6e0f6533 --- /dev/null +++ b/src/erc7730/model/input/v2/common.py @@ -0,0 +1,29 @@ +""" +Shared types for ERC-7730 v2 input descriptors. + +This module contains types used across multiple v2 input modules (display, context, metadata) +to avoid circular imports. +""" + +from pydantic import Field + +from erc7730.model.base import Model +from erc7730.model.input.path import ContainerPathStr, DataPathStr, DescriptorPathStr + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class InputMapReference(Model): + """ + A reference to a map for dynamic value resolution. + """ + + map: DescriptorPathStr = Field( + title="Map Reference", + description="The path to the referenced map.", + ) + + keyPath: DescriptorPathStr | DataPathStr | ContainerPathStr = Field( + title="Key Path", + description="The path to the key used to resolve a value in the referenced map.", + ) diff --git a/src/erc7730/model/input/v2/context.py b/src/erc7730/model/input/v2/context.py index dc5bb473..11e63adf 100644 --- a/src/erc7730/model/input/v2/context.py +++ b/src/erc7730/model/input/v2/context.py @@ -9,6 +9,8 @@ from pydantic_string_url import HttpUrl from erc7730.model.base import Model +from erc7730.model.input.path import DescriptorPathStr +from erc7730.model.input.v2.common import InputMapReference from erc7730.model.types import Id, MixedCaseAddress # ruff: noqa: N815 - camel case field names are tolerated to match schema @@ -21,9 +23,13 @@ class InputDomain(Model): Each value of the domain constraint MUST match the corresponding eip 712 message domain value. """ - name: str | None = Field(default=None, title="Name", description="The EIP-712 domain name.") + name: DescriptorPathStr | str | InputMapReference | None = Field( + default=None, title="Name", description="The EIP-712 domain name." + ) - version: str | None = Field(default=None, title="Version", description="The EIP-712 version.") + version: DescriptorPathStr | str | InputMapReference | None = Field( + default=None, title="Version", description="The EIP-712 version." + ) chainId: int | None = Field(default=None, title="Chain ID", description="The EIP-155 chain id.") diff --git a/src/erc7730/model/input/v2/display.py b/src/erc7730/model/input/v2/display.py index e9ecda91..4d160e06 100644 --- a/src/erc7730/model/input/v2/display.py +++ b/src/erc7730/model/input/v2/display.py @@ -15,6 +15,7 @@ FormatBase, ) from erc7730.model.input.path import ContainerPathStr, DataPathStr, DescriptorPathStr +from erc7730.model.input.v2.common import InputMapReference from erc7730.model.input.v2.format import DateEncoding, FieldFormat from erc7730.model.input.v2.unions import ( field_discriminator, @@ -56,22 +57,6 @@ def _validate_at_least_one_condition(self) -> Self: ] -class InputMapReference(Model): - """ - A reference to a map for dynamic value resolution. - """ - - map: DescriptorPathStr = Field( - title="Map Reference", - description="The path to the referenced map.", - ) - - keyPath: DescriptorPathStr | DataPathStr | ContainerPathStr = Field( - title="Key Path", - description="The path to the key used to resolve a value in the referenced map.", - ) - - class InputFieldBase(Model): """ A field formatter, containing formatting information of a single field in a message. @@ -84,7 +69,7 @@ class InputFieldBase(Model): """to extract the field value from the structured data. Exactly one of "path" or "value" must be set.""", ) - value: DescriptorPathStr | ScalarType | None = Field( + value: DescriptorPathStr | ScalarType | InputMapReference | None = Field( default=None, title="Value", description="A literal value on which the format should be applied instead of looking up a field in the " @@ -115,7 +100,7 @@ class InputReference(InputFieldBase): "key in the display definitions section, as a path expression $.display.definitions.DEFINITION_NAME.", ) - label: DescriptorPathStr | str | None = Field( + label: DescriptorPathStr | str | InputMapReference | None = Field( default=None, title="Field Label", description="Overrides the label in the referenced definition if set.", @@ -170,23 +155,23 @@ class InputTokenAmountParameters(Model): ), ) - nativeCurrencyAddress: list[DescriptorPathStr | MixedCaseAddress] | DescriptorPathStr | MixedCaseAddress | None = ( - Field( - default=None, - title="Native Currency Address", - description="An address or array of addresses, any of which are interpreted as an amount in native " - "currency rather than a token.", - ) + nativeCurrencyAddress: ( + list[DescriptorPathStr | MixedCaseAddress] | DescriptorPathStr | MixedCaseAddress | InputMapReference | None + ) = Field( + default=None, + title="Native Currency Address", + description="An address or array of addresses, any of which are interpreted as an amount in native " + "currency rather than a token.", ) - threshold: DescriptorPathStr | HexStr | int | None = Field( + threshold: DescriptorPathStr | HexStr | int | InputMapReference | None = Field( default=None, title="Unlimited Threshold", description="The threshold above which the amount should be displayed using the message parameter rather than " "the real amount (encoded as an int or byte array).", ) - message: DescriptorPathStr | str | None = Field( + message: DescriptorPathStr | str | InputMapReference | None = Field( default=None, title="Unlimited Message", description="The message to display when the amount is above the threshold.", @@ -513,7 +498,7 @@ class InputFieldDefinition(Model): "reference in device specific sections.", ) - label: DescriptorPathStr | str | None = Field( + label: DescriptorPathStr | str | InputMapReference | None = Field( default=None, title="Field Label", description="The label of the field, that will be displayed to the user in front of the formatted field value. " @@ -628,7 +613,7 @@ class InputFormat(FormatBase): in a single type of message (v2). """ - interpolatedIntent: str | None = Field( + interpolatedIntent: DescriptorPathStr | str | InputMapReference | None = Field( default=None, title="Interpolated Intent Message", description=( diff --git a/src/erc7730/model/input/v2/metadata.py b/src/erc7730/model/input/v2/metadata.py index 784f32d7..c021cbd9 100644 --- a/src/erc7730/model/input/v2/metadata.py +++ b/src/erc7730/model/input/v2/metadata.py @@ -12,6 +12,8 @@ from pydantic_string_url import HttpUrl from erc7730.model.base import Model +from erc7730.model.input.path import DescriptorPathStr +from erc7730.model.input.v2.common import InputMapReference from erc7730.model.metadata import TokenInfo from erc7730.model.resolved.metadata import EnumDefinition from erc7730.model.types import Id, ScalarType @@ -90,13 +92,13 @@ class InputMetadata(Model): message (as matched by the `context` section) """ - owner: str | None = Field( + owner: DescriptorPathStr | str | InputMapReference | None = Field( default=None, title="Owner display name.", description="The display name of the owner or target of the contract / message to be clear signed.", ) - contractName: str | None = Field( + contractName: DescriptorPathStr | str | InputMapReference | None = Field( default=None, title="Contract Name", description="The name of the contract targeted by the transaction or message.", diff --git a/src/erc7730/model/paths/__init__.py b/src/erc7730/model/paths/__init__.py index 81be9803..635bfe5c 100644 --- a/src/erc7730/model/paths/__init__.py +++ b/src/erc7730/model/paths/__init__.py @@ -123,6 +123,9 @@ class ContainerField(StrEnum): TO = auto() """The destination address of the containing transaction, ie the target smart contract address.""" + CHAINID = "chainId" + """The chain ID of the transaction / verifying contract.""" + DataPathElement = Annotated[ Field | ArrayElement | ArraySlice | Array, diff --git a/src/erc7730/model/paths/path_parser.py b/src/erc7730/model/paths/path_parser.py index b9f13638..ce2b1afe 100644 --- a/src/erc7730/model/paths/path_parser.py +++ b/src/erc7730/model/paths/path_parser.py @@ -25,7 +25,7 @@ ?descriptor_path_component: field | array_element container_path: "@." container_field - !container_field: "from" | "to" | "value" + !container_field: "from" | "to" | "value" | "chainId" ?data_path: absolute_data_path | relative_data_path absolute_data_path: "#." data_path_component ("." data_path_component)*