diff --git a/src/erc7730/convert/calldata/convert_erc7730_input_to_calldata.py b/src/erc7730/convert/calldata/convert_erc7730_input_to_calldata.py index 2a175719..2917bfdd 100644 --- a/src/erc7730/convert/calldata/convert_erc7730_input_to_calldata.py +++ b/src/erc7730/convert/calldata/convert_erc7730_input_to_calldata.py @@ -1,6 +1,6 @@ from pydantic_string_url import HttpUrl -from erc7730.common.output import ConsoleOutputAdder, exception_to_output +from erc7730.common.output import ConsoleOutputAdder, OutputAdder, exception_to_output from erc7730.convert.calldata.v1.descriptor import ( convert_descriptor, ) @@ -11,21 +11,25 @@ def erc7730_descriptor_to_calldata_descriptors( - input_descriptor: InputERC7730Descriptor, source: HttpUrl | None = None, chain_id: int | None = None + input_descriptor: InputERC7730Descriptor, + source: HttpUrl | None = None, + chain_id: int | None = None, + out: OutputAdder | None = None, ) -> list[CalldataDescriptor]: """ Generate output calldata descriptors from input ERC-7730 descriptor with contract context. If descriptor is invalid, an empty list is returned. If the descriptor is partially invalid, a partial list is - returned. Errors are logged as warnings. + returned. Errors are reported to the error handler. :param input_descriptor: input descriptor :param source: source of the descriptor file :param chain_id: if set, only emit calldata descriptors for given chain IDs + :param out: error handler, defaults to printing errors to the console :return: output calldata descriptors (1 per chain + selector) """ - out = ConsoleOutputAdder() + out = ConsoleOutputAdder() if out is None else out try: if not isinstance(input_descriptor.context, InputContractContext): return [] 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 5b7f6d00..2fa53156 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 @@ -24,6 +24,7 @@ from erc7730.common.output import ConsoleOutputAdder, OutputAdder, exception_to_output from erc7730.convert.calldata.v1.abi import ABITree, function_to_abi_tree from erc7730.convert.calldata.v1.enum import convert_enums +from erc7730.convert.calldata.v1.map import MapEntries from erc7730.convert.calldata.v1.path import ( convert_container_path, convert_data_path, @@ -31,7 +32,7 @@ from erc7730.convert.resolved.v2.convert_erc7730_input_to_resolved import ( ERC7730InputToResolved, ) -from erc7730.convert.resolved.v2.values import encode_value +from erc7730.convert.resolved.v2.values import deployment_map_key, encode_value, lookup_map_value from erc7730.model.abi import Function from erc7730.model.calldata.descriptor import ( CalldataDescriptor, @@ -73,7 +74,7 @@ from erc7730.model.input.v2.context import InputContractContext from erc7730.model.input.v2.descriptor import InputERC7730Descriptor from erc7730.model.input.v2.format import DateEncoding, FieldFormat -from erc7730.model.paths import ContainerPath, DataPath +from erc7730.model.paths import ContainerField, ContainerPath, DataPath from erc7730.model.paths.path_parser import to_path from erc7730.model.resolved.display import ResolvedValueConstant, ResolvedValuePath from erc7730.model.resolved.v2.context import ( @@ -87,6 +88,7 @@ ResolvedFieldGroup, ResolvedFormat, ResolvedNftNameParameters, + ResolvedValueMap, ResolvedVisibilityConditions, ) from erc7730.model.types import Address, HexStr, ScalarType, Selector @@ -103,19 +105,21 @@ def erc7730_v2_descriptor_to_calldata_descriptors( input_descriptor: InputERC7730Descriptor, source: HttpUrl | None = None, chain_id: int | None = None, + out: OutputAdder | None = None, ) -> list[CalldataDescriptor]: """ Generate output calldata descriptors from a v2 input ERC-7730 descriptor with contract context. If descriptor is invalid, an empty list is returned. If the descriptor is partially invalid, a partial list is - returned. Errors are logged as warnings. + returned. Errors are reported to the error handler. :param input_descriptor: deserialized v2 input ERC-7730 descriptor :param source: source of the descriptor file :param chain_id: if set, only emit calldata descriptors for given chain IDs + :param out: error handler, defaults to printing errors to the console :return: output calldata descriptors (1 per chain + selector) """ - out = ConsoleOutputAdder() + out = ConsoleOutputAdder() if out is None else out try: if not isinstance(input_descriptor.context, InputContractContext): @@ -230,10 +234,13 @@ def _convert_v2_selector( # Use v1 convert_enums — v2 ResolvedDeployment is duck-type compatible with v1 enums = convert_enums(deployment, selector, descriptor.metadata.enums) # type: ignore[arg-type] enums_by_id = {enum.enum_id: enum.id for enum in enums} + maps = MapEntries(deployment, selector) fields: list[CalldataDescriptorInstructionFieldV1] = [] for input_field in format.fields: - if (output_fields := _convert_v2_field(abi=abi_tree, field=input_field, enums=enums_by_id, out=out)) is None: + if ( + output_fields := _convert_v2_field(abi=abi_tree, field=input_field, enums=enums_by_id, maps=maps, out=out) + ) is None: return None fields.extend(output_fields) @@ -262,6 +269,7 @@ def _convert_v2_selector( selector=selector, transaction_info=transaction_info, enums=enums, + maps=maps.entries, fields=fields, ) @@ -273,6 +281,7 @@ def _convert_v2_field( abi: ABITree, field: ResolvedFieldDescription | ResolvedFieldGroup, enums: dict[str, int], + maps: MapEntries, out: OutputAdder, ) -> list[CalldataDescriptorInstructionFieldV1] | None: """ @@ -290,6 +299,7 @@ def _convert_v2_field( :param abi: function ABI tree :param field: v2 resolved field :param enums: mapping of source descriptor enum ids to calldata descriptor enum ids + :param maps: device side maps of the descriptor, map lookups are registered in it :param out: error handler :return: 1 or more calldata field instructions, or None on error """ @@ -318,7 +328,7 @@ def _convert_v2_field( ) name = field.id or "constraint" - if (param := _convert_v2_param(abi=abi, field=field, enums=enums, out=out)) is None: + if (param := _convert_v2_param(abi=abi, field=field, enums=enums, maps=maps, out=out)) is None: return None # Constraints are compared by the parameter formatter, so they are encoded against the type @@ -363,7 +373,9 @@ def _convert_v2_field( # In v1 protocol, nested fields are flattened instructions: list[CalldataDescriptorInstructionFieldV1] = [] for nested_field in field.fields: - if (nested_instructions := _convert_v2_field(abi=abi, field=nested_field, enums=enums, out=out)) is None: + if ( + nested_instructions := _convert_v2_field(abi=abi, field=nested_field, enums=enums, maps=maps, out=out) + ) is None: return None instructions.extend(nested_instructions) return instructions @@ -669,6 +681,7 @@ def _convert_v2_param( abi: ABITree, field: ResolvedFieldDescription, enums: dict[str, int], + maps: MapEntries, out: OutputAdder, ) -> CalldataDescriptorParamV1 | None: """ @@ -679,6 +692,7 @@ def _convert_v2_param( :param abi: function ABI tree :param field: v2 resolved field description :param enums: mapping of source descriptor enum ids to calldata descriptor enum ids + :param maps: device side maps of the descriptor, map lookups the device has to do are registered in it :param out: error handler :return: calldata protocol field parameter or None on error """ @@ -686,12 +700,60 @@ def _convert_v2_param( if (value := _convert_v2_value(path_str, field.value, field.format, abi, out)) is None: return None + def _deployment_map_key(value_map: ResolvedValueMap) -> str | None: + return deployment_map_key(value_map.keyPath, maps.deployment.chainId, maps.deployment.address) + + def _lookup_deployment_value(value_map: ResolvedValueMap, key: str) -> ResolvedValueConstant | None: + if (map_value := lookup_map_value(value_map.values, key)) is None: + return out.error( + title="Missing map value", + message=f"""Map {value_map.map} has no value for {value_map.keyPath} "{key}".""", + ) + return map_value + + def _check_token_chain(params: object) -> bool: + """ + Check that a token is on the transaction chain: PARAM_TOKEN_AMOUNT and PARAM_TOKEN have no chain id tag, the + device looks the token up on the transaction chain, so a token on another chain would be displayed with a + wrong ticker and decimals. + + :param params: token amount or token ticker parameters + :return: True if the token is on the transaction chain, False on error (reported) + """ + chain_id = getattr(params, "chainId", None) + if isinstance(chain_id, ResolvedValueMap) and (key := _deployment_map_key(chain_id)) is not None: + if (map_value := _lookup_deployment_value(chain_id, key)) is None: + return False + chain_id = map_value.value + # a chain id read from the transaction is only safe if it is the transaction chain itself + chain_id_path = getattr(params, "chainIdPath", None) + other_chain_path = chain_id_path is not None and not ( + isinstance(chain_id_path, ContainerPath) and chain_id_path.field == ContainerField.CHAIN_ID + ) + if (chain_id is not None and chain_id != maps.deployment.chainId) or other_chain_path: + out.error( + title="Unsupported token chain", + message=f"""Token "{field.label}" may be on another chain than the transaction, which cannot be """ + "encoded: the device looks the token up on the transaction chain.", + ) + return False + return True + def _convert_resolved_value( - resolved_value: ResolvedValuePath | ResolvedValueConstant | None, + resolved_value: ResolvedValuePath | ResolvedValueConstant | ResolvedValueMap | None, abi_type: ABIDataType, ) -> CalldataDescriptorValueV1 | None: if resolved_value is None: return None + if isinstance(resolved_value, ResolvedValueMap): + # the calldata descriptor is generated for a single deployment, so a map keyed on the chain id or target + # contract address resolves to a constant, other keys are only known when the transaction is signed and + # the device looks the value up in the map entries provided with the descriptor + if (key := _deployment_map_key(resolved_value)) is None: + return maps.map_ref(resolved_value, abi_type, abi, out) + if (map_value := _lookup_deployment_value(resolved_value, key)) is None: + return None + resolved_value = map_value if isinstance(resolved_value, ResolvedValuePath): return _convert_v2_value(str(resolved_value.path), None, None, abi, out) if isinstance(resolved_value, ResolvedValueConstant): @@ -745,7 +807,23 @@ def _convert_resolved_value( if input_source.lower() in set(TrustedNameSource): sources.append(TrustedNameSource(input_source.lower())) - sender_addresses = getattr(address_params, "senderAddress", None) + input_sender_addresses = getattr(address_params, "senderAddress", None) + if isinstance(input_sender_addresses, ResolvedValueMap): + # sender addresses are constants in the TRUSTED_NAME struct, the device cannot look them up + if (key := _deployment_map_key(input_sender_addresses)) is None: + return out.error( + title="Unsupported map key", + message=f"Sender addresses cannot be looked up in map {input_sender_addresses.map} using " + f"key {input_sender_addresses.keyPath}: the device only accepts constant sender " + "addresses, so only @.chainId and @.to keys are supported.", + ) + if (sender_address := _lookup_deployment_value(input_sender_addresses, key)) is None: + return None + sender_addresses = [Address(str(sender_address.value))] + elif isinstance(input_sender_addresses, str): + sender_addresses = [Address(input_sender_addresses)] + else: + sender_addresses = input_sender_addresses types = list(TrustedNameType) if not types else list(dict.fromkeys(types)) sources = list(TrustedNameSource) if not sources else list(dict.fromkeys(sources)) @@ -823,19 +901,22 @@ def _convert_resolved_value( return None selector_val = _convert_resolved_value(field.params.selector, ABIDataType.STRING) - - # v2 calldata params may not define chainId; keep compatibility with both models. - chain_id_val = _convert_resolved_value(getattr(field.params, "chainId", None), ABIDataType.UINT) - amount_val = _convert_resolved_value(field.params.amount, ABIDataType.UINT) - spender_val = _convert_resolved_value(field.params.spender, ABIDataType.ADDRESS) + # an optional parameter that is set but fails to convert must reject the field, not be omitted + if ( + (field.params.selector is not None and selector_val is None) + or (field.params.amount is not None and amount_val is None) + or (field.params.spender is not None and spender_val is None) + ): + return None + return CalldataDescriptorParamCalldataV1( value=value, callee=callee, selector=selector_val, - chain_id=chain_id_val, + chain_id=None, # v2 calldata parameters have no chain id amount=amount_val, spender=spender_val, ) @@ -863,14 +944,8 @@ def _convert_resolved_value( case FieldFormat.TOKEN_TICKER: # tokenTicker maps to PARAM_TOKEN: the field value is the token address, ticker is resolved by the device. - # chainId/chainIdPath have no equivalent tag in PARAM_TOKEN, so they cannot be encoded and are ignored. - if field.params is not None and ( - getattr(field.params, "chainId", None) is not None - or getattr(field.params, "chainIdPath", None) is not None - ): - out.warning( - "tokenTicker chainId/chainIdPath cannot be encoded in the PARAM_TOKEN struct and will be ignored." - ) + if field.params is not None and not _check_token_chain(field.params): + return None # native_currencies is left unset: PARAM_TOKEN supports NATIVE_CURRENCY, but tokenTicker has no such param. return CalldataDescriptorParamTokenV1(value=value) @@ -886,7 +961,11 @@ def _convert_resolved_value( if field.params is not None: token = getattr(field.params, "token", None) - token_path = _convert_resolved_value(token, ABIDataType.ADDRESS) + if (token_path := _convert_resolved_value(token, ABIDataType.ADDRESS)) is None and token is not None: + return None + + if not _check_token_chain(field.params): + return None threshold = getattr(field.params, "threshold", None) native_currencies = getattr(field.params, "nativeCurrencyAddress", None) diff --git a/src/erc7730/convert/calldata/v1/map.py b/src/erc7730/convert/calldata/v1/map.py new file mode 100644 index 00000000..ea4136cd --- /dev/null +++ b/src/erc7730/convert/calldata/v1/map.py @@ -0,0 +1,429 @@ +""" +Conversion of map lookups to device side maps: a MAP_REF value, looked up by the device in MAP_ENTRY structs. + +The device reads the key from the transaction, then looks up the MAP_ENTRY provided by the wallet whose key matches it +byte for byte, so each map key is encoded exactly as the device reads the key value: + - a static calldata value is a whole calldata chunk (ABI encoding on 32 bytes) + - a dynamic calldata value (bytes, string) is its raw content + - a sliced calldata value is the slice of the above + - the chain id is a 64 bits big endian integer, the sender / target addresses are 20 bytes, the native value is a + 256 bits big endian integer + +See doc/tlv_structs.md#map_entry in https://github.com/LedgerHQ/app-ethereum for specifications of these structs. +""" + +from collections.abc import Callable +from typing import assert_never + +from erc7730.common.abi import ABIDataType +from erc7730.common.binary import from_hex +from erc7730.common.output import OutputAdder +from erc7730.convert.calldata.v1.abi import ABITree +from erc7730.convert.calldata.v1.path import convert_container_path, convert_data_path +from erc7730.convert.calldata.v1.tlv import tlv_value +from erc7730.model.calldata.v1.instruction import MAX_MAP_ENTRY_SIZE, CalldataDescriptorInstructionMapEntryV1 +from erc7730.model.calldata.v1.value import ( + CalldataDescriptorContainerPathV1, + CalldataDescriptorContainerPathValueV1, + CalldataDescriptorDataPathV1, + CalldataDescriptorMapRefV1, + CalldataDescriptorPathElementArrayV1, + CalldataDescriptorPathElementLeafV1, + CalldataDescriptorPathElementSliceV1, + CalldataDescriptorPathLeafType, + CalldataDescriptorTypeFamily, + CalldataDescriptorValueMapRefV1, + CalldataDescriptorValuePathV1, +) +from erc7730.model.paths import ContainerPath, DataPath +from erc7730.model.resolved.v2.context import ResolvedDeployment +from erc7730.model.resolved.v2.display import ResolvedValueMap +from erc7730.model.types import Address, HexStr, Selector + +# size of a calldata chunk (CALLDATA_CHUNK_SIZE in app-ethereum) +CALLDATA_CHUNK_SIZE = 32 + +# length of an EVM address (ADDRESS_LENGTH in app-ethereum) +ADDRESS_LENGTH = 20 + +# size of the chain id the device reads from the transaction context (uint64) +CHAIN_ID_SIZE = 8 + +# maximum size of the KEY VALUE struct embedded in a MAP_REF (MAP_REF_KEY_TLV_MAX_SIZE in app-ethereum) +MAP_REF_KEY_MAX_SIZE = 48 + +# map ids are encoded on a single byte +MAX_MAP_ID = 255 + +# reports why a map key is invalid, and returns None +_Invalid = Callable[[str], bytes | None] + + +class MapEntries: + """ + Device side maps of a calldata descriptor (bound to a single deployment and selector). + + Map ids are allocated per descriptor: a map looked up several times with the same key and value encodings shares + its id and MAP_ENTRY structs, a map looked up with different encodings (for instance its values read as addresses + in one field and as strings in another) gets one id per encoding. + """ + + def __init__(self, deployment: ResolvedDeployment, selector: Selector) -> None: + self.deployment = deployment + self.selector = selector + self.entries: list[CalldataDescriptorInstructionMapEntryV1] = [] + self._ids: dict[tuple[str, tuple[tuple[bytes, bytes], ...]], int] = {} + + def map_ref( + self, + value_map: ResolvedValueMap, + abi_type: ABIDataType, + abi: ABITree, + out: OutputAdder, + ) -> CalldataDescriptorValueMapRefV1 | None: + """ + Convert a map lookup to a MAP_REF value, and register the MAP_ENTRY structs of the map. + + :param value_map: resolved map lookup + :param abi_type: data type the map values are encoded with + :param abi: function ABI tree + :param out: error handler + :return: MAP_REF value, or None on error + """ + map_name = str(value_map.map) + + if (encoded_keys := encode_map_keys(value_map, abi, out)) is None: + return None + key, keys_bytes = encoded_keys + + encoded: list[tuple[str, bytes, bytes]] = [] + for key_source, value in value_map.values.items(): + key_bytes = keys_bytes[key_source] + value_bytes = from_hex(value.raw) # encoded with the parameter type when resolved + if not 1 <= len(value_bytes) <= MAX_MAP_ENTRY_SIZE: + return out.error( + title="Invalid map value", + message=f"""Value for key "{key_source}" of map {map_name} must encode to 1 to """ + f"{MAX_MAP_ENTRY_SIZE} bytes, it encodes to {len(value_bytes)}.", + ) + encoded.append((key_source, key_bytes, value_bytes)) + + signature = (map_name, tuple(sorted((key_bytes, value_bytes) for _, key_bytes, value_bytes in encoded))) + if (map_id := self._ids.get(signature)) is None: + if (map_id := len(self._ids)) > MAX_MAP_ID: + return out.error( + title="Too many maps", + message=f"At most {MAX_MAP_ID + 1} maps can be looked up in a single function.", + ) + self._ids[signature] = map_id + for key_source, key_bytes, value_bytes in encoded: + self.entries.append( + CalldataDescriptorInstructionMapEntryV1( + chain_id=self.deployment.chainId, + address=Address(self.deployment.address), + selector=self.selector, + map=map_name, + id=map_id, + key_source=key_source, + key=HexStr("0x" + key_bytes.hex()), + value_source=value_map.values[key_source].value, + value=HexStr("0x" + value_bytes.hex()), + ) + ) + + # the formatters only read the declared size to interpret signed integers and to check a minimum length, + # which only holds if all the values have it + value_sizes = {len(value_bytes) for _, _, value_bytes in encoded} + return CalldataDescriptorValueMapRefV1( + type_family=CalldataDescriptorTypeFamily[abi_type.name], + type_size=value_sizes.pop() if len(value_sizes) == 1 else None, + map_ref=CalldataDescriptorMapRefV1(map=map_name, id=map_id, key=key), + ) + + +def encode_map_keys( + value_map: ResolvedValueMap, abi: ABITree, out: OutputAdder +) -> tuple[CalldataDescriptorValuePathV1, dict[str, bytes]] | None: + """ + Convert the key path of a map lookup to the value the device reads the key from, and encode the map keys the way + the device reads that value. + + :param value_map: resolved map lookup + :param abi: function ABI tree + :param out: error handler + :return: value the device reads the key from, and encoded keys indexed by source key, or None on error + """ + map_name = str(value_map.map) + + if (key := _convert_key_path(value_map, abi, out)) is None: + return None + + if len(tlv_value(key)) > MAP_REF_KEY_MAX_SIZE: + return out.error( + title="Unsupported map key", + message=f"Path {value_map.keyPath} used as key of map {map_name} is too complex: the device " + f"accepts at most {MAP_REF_KEY_MAX_SIZE} bytes to encode it.", + ) + + keys_bytes: dict[str, bytes] = {} + sources: dict[bytes, str] = {} + valid = True + for key_source in value_map.values: + # all keys are checked, so that every invalid key is reported at once + if (key_bytes := _encode_key(key_source, key, map_name, out)) is None: + valid = False + continue + if (other_key := sources.get(key_bytes)) is not None: + valid = False + out.error( + title="Invalid map key", + message=f"""Keys "{other_key}" and "{key_source}" of map {map_name} encode to the same """ + f"{key.type_family.name.lower()} key.", + ) + continue + sources[key_bytes] = key_source + keys_bytes[key_source] = key_bytes + + return (key, keys_bytes) if valid else None + + +def _convert_key_path( + value_map: ResolvedValueMap, abi: ABITree, out: OutputAdder +) -> CalldataDescriptorValuePathV1 | None: + """Convert the key path of a map lookup to the value the device reads the key from.""" + match value_map.keyPath: + case ContainerPath() as container_path: + return convert_container_path(container_path, out) + + case DataPath() as data_path: + if (key := convert_data_path(data_path, abi, out)) is None: + return None + assert isinstance(key.binary_path, CalldataDescriptorDataPathV1) # nosec B101 - data path converts to one + + # the device looks up a single key: it rejects a path reading several values + for element in key.binary_path.elements: + if isinstance(element, CalldataDescriptorPathElementArrayV1) and not _is_single_element(element): + return out.error( + title="Unsupported map key", + message=f"Path {data_path} used as key of map {value_map.map} references several values, a " + "map key must reference a single value.", + ) + return key + + case _: + assert_never(value_map.keyPath) + + +def _is_single_element(element: CalldataDescriptorPathElementArrayV1) -> bool: + """Whether an array path element selects a single array element.""" + match element.start, element.end: + case int(start), int(end): + return end == start + 1 + case -1, None: + return True + case _: + return False + + +def _encode_key(key_source: str, key: CalldataDescriptorValuePathV1, map_name: str, out: OutputAdder) -> bytes | None: + """ + Encode a map key the way the device reads the key value from the transaction. + + :param key_source: map key, as written in the source descriptor + :param key: value the device reads the key from + :param map_name: source map reference, for error messages + :param out: error handler + :return: encoded key, or None on error + """ + + def invalid(reason: str) -> bytes | None: + out.error( + title="Invalid map key", + message=f"""Key "{key_source}" of map {map_name} cannot be matched against {key.abi_path}: {reason}.""", + ) + return None + + match key.binary_path: + case CalldataDescriptorContainerPathV1(value=container_value): + match container_value: + case CalldataDescriptorContainerPathValueV1.CHAIN_ID: + return _encode_uint(key_source, CHAIN_ID_SIZE, invalid) + case CalldataDescriptorContainerPathValueV1.FROM | CalldataDescriptorContainerPathValueV1.TO: + return _encode_hex(key_source, ADDRESS_LENGTH, invalid, pad_left=True) + case CalldataDescriptorContainerPathValueV1.VALUE: + return _encode_uint(key_source, CALLDATA_CHUNK_SIZE, invalid) + case _: + assert_never(container_value) + + case CalldataDescriptorDataPathV1(elements=elements): + leaf_slice: CalldataDescriptorPathElementSliceV1 | None = None + if isinstance(elements[-1], CalldataDescriptorPathElementSliceV1): + leaf_slice = elements[-1] + elements = elements[:-1] + if not isinstance(leaf := elements[-1], CalldataDescriptorPathElementLeafV1): + return invalid("the key path does not end with a value") + + match leaf.leaf_type: + case CalldataDescriptorPathLeafType.STATIC_LEAF: + length: int | None = CALLDATA_CHUNK_SIZE + case CalldataDescriptorPathLeafType.DYNAMIC_LEAF: + length = None + case _: + return invalid("the key path must reference a static or dynamic value") + + # a sliced key is written in the map as the slice itself, there is no way to tell the rest of the value + if leaf_slice is not None: + return _encode_sliced_key(key_source, key, length, leaf_slice, invalid) + if length is not None: + return _encode_static_key(key_source, key, invalid) + return _encode_dynamic_key(key_source, key, invalid) + + case _: + assert_never(key.binary_path) + + +def _encode_static_key(key_source: str, key: CalldataDescriptorValuePathV1, invalid: _Invalid) -> bytes | None: + """Encode a key read from a static calldata value: the device reads the whole ABI encoded chunk.""" + match key.type_family: + case CalldataDescriptorTypeFamily.UINT: + if (encoded := _encode_uint(key_source, key.type_size or CALLDATA_CHUNK_SIZE, invalid)) is None: + return None + return encoded.rjust(CALLDATA_CHUNK_SIZE, b"\x00") + case CalldataDescriptorTypeFamily.INT: + if (value := _parse_int(key_source)) is None: + return invalid("expected an integer") + size = key.type_size or CALLDATA_CHUNK_SIZE + bound = 1 << (size * 8 - 1) + if not -bound <= value < bound: + return invalid(f"it does not fit in {size} bytes") + return value.to_bytes(CALLDATA_CHUNK_SIZE, byteorder="big", signed=True) + case CalldataDescriptorTypeFamily.ADDRESS: + if (encoded := _encode_hex(key_source, ADDRESS_LENGTH, invalid, pad_left=True)) is None: + return None + return encoded.rjust(CALLDATA_CHUNK_SIZE, b"\x00") + case CalldataDescriptorTypeFamily.BOOL: + if (encoded := _encode_bool(key_source, invalid)) is None: + return None + return encoded.rjust(CALLDATA_CHUNK_SIZE, b"\x00") + case CalldataDescriptorTypeFamily.BYTES: + # bytesN values are left aligned in their chunk + if (encoded := _encode_hex(key_source, key.type_size or CALLDATA_CHUNK_SIZE, invalid)) is None: + return None + return encoded.ljust(CALLDATA_CHUNK_SIZE, b"\x00") + case _: + return invalid(f"{key.type_family.name.lower()} keys are not supported") + + +def _encode_dynamic_key(key_source: str, key: CalldataDescriptorValuePathV1, invalid: _Invalid) -> bytes | None: + """Encode a key read from a dynamic calldata value: the device reads its raw content.""" + match key.type_family: + case CalldataDescriptorTypeFamily.STRING: + encoded = key_source.encode("utf-8") + case CalldataDescriptorTypeFamily.BYTES: + if (hex_encoded := _encode_hex(key_source, MAX_MAP_ENTRY_SIZE, invalid)) is None: + return None + encoded = hex_encoded + case _: + return invalid(f"dynamic {key.type_family.name.lower()} keys are not supported") + return _check_key_size(encoded, invalid) + + +def _encode_sliced_key( + key_source: str, + key: CalldataDescriptorValuePathV1, + length: int | None, + leaf_slice: CalldataDescriptorPathElementSliceV1, + invalid: _Invalid, +) -> bytes | None: + """Encode a key read from a slice of a calldata value: the key is the slice itself.""" + width = _slice_width(length, leaf_slice) + if width == 0: + return invalid("the key path slice is empty") + + match key.type_family: + case CalldataDescriptorTypeFamily.STRING: + encoded = key_source.encode("utf-8") + case CalldataDescriptorTypeFamily.UINT | CalldataDescriptorTypeFamily.INT if not key_source.startswith("0x"): + if (value := _parse_int(key_source)) is None: + return invalid("expected an integer") + signed = key.type_family is CalldataDescriptorTypeFamily.INT + try: + encoded = value.to_bytes( + width or max(1, (value.bit_length() + 8 * signed + 7) // 8), byteorder="big", signed=signed + ) + except OverflowError: + return invalid(f"it does not fit in the {width} bytes of the slice") + case CalldataDescriptorTypeFamily.BOOL if not key_source.startswith("0x"): + if (encoded_bool := _encode_bool(key_source, invalid)) is None: + return None + encoded = encoded_bool.rjust(width or 1, b"\x00") + case _: + # numbers and addresses are right aligned in the value they are sliced from + pad_left = key.type_family in (CalldataDescriptorTypeFamily.UINT, CalldataDescriptorTypeFamily.ADDRESS) + if (hex_encoded := _encode_hex(key_source, width or MAX_MAP_ENTRY_SIZE, invalid, pad_left)) is None: + return None + encoded = hex_encoded + + if width is not None and len(encoded) != width: + return invalid(f"it encodes to {len(encoded)} bytes, but the key path slice is {width} bytes") + return _check_key_size(encoded, invalid) + + +def _slice_width(length: int | None, leaf_slice: CalldataDescriptorPathElementSliceV1) -> int | None: + """Width of a slice, when it can be known at conversion time (the device applies it as python slices do).""" + start, end = leaf_slice.start, leaf_slice.end + if length is not None: + return len(range(length)[start:end]) + match start, end: + case None, int(end) if end >= 0: + return end + case int(start), None if start < 0: + return -start + case int(start), int(end) if (start >= 0) == (end >= 0): + return max(0, end - start) + case _: + return None + + +def _check_key_size(encoded: bytes, invalid: _Invalid) -> bytes | None: + if not 1 <= len(encoded) <= MAX_MAP_ENTRY_SIZE: + return invalid(f"a key must encode to 1 to {MAX_MAP_ENTRY_SIZE} bytes, it encodes to {len(encoded)}") + return encoded + + +def _parse_int(key_source: str) -> int | None: + try: + return int(key_source, 16) if key_source.lower().startswith("0x") else int(key_source, 10) + except ValueError: + return None + + +def _encode_uint(key_source: str, size: int, invalid: _Invalid) -> bytes | None: + if (value := _parse_int(key_source)) is None or value < 0: + return invalid("expected an unsigned integer") + if value.bit_length() > size * 8: + return invalid(f"it does not fit in {size} bytes") + return value.to_bytes(size, byteorder="big") + + +def _encode_hex(key_source: str, size: int, invalid: _Invalid, pad_left: bool = False) -> bytes | None: + """Decode a hex key of at most size bytes, left padded to size bytes if requested.""" + if not key_source.startswith("0x"): + return invalid("expected a hexadecimal string") + try: + encoded = from_hex(key_source) + except ValueError: + return invalid("expected a hexadecimal string") + if not 1 <= len(encoded) <= size: + return invalid(f"expected 1 to {size} bytes, got {len(encoded)}") + return encoded.rjust(size, b"\x00") if pad_left else encoded + + +def _encode_bool(key_source: str, invalid: _Invalid) -> bytes | None: + match key_source.lower(): + case "true" | "1": + return b"\x01" + case "false" | "0": + return b"\x00" + case _: + return invalid("expected a boolean") diff --git a/src/erc7730/convert/calldata/v1/tlv.py b/src/erc7730/convert/calldata/v1/tlv.py index a5849878..ab63bf19 100644 --- a/src/erc7730/convert/calldata/v1/tlv.py +++ b/src/erc7730/convert/calldata/v1/tlv.py @@ -15,6 +15,7 @@ CalldataDescriptorFieldVisibilityV1, CalldataDescriptorInstructionEnumValueV1, CalldataDescriptorInstructionFieldV1, + CalldataDescriptorInstructionMapEntryV1, CalldataDescriptorInstructionTransactionInfoV1, ) from erc7730.model.calldata.v1.param import ( @@ -35,6 +36,7 @@ from erc7730.model.calldata.v1.value import ( CalldataDescriptorContainerPathV1, CalldataDescriptorDataPathV1, + CalldataDescriptorMapRefV1, CalldataDescriptorPathElementArrayV1, CalldataDescriptorPathElementLeafV1, CalldataDescriptorPathElementRefV1, @@ -42,6 +44,7 @@ CalldataDescriptorPathElementTupleV1, CalldataDescriptorPathElementV1, CalldataDescriptorValueConstantV1, + CalldataDescriptorValueMapRefV1, CalldataDescriptorValuePathV1, CalldataDescriptorValueV1, ) @@ -74,6 +77,18 @@ class CalldataDescriptorEnumValueTag(IntEnum): NAME = 0x06 +@pydantic_enum_by_name +class CalldataDescriptorMapEntryTag(IntEnum): + VERSION = 0x00 + CHAIN_ID = 0x01 + CONTRACT_ADDR = 0x02 + SELECTOR = 0x03 + ID = 0x04 + KEY = 0x05 + VALUE = 0x06 + SIGNATURE = 0xFF + + @pydantic_enum_by_name class CalldataDescriptorFieldTag(IntEnum): VERSION = 0x00 @@ -183,6 +198,14 @@ class CalldataDescriptorValueTag(IntEnum): DATA_PATH = 0x03 CONTAINER_PATH = 0x04 CONSTANT = 0x05 + MAP_REF = 0x06 + + +@pydantic_enum_by_name +class CalldataDescriptorMapRefTag(IntEnum): + VERSION = 0x00 + ID = 0x01 + KEY = 0x02 @pydantic_enum_by_name @@ -260,6 +283,24 @@ def tlv_enum_value(obj: CalldataDescriptorInstructionEnumValueV1) -> bytes: return out +def tlv_map_entry(obj: CalldataDescriptorInstructionMapEntryV1) -> bytes: + """ + Encode a struct of type MAP_ENTRY (without its signature, which is computed by CAL). + + @param obj: object representation of struct + @return: encoded struct TLV + """ + out = bytearray() + out += tlv(CalldataDescriptorMapEntryTag.VERSION, obj.version.to_bytes(1)) + out += tlv(CalldataDescriptorMapEntryTag.CHAIN_ID, obj.chain_id.to_bytes(8)) + out += tlv(CalldataDescriptorMapEntryTag.CONTRACT_ADDR, from_hex(obj.address)) + out += tlv(CalldataDescriptorMapEntryTag.SELECTOR, from_hex(obj.selector)) + out += tlv(CalldataDescriptorMapEntryTag.ID, obj.id.to_bytes(1)) + out += tlv(CalldataDescriptorMapEntryTag.KEY, from_hex(obj.key)) + out += tlv(CalldataDescriptorMapEntryTag.VALUE, from_hex(obj.value)) + return out + + def tlv_field(obj: CalldataDescriptorInstructionFieldV1) -> bytes: """ Encode a struct of type FIELD. @@ -573,12 +614,28 @@ def tlv_value(obj: CalldataDescriptorValueV1) -> bytes: assert_never(path.binary_path) case CalldataDescriptorValueConstantV1() as constant: out += tlv(CalldataDescriptorValueTag.CONSTANT, from_hex(constant.raw)) + case CalldataDescriptorValueMapRefV1() as map_value: + out += tlv(CalldataDescriptorValueTag.MAP_REF, tlv_map_ref(map_value.map_ref)) case _: assert_never(obj) return out +def tlv_map_ref(obj: CalldataDescriptorMapRefV1) -> bytes: + """ + Encode a struct of type MAP_REF. + + @param obj: object representation of struct + @return: encoded struct TLV + """ + out = bytearray() + out += tlv(CalldataDescriptorMapRefTag.VERSION, obj.version.to_bytes(1)) + out += tlv(CalldataDescriptorMapRefTag.ID, obj.id.to_bytes(1)) + out += tlv(CalldataDescriptorMapRefTag.KEY, tlv_value(obj.key)) + return out + + def tlv_data_path_element(obj: CalldataDescriptorPathElementV1) -> bytes: """ Encode a struct of type PATH_ELEMENT. 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 78c63c89..b419e81b 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 @@ -44,6 +44,7 @@ ResolvedFieldDescription, ResolvedFieldGroup, ResolvedTokenAmountParameters, + ResolvedValueMap, ) # --------------------------------------------------------------------------- @@ -225,6 +226,16 @@ def _convert_v2_field_description( relative_path = str(to_relative(parsed_path)) + # --- Map lookups (looked up by the wallet at signing time) cannot be represented in legacy format --- + if field.params is not None: + for param_name, param in field.params: + if isinstance(param, ResolvedValueMap): + return out.error( + title="Map lookup not supported", + message=f'Map lookup "{param_name}" of field "{field.label}" cannot be converted to legacy ' + "EIP-712 fields.", + ) + # --- Format mapping --- asset_path: str | None = None field_format: EIP712Format | None = None @@ -314,7 +325,7 @@ def _convert_v2_field_description( ): def _resolve_calldata_resolved_value( - value: ResolvedValuePath | ResolvedValueConstant | None, + value: ResolvedValuePath | ResolvedValueConstant | ResolvedValueMap | None, field_name: str, ) -> str | None: if value is None: 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 4388cf61..4dd7d5b1 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 @@ -15,7 +15,7 @@ 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 is_field_hidden, resolve_reference -from erc7730.convert.resolved.v2.values import resolve_field_value +from erc7730.convert.resolved.v2.values import deployment_map_key, lookup_map_value, resolve_field_value from erc7730.model.input.v2.context import ( InputContract, InputContractContext, @@ -57,6 +57,7 @@ ResolvedFieldDescription, ResolvedFieldGroup, ResolvedFormat, + ResolvedValueMap, ) from erc7730.model.resolved.v2.metadata import ResolvedMapDefinition, ResolvedMetadata, ResolvedOwnerInfo from erc7730.model.types import Address, Id, Selector @@ -89,6 +90,8 @@ def convert(self, descriptor: InputERC7730Descriptor, out: OutputAdder) -> Resol return None if (display := self._resolve_display(descriptor.display, context, metadata.enums, constants, out)) is None: return None + if not self._validate_deployment_map_lookups(context, display, out): + return None return ResolvedERC7730Descriptor.model_validate( { @@ -273,6 +276,57 @@ def _resolve_display( return ResolvedDisplay(definitions=None, formats=formats) + @classmethod + def _validate_deployment_map_lookups( + cls, context: ResolvedContractContext | ResolvedEIP712Context, display: ResolvedDisplay, out: OutputAdder + ) -> bool: + """ + Check that map lookups keyed on the chain id or target contract address have a value for each deployment. + + A wallet must consider the descriptor invalid when a map has no value for the key, so a missing key for a + known deployment is an error. + """ + match context: + case ResolvedContractContext(): + deployments = context.contract.deployments + case ResolvedEIP712Context(): + deployments = context.eip712.deployments or [] + case _: + assert_never(context) + + valid = True + + def validate_field(field: ResolvedField) -> None: + nonlocal valid + match field: + case ResolvedFieldDescription(): + if field.params is None: + return + for param_name, param in field.params: + if not isinstance(param, ResolvedValueMap): + continue + for deployment in deployments: + key = deployment_map_key(param.keyPath, deployment.chainId, deployment.address) + if key is not None and lookup_map_value(param.values, key) is None: + valid = False + out.error( + title="Missing map value", + message=f"""Map used for "{param_name}" of field "{field.label}" has no value """ + f"""for {param.keyPath} "{key}" (deployment {deployment.address} on chain """ + f"""{deployment.chainId}).""", + ) + case ResolvedFieldGroup(): + for sub_field in field.fields: + validate_field(sub_field) + case _: + assert_never(field) + + for format in display.formats.values(): + for field in format.fields: + validate_field(field) + + return valid + @classmethod def _resolve_field_description( cls, @@ -314,6 +368,10 @@ def _resolve_field_description( assert_never(definition.format) params = resolve_field_parameters(prefix, definition.params, enums, constants, out) + if definition.params is not None and params is None: + # parameters are set but could not be resolved (error already reported): dropping them would display the + # field without them, so reject the field instead + return None if (value_or_path := resolve_field_value(prefix, definition, definition.format, constants, out)) is None: return None diff --git a/src/erc7730/convert/resolved/v2/parameters.py b/src/erc7730/convert/resolved/v2/parameters.py index 2252d70e..8254f691 100644 --- a/src/erc7730/convert/resolved/v2/parameters.py +++ b/src/erc7730/convert/resolved/v2/parameters.py @@ -4,7 +4,10 @@ from erc7730.common.output import OutputAdder from erc7730.convert.resolved.v2.constants import ConstantProvider 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.convert.resolved.v2.values import ( + resolve_map_reference_value, + resolve_path_constant_or_map_value, +) from erc7730.model.input.path import DescriptorPathStr from erc7730.model.input.v2.display import ( InputAddressNameParameters, @@ -20,9 +23,9 @@ InputTokenTickerParameters, InputUnitParameters, ) -from erc7730.model.paths import ContainerPath, DataPath +from erc7730.model.paths import ContainerPath, DataPath, DescriptorPath from erc7730.model.paths.path_ops import data_or_container_path_concat -from erc7730.model.resolved.display import ResolvedValue +from erc7730.model.resolved.display import ResolvedValueConstant from erc7730.model.resolved.metadata import EnumDefinition from erc7730.model.resolved.v2.display import ( ResolvedAddressNameParameters, @@ -36,8 +39,10 @@ ResolvedTokenAmountParameters, ResolvedTokenTickerParameters, ResolvedUnitParameters, + ResolvedValueMap, + ResolvedValueOrMap, ) -from erc7730.model.types import Address, HexStr, Id, MixedCaseAddress +from erc7730.model.types import Address, HexStr, Id, MixedCaseAddress, ScalarType def resolve_field_parameters( @@ -75,26 +80,9 @@ def resolve_field_parameters( def resolve_address_name_parameters( prefix: DataPath, params: InputAddressNameParameters, constants: ConstantProvider, out: OutputAdder ) -> 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.", - ) - sender_address = None - else: - resolved_sender = constants.resolve_or_none(sender_addr_input, out) - if resolved_sender is None: - sender_address = None - elif isinstance(resolved_sender, str): - sender_address = [Address(resolved_sender)] - elif isinstance(resolved_sender, list): - sender_address = [Address(addr) for addr in resolved_sender] - else: - raise Exception("Invalid senderAddress type") + sender_address = _resolve_sender_address(prefix, params.senderAddress, constants, out) + if params.senderAddress is not None and sender_address is None: + return None return ResolvedAddressNameParameters( types=constants.resolve_or_none(params.types, out), @@ -106,25 +94,9 @@ def resolve_address_name_parameters( def resolve_interoperable_address_name_parameters( prefix: DataPath, params: InputInteroperableAddressNameParameters, constants: ConstantProvider, out: OutputAdder ) -> 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.", - ) - sender_address = None - else: - resolved_sender = constants.resolve_or_none(sender_addr_input, out) - if resolved_sender is None: - sender_address = None - elif isinstance(resolved_sender, str): - sender_address = [Address(resolved_sender)] - elif isinstance(resolved_sender, list): - sender_address = [Address(addr) for addr in resolved_sender] - else: - raise Exception("Invalid senderAddress type") + sender_address = _resolve_sender_address(prefix, params.senderAddress, constants, out) + if params.senderAddress is not None and sender_address is None: + return None return ResolvedInteroperableAddressNameParameters( types=constants.resolve_or_none(params.types, out), @@ -133,82 +105,67 @@ def resolve_interoperable_address_name_parameters( ) +def _resolve_sender_address( + prefix: DataPath, + sender_addr_input: MixedCaseAddress | list[MixedCaseAddress] | DescriptorPath | InputMapReference | None, + constants: ConstantProvider, + out: OutputAdder, +) -> list[Address] | ResolvedValueMap | None: + if sender_addr_input is None: + return None + + if isinstance(sender_addr_input, InputMapReference): + match resolved_map := resolve_map_reference_value( + prefix, sender_addr_input, ABIDataType.ADDRESS, constants, out + ): + case ResolvedValueConstant(): + return [Address(str(resolved_map.value))] + case _: + return resolved_map + + resolved_sender = constants.resolve_or_none(sender_addr_input, out) + if resolved_sender is None: + return None + if isinstance(resolved_sender, str): + return [Address(resolved_sender)] + if isinstance(resolved_sender, list): + return [Address(addr) for addr in resolved_sender] + raise Exception("Invalid senderAddress type") + + def resolve_calldata_parameters( prefix: DataPath, params: InputCallDataParameters, constants: ConstantProvider, out: OutputAdder ) -> 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.", + def resolve( + input_path: DescriptorPath | DataPath | ContainerPath | None, + input_value: DescriptorPath | ScalarType | InputMapReference | None, + abi_type: ABIDataType, + ) -> ResolvedValueOrMap | None: + return resolve_path_constant_or_map_value(prefix, input_path, input_value, abi_type, constants, out) + + # callee is mandatory, other parameters are optional: each can be a path, a constant or a map reference + if params.callee is None and params.calleePath is None: + return out.error( + title="Missing callee", + message='Calldata parameters must set either "callee" or "calleePath".', ) + if (callee_resolved := resolve(params.calleePath, params.callee, ABIDataType.ADDRESS)) is None: return None - elif params.callee is not None or params.calleePath is not None: - callee_resolved = resolve_path_or_constant_value( - prefix=prefix, - input_path=params.calleePath, - input_value=params.callee, - abi_type=ABIDataType.ADDRESS, - constants=constants, - out=out, - ) - 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.", - ) - elif params.selector is not None or params.selectorPath is not None: - selector_resolved = resolve_path_or_constant_value( - prefix=prefix, - input_path=params.selectorPath, - input_value=params.selector, - abi_type=ABIDataType.STRING, - constants=constants, - 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.", - ) - elif params.amount is not None or params.amountPath is not None: - amount_resolved = resolve_path_or_constant_value( - prefix=prefix, - input_path=params.amountPath, - input_value=params.amount, - abi_type=ABIDataType.UINT, - constants=constants, - out=out, - ) + selector_resolved = resolve(params.selectorPath, params.selector, ABIDataType.STRING) + amount_resolved = resolve(params.amountPath, params.amount, ABIDataType.UINT) + spender_resolved = resolve(params.spenderPath, params.spender, ABIDataType.ADDRESS) - # 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.", - ) - elif params.spender is not None or params.spenderPath is not None: - spender_resolved = resolve_path_or_constant_value( - prefix=prefix, - input_path=params.spenderPath, - input_value=params.spender, - abi_type=ABIDataType.ADDRESS, - constants=constants, - out=out, - ) + # an optional parameter that is set but fails to resolve must reject the field, not be omitted + if ( + (selector_resolved is None and (params.selector is not None or params.selectorPath is not None)) + or (amount_resolved is None and (params.amount is not None or params.amountPath is not None)) + or (spender_resolved is None and (params.spender is not None or params.spenderPath is not None)) + ): + return None return ResolvedCallDataParameters( - callee=cast(ResolvedValue, callee_resolved), + callee=callee_resolved, selector=selector_resolved, amount=amount_resolved, spender=spender_resolved, @@ -218,24 +175,17 @@ def resolve_calldata_parameters( def resolve_token_amount_parameters( prefix: DataPath, params: InputTokenAmountParameters, constants: ConstantProvider, out: OutputAdder ) -> 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.", - ) - else: - token_resolved = resolve_path_or_constant_value( - prefix=prefix, - input_path=params.tokenPath, - input_value=token_value, - abi_type=ABIDataType.ADDRESS, - constants=constants, - out=out, - ) + # Resolve token into a ResolvedValue (path or constant) like v1, or into a map lookup. + token_resolved = resolve_path_constant_or_map_value( + prefix=prefix, + input_path=params.tokenPath, + input_value=params.token, + abi_type=ABIDataType.ADDRESS, + constants=constants, + out=out, + ) + if token_resolved is None and (params.token is not None or params.tokenPath is not None): + return None input_addresses = cast( list[DescriptorPathStr | MixedCaseAddress] | MixedCaseAddress | None, @@ -265,17 +215,10 @@ def resolve_token_amount_parameters( else: resolved_threshold = 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 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 + if (resolved_chain_id := _resolve_chain_id(prefix, params.chainId, constants, out)) is None and ( + params.chainId is not None + ): + return None return ResolvedTokenAmountParameters( token=token_resolved, @@ -290,17 +233,10 @@ def resolve_token_amount_parameters( def resolve_token_ticker_parameters( prefix: DataPath, params: InputTokenTickerParameters, constants: ConstantProvider, out: OutputAdder ) -> 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 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 + if (resolved_chain_id := _resolve_chain_id(prefix, params.chainId, constants, out)) is None and ( + params.chainId is not None + ): + return None # Resolve and normalize chainIdPath using constants and the current prefix resolved_chain_id_path: DataPath | ContainerPath | None = None @@ -315,30 +251,58 @@ def resolve_token_ticker_parameters( ) +def _resolve_chain_id( + prefix: DataPath, + chain_id_input: int | DescriptorPath | InputMapReference | None, + constants: ConstantProvider, + out: OutputAdder, +) -> int | ResolvedValueMap | None: + if chain_id_input is None: + return None + + if isinstance(chain_id_input, InputMapReference): + match resolved_map := resolve_map_reference_value(prefix, chain_id_input, ABIDataType.UINT, constants, out): + case ResolvedValueConstant(): + if not isinstance(resolved_map.value, int): + return out.error( + title="Invalid chain id", + message=f"""Chain id "{resolved_map.value}" from map {chain_id_input.map} is not an integer.""", + ) + return resolved_map.value + case _: + return resolved_map + + if isinstance(chain_id_input, int): + return chain_id_input + + # Descriptor path + resolved_value: Any = constants.resolve(chain_id_input, out) + if resolved_value is None: + return None + if not isinstance(resolved_value, int): + return out.error( + title="Invalid chain id", + message=f"""Chain id "{resolved_value}" from {chain_id_input} is not an integer.""", + ) + return resolved_value + + def resolve_nft_parameters( prefix: DataPath, params: InputNftNameParameters, constants: ConstantProvider, out: OutputAdder ) -> 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.", - ) - return None - else: - collection_resolved = resolve_path_or_constant_value( + if ( + collection_resolved := resolve_path_constant_or_map_value( prefix=prefix, input_path=params.collectionPath, - input_value=collection_value, + input_value=params.collection, abi_type=ABIDataType.ADDRESS, constants=constants, out=out, ) - if collection_resolved is None: - return None - return ResolvedNftNameParameters(collection=collection_resolved) + ) is None: + return None + return ResolvedNftNameParameters(collection=collection_resolved) def resolve_date_parameters( diff --git a/src/erc7730/convert/resolved/v2/values.py b/src/erc7730/convert/resolved/v2/values.py index 55ef0739..b4684008 100644 --- a/src/erc7730/convert/resolved/v2/values.py +++ b/src/erc7730/convert/resolved/v2/values.py @@ -1,15 +1,17 @@ -from typing import assert_never +from collections.abc import Mapping +from typing import Any, TypeGuard, assert_never from pydantic import TypeAdapter, ValidationError 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.display import InputFieldBase +from erc7730.model.input.v2.display import InputFieldBase, InputMapReference from erc7730.model.input.v2.format import FieldFormat -from erc7730.model.paths import ContainerPath, DataPath, DescriptorPath +from erc7730.model.paths import ContainerField, ContainerPath, DataPath, DescriptorPath from erc7730.model.paths.path_ops import data_or_container_path_concat from erc7730.model.resolved.display import ResolvedValue, ResolvedValueConstant, ResolvedValuePath +from erc7730.model.resolved.v2.display import ResolvedValueMap, ResolvedValueOrMap from erc7730.model.types import HexStr, ScalarType @@ -117,15 +119,184 @@ def resolve_path_or_constant_value( return None +def resolve_path_constant_or_map_value( + prefix: DataPath, + input_path: DescriptorPath | DataPath | ContainerPath | None, + input_value: DescriptorPath | ScalarType | InputMapReference | None, + abi_type: ABIDataType, + constants: ConstantProvider, + out: OutputAdder, +) -> ResolvedValueOrMap | None: + """ + Resolve value, as a data path, constant value or map lookup. + + :param prefix: current path prefix + :param input_path: input data path, if provided + :param input_value: input constant value or map reference, if provided + :param abi_type: expected encoded value data type + :param constants: descriptor paths constants resolver + :param out: error handler + :return: resolved value or None if error or value resolves to None + """ + if not isinstance(input_value, InputMapReference): + return resolve_path_or_constant_value(prefix, input_path, input_value, abi_type, constants, out) + + if input_path is not None: + return out.error( + title="Invalid field", + message="Field cannot have both a path and a value.", + ) + + return resolve_map_reference_value(prefix, input_value, abi_type, constants, out) + + +def resolve_map_reference_value( + prefix: DataPath, + map_ref: InputMapReference, + abi_type: ABIDataType, + constants: ConstantProvider, + out: OutputAdder, +) -> ResolvedValueConstant | ResolvedValueMap | None: + """ + Resolve a map reference, as a constant value if the key is a constant, or as a map lookup otherwise. + + :param prefix: current path prefix + :param map_ref: input map reference + :param abi_type: expected encoded value data type + :param constants: descriptor paths constants resolver + :param out: error handler + :return: resolved value or None if error + """ + map_def = constants.get(map_ref.map, out) + if map_def is None: + return None + if not isinstance(values := getattr(map_def, "values", None), dict): + return out.error( + title="Invalid map reference", + message=f"{map_ref.map} is not a valid map definition.", + ) + + # hex keys (addresses) are matched case-insensitively, so keys only differing by case would be ambiguous + hex_keys: dict[str, str] = {} + for key in values: + if not key.startswith("0x"): + continue + if (other_key := hex_keys.get(key.lower())) is not None: + return out.error( + title="Invalid map key", + message=f"""Keys "{other_key}" and "{key}" of map {map_ref.map} only differ by case, hexadecimal """ + "keys are matched case-insensitively.", + ) + hex_keys[key.lower()] = key + + def resolve_map_value(key: str, value: Any) -> ResolvedValueConstant | None: + if not isinstance(value, str | bool | int | float): + return out.error( + title="Invalid map value", + message=f"""Value for key "{key}" in map {map_ref.map} must be a scalar type (string, boolean or """ + "number).", + ) + if (raw := encode_value(value, abi_type, out)) is None: + return None + return ResolvedValueConstant(type_family=abi_type, type_size=len(raw) // 2 - 1, value=value, raw=raw) + + # a key that is itself a constant selects the value right away, other map values are not used + if isinstance(map_ref.keyPath, DescriptorPath): + if (key := constants.get(map_ref.keyPath, out)) is None: + return None + # map keys are JSON object keys, so a boolean key is written "true" or "false" + if (map_key := lookup_map_key(values, str(key).lower() if isinstance(key, bool) else str(key))) is None: + return out.error( + title="Invalid map reference", + message=f"""Map {map_ref.map} has no value for key "{key}" (from {map_ref.keyPath}).""", + ) + return resolve_map_value(map_key, values[map_key]) + + resolved_values: dict[str, ResolvedValueConstant] = {} + for key, value in values.items(): + if (resolved_value := resolve_map_value(key, value)) is None: + return None + resolved_values[key] = resolved_value + + if (key_path := constants.resolve_path(map_ref.keyPath, out)) is None: + return None + + return ResolvedValueMap( + map=map_ref.map, keyPath=data_or_container_path_concat(prefix, key_path), values=resolved_values + ) + + +def is_deployment_key(key_path: ContainerPath | DataPath) -> TypeGuard[ContainerPath]: + """ + Whether a map key is determined by the deployment: the chain id or the target contract address. + + :param key_path: resolved map key path + :return: True if the key is the chain id or the target contract address + """ + return isinstance(key_path, ContainerPath) and key_path.field in (ContainerField.CHAIN_ID, ContainerField.TO) + + +def deployment_map_key(key_path: ContainerPath | DataPath, chain_id: int, address: str) -> str | None: + """ + Get the map key a deployment provides, for map lookups keyed on the chain id or target contract address. + + :param key_path: resolved map key path + :param chain_id: deployment chain id + :param address: deployment contract address + :return: map key, or None if the key is not determined by the deployment + """ + if not is_deployment_key(key_path): + return None + return str(chain_id) if key_path.field == ContainerField.CHAIN_ID else address + + +def lookup_map_value(values: dict[str, ResolvedValueConstant], key: str) -> ResolvedValueConstant | None: + """ + Look up a map value, matching hex keys (addresses) case-insensitively. + + :param values: resolved map values, indexed by key + :param key: map key + :return: resolved constant value, or None if the map has no value for the key + """ + if (map_key := lookup_map_key(values, key)) is None: + return None + return values[map_key] + + +def lookup_map_key(values: Mapping[str, Any], key: str) -> str | None: + """ + Find the map key matching a key, matching hex keys (addresses) case-insensitively. + + :param values: map values, indexed by key + :param key: map key + :return: matching map key, or None if the map has no value for the key + """ + if key in values: + return key + if key.startswith("0x"): + for map_key in values: + if map_key.lower() == key.lower(): + return map_key + return None + + def encode_value(value: ScalarType, abi_type: ABIDataType, out: OutputAdder) -> HexStr | None: if isinstance(value, str) and value.startswith("0x"): try: - return TypeAdapter(HexStr).validate_strings(value) + hex_value = TypeAdapter(HexStr).validate_strings(value) except ValidationError: return out.error( title="Invalid hex string", message=f""""{value}" is not a valid hexadecimal string.""", ) + if len(hex_value) % 2 != 0: + return out.error( + title="Invalid hex string", + message=f""""{value}" is not a valid hexadecimal string: it must have an even number of digits.""", + ) + if abi_type == ABIDataType.ADDRESS and len(hex_value) != 42: + return out.error(title="Invalid constant", message=f"""Value "{value}" is not a valid 20 bytes address.""") + return hex_value # this uses a custom specific encoding because this is what the Ledger app expects try: diff --git a/src/erc7730/lint/v2/lint.py b/src/erc7730/lint/v2/lint.py index 515f9e4c..c1f82259 100644 --- a/src/erc7730/lint/v2/lint.py +++ b/src/erc7730/lint/v2/lint.py @@ -18,6 +18,7 @@ from erc7730.lint.v2.lint_transaction_type_classifier import ClassifyTransactionTypeLinter from erc7730.lint.v2.lint_validate_display_fields import ValidateDisplayFieldsLinter from erc7730.lint.v2.lint_validate_eip712_keys import ValidateEIP712KeysLinter +from erc7730.lint.v2.lint_validate_map_keys import ValidateMapKeysLinter from erc7730.lint.v2.lint_validate_max_length import ValidateMaxLengthLinter from erc7730.list.list import get_erc7730_files from erc7730.model.input.v2.descriptor import InputERC7730Descriptor @@ -55,6 +56,7 @@ def lint_all(paths: list[Path], out: OutputAdder) -> int: [ ValidateDisplayFieldsLinter(), ValidateEIP712KeysLinter(), + ValidateMapKeysLinter(), ClassifyTransactionTypeLinter(), ValidateMaxLengthLinter(), ] diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py index 86d6e82e..7d427865 100644 --- a/src/erc7730/lint/v2/lint_validate_display_fields.py +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -96,7 +96,7 @@ def _validate_contract_display_fields( abi_paths_by_selector[selector] = compute_abi_schema_paths(abi) # Parse the input format keys, which carry the parameter names resolution reduced to selectors - declared_abis_by_selector = cls._parse_declared_abis(input_descriptor) + declared_abis_by_selector = parse_declared_abis(input_descriptor) # Validate display field paths against ABI paths for selector, fmt in descriptor.display.formats.items(): @@ -147,27 +147,6 @@ def _validate_contract_display_fields( f"(see {explorer_url}) but has no display format defined in the descriptor.", ) - @classmethod - def _parse_declared_abis(cls, input_descriptor: InputERC7730Descriptor) -> dict[str, Function]: - """ - Parse the function signatures declared as display format keys, indexed by selector. - - Format keys that are already selectors declare no parameter name, and are skipped. - - :param input_descriptor: input descriptor the linted descriptor was resolved from - :return: declared ABI functions, by selector - """ - declared_abis: dict[str, Function] = {} - for format_id in input_descriptor.display.formats: - if format_id.startswith("0x"): - continue - try: - declared_abi = parse_signature(format_id) - except ValueError: - continue # invalid signatures are reported by the resolution step - declared_abis[signature_to_selector(compute_signature(declared_abi))] = declared_abi - return declared_abis - @classmethod def _unnamed_parameter_names(cls, abi: Function, declared_abi: Function | None) -> set[str]: """ @@ -202,3 +181,24 @@ def _root_name(cls, path: DataPath) -> str | None: if path.elements and isinstance(root := path.elements[0], Field): return root.identifier return None + + +def parse_declared_abis(input_descriptor: InputERC7730Descriptor) -> dict[str, Function]: + """ + Parse the function signatures declared as display format keys, indexed by selector. + + Format keys that are already selectors declare no parameter name, and are skipped. + + :param input_descriptor: input descriptor the linted descriptor was resolved from + :return: declared ABI functions, by selector + """ + declared_abis: dict[str, Function] = {} + for format_id in input_descriptor.display.formats: + if format_id.startswith("0x"): + continue + try: + declared_abi = parse_signature(format_id) + except ValueError: + continue # invalid signatures are reported by the resolution step + declared_abis[signature_to_selector(compute_signature(declared_abi))] = declared_abi + return declared_abis diff --git a/src/erc7730/lint/v2/lint_validate_map_keys.py b/src/erc7730/lint/v2/lint_validate_map_keys.py new file mode 100644 index 00000000..b64573f0 --- /dev/null +++ b/src/erc7730/lint/v2/lint_validate_map_keys.py @@ -0,0 +1,73 @@ +""" +V2 linter that validates the keys of maps looked up with a key read from the transaction. + +The device reads the key from the transaction and matches it byte for byte against the map keys, so each map key must +be encodable as the value at the key path (for instance, a uint32 key must fit in 4 bytes). +""" + +from typing import assert_never, final, override + +from erc7730.common.output import OutputAdder +from erc7730.convert.calldata.v1.abi import function_to_abi_tree +from erc7730.convert.calldata.v1.map import encode_map_keys +from erc7730.convert.resolved.v2.values import is_deployment_key +from erc7730.lint.v2 import ERC7730Linter +from erc7730.lint.v2.lint_validate_display_fields import parse_declared_abis +from erc7730.model.input.v2.descriptor import InputERC7730Descriptor +from erc7730.model.resolved.v2.context import ResolvedContractContext, ResolvedEIP712Context +from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor +from erc7730.model.resolved.v2.display import ( + ResolvedField, + ResolvedFieldDescription, + ResolvedFieldGroup, + ResolvedValueMap, +) + + +@final +class ValidateMapKeysLinter(ERC7730Linter): + """ + Validates that the keys of maps looked up with a key read from the transaction can be encoded as the key value. + + Maps keyed on the chain id or the target contract address are resolved for each deployment and validated during + resolution. Only contract descriptors with function signatures as format keys are validated: the ABI is needed to + know how the key is encoded. + """ + + @override + def lint( + self, input_descriptor: InputERC7730Descriptor, descriptor: ResolvedERC7730Descriptor, out: OutputAdder + ) -> None: + match descriptor.context: + case ResolvedEIP712Context(): + pass # map lookups are not supported for EIP-712 messages by the device + case ResolvedContractContext(): + abis = parse_declared_abis(input_descriptor) + for selector, fmt in descriptor.display.formats.items(): + if (abi := abis.get(selector)) is None: + continue + if not (value_maps := [value_map for field in fmt.fields for value_map in _value_maps(field)]): + continue + # only built when needed: some ABI types (fixed point numbers) cannot be converted to a tree + abi_tree = function_to_abi_tree(abi) + for value_map in value_maps: + encode_map_keys(value_map, abi_tree, out) + case _: + assert_never(descriptor.context) + + +def _value_maps(field: ResolvedField) -> list[ResolvedValueMap]: + """Get the map lookups of a field whose key is read from the transaction.""" + match field: + case ResolvedFieldDescription(): + if field.params is None: + return [] + return [ + param + for _, param in field.params + if isinstance(param, ResolvedValueMap) and not is_deployment_key(param.keyPath) + ] + case ResolvedFieldGroup(): + return [value_map for sub_field in field.fields for value_map in _value_maps(sub_field)] + case _: + assert_never(field) diff --git a/src/erc7730/lint/v2/path_schemas.py b/src/erc7730/lint/v2/path_schemas.py index 4ea4da79..4df971e8 100644 --- a/src/erc7730/lint/v2/path_schemas.py +++ b/src/erc7730/lint/v2/path_schemas.py @@ -24,7 +24,9 @@ ResolvedTokenAmountParameters, ResolvedTokenTickerParameters, ResolvedUnitParameters, + ResolvedValueMap, ) +from erc7730.model.types import Address def compute_format_schema_paths(fmt: ResolvedFormat) -> FormatPaths: @@ -48,9 +50,11 @@ def add_path(path: DescriptorPath | DataPath | ContainerPath | None) -> None: case DescriptorPath(): pass # descriptor paths are not schema paths - def add_value(value: ResolvedValue | None) -> None: + def add_value(value: ResolvedValue | ResolvedValueMap | Address | list[Address] | int | None) -> None: if isinstance(value, ResolvedValuePath): add_path(value.path) + elif isinstance(value, ResolvedValueMap): + add_path(value.keyPath) def append_field(field: ResolvedField) -> None: match field: @@ -64,19 +68,20 @@ def append_field(field: ResolvedField) -> None: case None: pass case ResolvedAddressNameParameters(): - pass + add_value(field.params.senderAddress) case ResolvedInteroperableAddressNameParameters(): - pass + add_value(field.params.senderAddress) case ResolvedCallDataParameters(): add_value(field.params.callee) add_value(field.params.selector) add_value(field.params.amount) add_value(field.params.spender) case ResolvedTokenAmountParameters(): - if isinstance(field.params.token, ResolvedValuePath): - add_path(field.params.token.path) + add_value(field.params.token) + add_value(field.params.chainId) add_path(field.params.chainIdPath) case ResolvedTokenTickerParameters(): + add_value(field.params.chainId) add_path(field.params.chainIdPath) case ResolvedNftNameParameters(): add_value(field.params.collection) diff --git a/src/erc7730/main.py b/src/erc7730/main.py index 13c04979..de475881 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -237,23 +237,28 @@ def command_calldata( v2: Annotated[bool, Option("--v2", help="Force v2 mode")] = False, ) -> None: source_url = HttpUrl(source) if source is not None else None + out = ConsoleOutputAdder() if v2 or _any_v2_descriptor([input_erc7730_path]): from erc7730.model.input.v2.descriptor import InputERC7730Descriptor as InputERC7730DescriptorV2 input_descriptor_v2 = InputERC7730DescriptorV2.load(input_erc7730_path) calldata_descriptors = erc7730_v2_descriptor_to_calldata_descriptors( - input_descriptor_v2, source=source_url, chain_id=chain_id + input_descriptor_v2, source=source_url, chain_id=chain_id, out=out ) else: input_descriptor = InputERC7730Descriptor.load(input_erc7730_path) calldata_descriptors = erc7730_descriptor_to_calldata_descriptors( - input_descriptor, source=source_url, chain_id=chain_id + input_descriptor, source=source_url, chain_id=chain_id, out=out ) model = RootModel[list[CalldataDescriptor]](calldata_descriptors) builtins.print(model.model_dump_json(indent=2, exclude_none=True)) + # descriptors failing to convert are left out of the output, so the output alone does not tell an error happened + if out.has_errors: + raise Exit(1) + if __name__ == "__main__": app() diff --git a/src/erc7730/model/calldata/v1/descriptor.py b/src/erc7730/model/calldata/v1/descriptor.py index 1dd81f65..dc1d9da5 100644 --- a/src/erc7730/model/calldata/v1/descriptor.py +++ b/src/erc7730/model/calldata/v1/descriptor.py @@ -10,6 +10,7 @@ from erc7730.model.calldata.v1.instruction import ( CalldataDescriptorInstructionEnumValueV1, CalldataDescriptorInstructionFieldV1, + CalldataDescriptorInstructionMapEntryV1, CalldataDescriptorInstructionTransactionInfoV1, ) @@ -38,6 +39,13 @@ class CalldataDescriptorV1(CalldataDescriptorBase): description="Descriptor and metadata to craft ENUM APDUs.", ) + maps: list[CalldataDescriptorInstructionMapEntryV1] = Field( + default_factory=list, + title="MAP_ENTRY instructions descriptors", + description="Descriptor and metadata to craft MAP_ENTRY APDUs, one per key of the maps looked up by the " + "device. Only the entries matching the keys read from the transaction need to be provided.", + ) + fields: list[CalldataDescriptorInstructionFieldV1] = Field( title="FIELD instructions descriptors", description="Descriptor and metadata to craft FIELD APDUs.", diff --git a/src/erc7730/model/calldata/v1/instruction.py b/src/erc7730/model/calldata/v1/instruction.py index 69721014..8e2631c9 100644 --- a/src/erc7730/model/calldata/v1/instruction.py +++ b/src/erc7730/model/calldata/v1/instruction.py @@ -20,7 +20,7 @@ from erc7730.model.calldata.v1.struct import ( CalldataDescriptorStructV1, ) -from erc7730.model.types import Address, HexStr, Selector +from erc7730.model.types import Address, HexStr, ScalarType, Selector CalldataDescriptorInstructionHex = Annotated[ HexStr, @@ -46,6 +46,9 @@ # maximum number of CONSTRAINT tags per FIELD struct, as documented in app-ethereum MAX_FIELD_CONSTRAINTS = 5 +# maximum size of a MAP_ENTRY key or value (MAP_ENTRY_MAX_KEY_SIZE / MAP_ENTRY_MAX_VALUE_SIZE in app-ethereum) +MAX_MAP_ENTRY_SIZE = 32 + @pydantic_enum_by_name class CalldataDescriptorFieldVisibilityV1(IntEnum): @@ -213,6 +216,88 @@ def descriptor(self) -> CalldataDescriptorInstructionHex: return tlv_enum_value(self).hex() +class CalldataDescriptorInstructionMapEntryV1(CalldataDescriptorInstructionBaseV1): + """ + Instruction descriptor for the MAP_ENTRY struct. + + One struct is emitted per key of a map looked up by the device (see MAP_REF values). The wallet only provides the + entry matching the key read from the transaction, after it has been signed with the calldata key. + """ + + version: Literal[1] = Field( + default=1, + title="Struct version", + description="Version of the MAP_ENTRY struct", + ) + + chain_id: int = Field( + title="Chain ID", + description="The contract deployment EIP-155 chain id.", + ge=1, + ) + + address: Address = Field( + title="Contract address", + description="The contract deployment address.", + ) + + selector: Selector = Field( + title="Function selector", + description="The 4-bytes function selector this descriptor applies to.", + ) + + map: str = Field( + title="Source map reference", + description="Path of the map in the source descriptor.", + ) + + id: int = Field( + title="Map identifier", + description="Identifier of the map (to differentiate multiple maps in one contract)", + ge=0, + le=255, + ) + + key_source: str = Field( + title="Source map key", + description="The map key, as written in the source descriptor.", + ) + + key: HexStr = Field( + title="Map key", + description="The map key, encoded as the device reads it from the transaction (raw bytes, hex encoded).", + ) + + value_source: ScalarType = Field( + title="Source map value", + description="The map value, as written in the source descriptor.", + ) + + value: HexStr = Field( + title="Map value", + description="The map value, encoded for the device (raw bytes, hex encoded).", + ) + + @model_validator(mode="after") + def _validate_sizes(self) -> Self: + # key and value are copied into fixed size buffers by the device (MAP_ENTRY_MAX_KEY_SIZE, + # MAP_ENTRY_MAX_VALUE_SIZE), and an empty one is rejected + for name, payload in (("key", self.key), ("value", self.value)): + digits = payload.removeprefix("0x") + if len(digits) % 2 != 0: + raise ValueError(f"Map entry {name} must be whole bytes, got {len(digits)} hex digits.") + if not 1 <= len(digits) // 2 <= MAX_MAP_ENTRY_SIZE: + raise ValueError(f"Map entry {name} must be 1 to {MAX_MAP_ENTRY_SIZE} bytes, got {len(digits) // 2}.") + return self + + @computed_field(title="Descriptor", description="Hex encoded MAP_ENTRY TLV struct") # type: ignore[misc] + @cached_property + def descriptor(self) -> CalldataDescriptorInstructionHex: + from erc7730.convert.calldata.v1.tlv import tlv_map_entry + + return tlv_map_entry(self).hex() + + class CalldataDescriptorInstructionFieldV1(CalldataDescriptorInstructionBaseV1): """Instruction descriptor for the FIELD struct.""" diff --git a/src/erc7730/model/calldata/v1/value.py b/src/erc7730/model/calldata/v1/value.py index 58817a59..26be1370 100644 --- a/src/erc7730/model/calldata/v1/value.py +++ b/src/erc7730/model/calldata/v1/value.py @@ -299,7 +299,53 @@ class CalldataDescriptorValueConstantV1(CalldataDescriptorValueBaseV1): ) +class CalldataDescriptorMapRefV1(CalldataDescriptorStructV1): + """Descriptor for the MAP_REF struct.""" + + version: Literal[1] = Field( + default=1, + title="Struct version", + description="Version of the MAP_REF struct", + ) + + map: str = Field( + title="Source map reference", + description="Path of the referenced map in the source descriptor (not serialized).", + ) + + id: int = Field( + title="Map identifier", + description="Identifier of the map (references the MAP_ENTRY structs carrying its values)", + ge=0, + le=255, + ) + + key: CalldataDescriptorValuePathV1 = Field( + title="Key", + description="Value read from the transaction to get the key to look up in the map", + ) + + +class CalldataDescriptorValueMapRefV1(CalldataDescriptorValueBaseV1): + """ + A value looked up by the device in a map, using a key read from the transaction. + + The map values are provided to the device by MAP_ENTRY structs. + """ + + type: Literal["map"] = Field( + default="map", + title="Value Type", + description="The value type identifier (discriminator for values discriminated union).", + ) + + map_ref: CalldataDescriptorMapRefV1 = Field( + title="Map reference", + description="Reference to the map and the key to look up in it", + ) + + CalldataDescriptorValueV1 = Annotated[ - CalldataDescriptorValuePathV1 | CalldataDescriptorValueConstantV1, + CalldataDescriptorValuePathV1 | CalldataDescriptorValueConstantV1 | CalldataDescriptorValueMapRefV1, Discriminator("type"), ] diff --git a/src/erc7730/model/input/path.py b/src/erc7730/model/input/path.py index 0dc0fb3c..ab4c661b 100644 --- a/src/erc7730/model/input/path.py +++ b/src/erc7730/model/input/path.py @@ -2,27 +2,44 @@ from pydantic import Field as PydanticField from pydantic import GetPydanticSchema -from pydantic_core import core_schema +from pydantic_core import PydanticSerializationUnexpectedValue, core_schema from pydantic_core.core_schema import ( chain_schema, is_instance_schema, json_or_python_schema, no_info_plain_validator_function, + plain_serializer_function_ser_schema, str_schema, - to_string_ser_schema, ) from erc7730.common.pydantic import ErrorTypeLabel from erc7730.model.paths import ContainerPath, DataPath, DescriptorPath from erc7730.model.paths.path_parser import to_path + +def _path_ser_schema(path_type: type[ContainerPath | DataPath | DescriptorPath]) -> core_schema.SerSchema: + """ + Serialize a path as a string, rejecting values of other types. + + In a union (for instance a path or a map reference), the serializer of each member is tried in turn: serializing any + value as a string would turn other members into their repr string, so other types must be rejected. + """ + + def serialize(value: object) -> str: + if not isinstance(value, path_type): + raise PydanticSerializationUnexpectedValue(f"Expected {path_type.__name__}, got {type(value).__name__}") + return str(value) + + return plain_serializer_function_ser_schema(serialize, return_schema=str_schema()) + + CONTAINER_PATH_STR_JSON_SCHEMA = chain_schema( [str_schema(), no_info_plain_validator_function(to_path), is_instance_schema(ContainerPath)] ) CONTAINER_PATH_STR_CORE_SCHEMA = json_or_python_schema( json_schema=CONTAINER_PATH_STR_JSON_SCHEMA, python_schema=core_schema.union_schema([is_instance_schema(ContainerPath), CONTAINER_PATH_STR_JSON_SCHEMA]), - serialization=to_string_ser_schema(), + serialization=_path_ser_schema(ContainerPath), ) ContainerPathStr = Annotated[ ContainerPath, @@ -43,7 +60,7 @@ DATA_PATH_STR_CORE_SCHEMA = json_or_python_schema( json_schema=DATA_PATH_STR_JSON_SCHEMA, python_schema=core_schema.union_schema([is_instance_schema(DataPath), DATA_PATH_STR_JSON_SCHEMA]), - serialization=to_string_ser_schema(), + serialization=_path_ser_schema(DataPath), ) DataPathStr = Annotated[ DataPath, @@ -63,7 +80,7 @@ DESCRIPTOR_PATH_STR_CORE_SCHEMA = json_or_python_schema( json_schema=DESCRIPTOR_PATH_STR_JSON_SCHEMA, python_schema=core_schema.union_schema([is_instance_schema(DescriptorPath), DESCRIPTOR_PATH_STR_JSON_SCHEMA]), - serialization=to_string_ser_schema(), + serialization=_path_ser_schema(DescriptorPath), ) DescriptorPathStr = Annotated[ DescriptorPath, diff --git a/src/erc7730/model/resolved/v2/display.py b/src/erc7730/model/resolved/v2/display.py index 847cf1db..9e8b6a51 100644 --- a/src/erc7730/model/resolved/v2/display.py +++ b/src/erc7730/model/resolved/v2/display.py @@ -5,7 +5,7 @@ JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v2.schema.json """ -from typing import Annotated, ForwardRef, Self +from typing import Annotated, ForwardRef, Literal, Self from pydantic import Discriminator, Field, Tag, model_validator @@ -21,12 +21,50 @@ field_parameters_discriminator, visibility_rules_discriminator, ) -from erc7730.model.resolved.display import ResolvedValue +from erc7730.model.resolved.display import ResolvedValueConstant, ResolvedValuePath +from erc7730.model.resolved.path import ResolvedPath from erc7730.model.types import Address, HexStr, Id, ScalarType # ruff: noqa: N815 - camel case field names are tolerated to match schema +class ResolvedValueMap(Model): + """ + A constant value looked up in a map, using a key read from the structured data or its container (resolved). + + The map values are inlined and encoded, the key is only known when the structured data is signed (or, for keys + read from the container like the chain id or the target contract address, for a given deployment). + """ + + type: Literal["map"] = Field( + default="map", + title="Value Type", + description="The value type identifier (discriminator for values discriminated union).", + ) + + map: DescriptorPathStr = Field( + title="Map Reference", + description="The path to the referenced map, identifying the map when its values are served to a wallet.", + ) + + keyPath: ResolvedPath = Field( + title="Key Path", + description="The path to the key used to look up the value in the map.", + ) + + values: dict[str, ResolvedValueConstant] = Field( + title="Map Values", + description="The resolved constant values, indexed by key.", + min_length=1, + ) + + +ResolvedValueOrMap = Annotated[ + ResolvedValuePath | ResolvedValueConstant | ResolvedValueMap, + Discriminator("type"), +] + + class ResolvedVisibilityConditions(Model): """ Complex visibility conditions for field display rules (resolved). @@ -96,10 +134,10 @@ class ResolvedTokenAmountParameters(Model): Token Amount Formatting Parameters (resolved). """ - token: ResolvedValue | None = Field( + token: ResolvedValueOrMap | None = Field( default=None, title="Token", - description="The resolved address of the token contract, either as path or constant value. Used to " + description="The resolved address of the token contract, either as path, constant value or map lookup. Used to " "associate correct ticker. If ticker is not found or value is not set, the wallet SHOULD display the " 'raw value instead with an "Unknown token" warning.', ) @@ -126,11 +164,11 @@ class ResolvedTokenAmountParameters(Model): description="The resolved message to display when the amount is above the threshold.", ) - chainId: int | None = Field( + chainId: int | ResolvedValueMap | None = Field( default=None, title="Chain ID", description=( - "Optional. The resolved chain on which the token is deployed. " + "Optional. The resolved chain on which the token is deployed, as constant or map lookup. " "When present, the wallet SHOULD resolve token metadata (ticker, decimals) for this chain. " "Useful for cross-chain swap clear signing where the same token address may refer to different chains." ), @@ -174,11 +212,12 @@ class ResolvedAddressNameParameters(Model): min_length=1, ) - senderAddress: Address | list[Address] | None = Field( + senderAddress: Address | list[Address] | ResolvedValueMap | None = Field( default=None, title="Sender Address", description="Either a string or an array of strings. If the address pointed to by addressName is equal to one " - "of the addresses in senderAddress, the addressName is interpreted as the sender referenced by @.from", + "of the addresses in senderAddress, the addressName is interpreted as the sender referenced by @.from. Can " + "also be a map lookup.", ) @@ -201,10 +240,10 @@ class ResolvedInteroperableAddressNameParameters(Model): min_length=1, ) - senderAddress: Address | list[Address] | None = Field( + senderAddress: Address | list[Address] | ResolvedValueMap | None = Field( default=None, title="Sender Address", - description="Either a string or an array of strings for sender address matching.", + description="Either a string or an array of strings for sender address matching, or a map lookup.", ) @@ -213,30 +252,31 @@ class ResolvedCallDataParameters(Model): Embedded Calldata Formatting Parameters (resolved). """ - callee: ResolvedValue = Field( + callee: ResolvedValueOrMap = Field( title="Callee", - description="The resolved address of the contract being called by this embedded calldata, either as path " - "or constant value.", + description="The resolved address of the contract being called by this embedded calldata, either as path, " + "constant value or map lookup.", ) - selector: ResolvedValue | None = Field( + selector: ResolvedValueOrMap | None = Field( default=None, title="Called Selector", - description="The resolved selector being called, if not contained in the calldata, either as path or " - "constant value.", + description="The resolved selector being called, if not contained in the calldata, either as path, " + "constant value or map lookup.", ) - amount: ResolvedValue | None = Field( + amount: ResolvedValueOrMap | None = Field( default=None, title="Amount", - description="The resolved amount being transferred, if not contained in the calldata, either as path or " - "constant value.", + description="The resolved amount being transferred, if not contained in the calldata, either as path, " + "constant value or map lookup.", ) - spender: ResolvedValue | None = Field( + spender: ResolvedValueOrMap | None = Field( default=None, title="Spender", - description="The resolved spender, if not contained in the calldata, either as path or constant value.", + description="The resolved spender, if not contained in the calldata, either as path, constant value or map " + "lookup.", ) @@ -245,9 +285,9 @@ class ResolvedNftNameParameters(Model): NFT Names Formatting Parameters (resolved). """ - collection: ResolvedValue = Field( + collection: ResolvedValueOrMap = Field( title="Collection", - description="The resolved address of the collection contract, either as path or constant value.", + description="The resolved address of the collection contract, either as path, constant value or map lookup.", ) @@ -304,11 +344,11 @@ class ResolvedTokenTickerParameters(Model): Token Ticker Formatting Parameters (resolved). """ - chainId: int | None = Field( + chainId: int | ResolvedValueMap | None = Field( default=None, title="Chain ID", description=( - "Optional. The resolved chain on which the token is deployed. " + "Optional. The resolved chain on which the token is deployed, as constant or map lookup. " "When present, the wallet SHOULD resolve the token ticker for this chain. " "Useful for cross-chain swap clear signing." ), diff --git a/tests/test_main.py b/tests/test_main.py index fac9e546..b80ebf23 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -105,3 +105,40 @@ def test_convert_registry_files_to_legacy_eip712_files(input_file: Path, tmp_pat out = "".join(result.stdout.splitlines()) assert "generated" in out assert "✅" in out + + +def test_calldata_fails_on_conversion_error(tmp_path: Path) -> None: + # a map key that cannot be encoded as a uint32 makes the selector fail to convert + descriptor = { + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + "context": { + "$id": "test", + "contract": {"deployments": [{"chainId": 1, "address": "0x0000000000000000000000000000000000000001"}]}, + }, + "metadata": { + "owner": "Test Owner", + "maps": {"tokens": {"values": {"4294967296": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}}}, + }, + "display": { + "formats": { + "deposit(uint256 assets, uint32 marketId)": { + "intent": "Deposit", + "fields": [ + { + "path": "assets", + "label": "Amount", + "format": "tokenAmount", + "params": {"token": {"map": "$.metadata.maps.tokens", "keyPath": "#.marketId"}}, + } + ], + } + } + }, + } + input_file = tmp_path / "calldata-test.json" + input_file.write_text(json.dumps(descriptor)) + + result = runner.invoke(app, ["calldata", "--v2", str(input_file)]) + + assert result.exit_code == 1, result.output + assert "Invalid map key" in result.output diff --git a/tests/v2/convert/calldata/test_convert_fields.py b/tests/v2/convert/calldata/test_convert_fields.py index ec3218a0..694c86e6 100644 --- a/tests/v2/convert/calldata/test_convert_fields.py +++ b/tests/v2/convert/calldata/test_convert_fields.py @@ -4,6 +4,7 @@ import pytest from pydantic import ValidationError +from erc7730.common.output import ListOutputAdder from erc7730.convert.calldata.convert_erc7730_v2_input_to_calldata import ( erc7730_v2_descriptor_to_calldata_descriptors, ) @@ -223,6 +224,211 @@ def test_convert_token_amount_resolves_token_path() -> None: assert field.param.token is not None +MAP_ADDRESSES = { + 1: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + 8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", +} +MAP_SELECTORS = {1: "0xa9059cbb", 8453: "0x095ea7b3"} +MAP_AMOUNTS = {1: 1000, 8453: 2000} + + +def map_ref(map_name: str, key_path: str = "@.chainId") -> dict[str, str]: + return {"map": f"$.metadata.maps.{map_name}", "keyPath": key_path} + + +def convert_with_maps(signature: str, field: dict[str, Any], chain_id: int) -> list[Any]: + """Build a 2 deployments descriptor around a single display field using maps, and convert it for a chain.""" + descriptor = InputERC7730Descriptor.model_validate_json( + json.dumps( + { + "$schema": "specs/erc7730-v2.schema.json", + "context": { + "$id": "test", + "contract": { + "deployments": [ + {"chainId": 1, "address": DEFAULT_ADDRESS}, + {"chainId": 8453, "address": "0x0000000000000000000000000000000000000002"}, + ] + }, + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "addresses": {"values": {str(k): v for k, v in MAP_ADDRESSES.items()}}, + "selectors": {"values": {str(k): v for k, v in MAP_SELECTORS.items()}}, + "amounts": {"values": {str(k): v for k, v in MAP_AMOUNTS.items()}}, + }, + }, + "display": {"formats": {signature: {"intent": "Test intent", "fields": [field]}}}, + } + ) + ) + return erc7730_v2_descriptor_to_calldata_descriptors(descriptor, chain_id=chain_id) + + +def convert_map_token_descriptor(key_path: str, chain_id: int) -> list[Any]: + """Convert a token amount field looking up its token in a map, for a chain.""" + return convert_with_maps( + "deposit(uint256 assets, uint256 key)", + { + "path": "assets", + "label": "Deposit asset", + "format": "tokenAmount", + "params": {"token": map_ref("addresses", key_path)}, + }, + chain_id, + ) + + +@pytest.mark.parametrize("chain_id", [1, 8453]) +def test_convert_token_amount_resolves_map_per_deployment(chain_id: int) -> None: + descriptors = convert_map_token_descriptor("@.chainId", chain_id) + + assert len(descriptors) == 1 + param = descriptors[0].fields[0].param + assert isinstance(param, CalldataDescriptorParamTokenAmountV1) + assert isinstance(param.token, CalldataDescriptorValueConstantV1) + assert param.token.value == MAP_ADDRESSES[chain_id] + assert param.token.type_family == CalldataDescriptorTypeFamily.ADDRESS + + +@pytest.mark.parametrize("format", ["tokenAmount", "tokenTicker"]) +@pytest.mark.parametrize( + ("chain_params", "rejected"), + [ + pytest.param({}, False, id="no_chain"), + pytest.param({"chainId": 1}, False, id="same_chain"), + pytest.param({"chainId": 8453}, True, id="other_chain"), + pytest.param({"chainId": map_ref("sameChains")}, False, id="map_same_chain"), + pytest.param({"chainId": map_ref("chains")}, True, id="map_other_chain"), + pytest.param({"chainId": map_ref("chains", "#.key")}, True, id="map_data_key"), + pytest.param({"chainIdPath": "@.chainId"}, False, id="path_transaction_chain"), + pytest.param({"chainIdPath": "#.key"}, True, id="path_data"), + ], +) +def test_convert_token_rejects_other_chain_id(format: str, chain_params: dict[str, Any], rejected: bool) -> None: + # the token amount is on the amount, the token ticker on the token address + field: dict[str, Any] = ( + {"path": "assets", "format": "tokenAmount", "params": {"tokenPath": "token", **chain_params}} + if format == "tokenAmount" + else {"path": "token", "format": "tokenTicker", "params": chain_params or None} + ) + descriptor = InputERC7730Descriptor.model_validate_json( + json.dumps( + { + "$schema": "specs/erc7730-v2.schema.json", + "context": { + "$id": "test", + "contract": {"deployments": [{"chainId": DEFAULT_CHAIN_ID, "address": DEFAULT_ADDRESS}]}, + }, + "metadata": { + "owner": "Test Owner", + "maps": {"chains": {"values": {"1": 8453}}, "sameChains": {"values": {"1": 1}}}, + }, + "display": { + "formats": { + "deposit(address token, uint256 assets, uint256 key)": { + "intent": "Test intent", + "fields": [{"label": "Token", **field}], + } + } + }, + } + ) + ) + out = ListOutputAdder() + descriptors = erc7730_v2_descriptor_to_calldata_descriptors(descriptor, chain_id=DEFAULT_CHAIN_ID, out=out) + + # the device looks the token up on the transaction chain, so a token on another chain must not be displayed + assert len(descriptors) == (0 if rejected else 1), out.outputs + assert any(output.title == "Unsupported token chain" for output in out.outputs) == rejected, out.outputs + + +@pytest.mark.parametrize("chain_id", [1, 8453]) +def test_convert_calldata_resolves_maps_per_deployment(chain_id: int) -> None: + descriptors = convert_with_maps( + "execute(address target, bytes data)", + { + "path": "data", + "label": "Embedded call", + "format": "calldata", + "params": { + "callee": map_ref("addresses"), + "selector": map_ref("selectors"), + "amount": map_ref("amounts"), + "spender": map_ref("addresses"), + }, + }, + chain_id, + ) + + assert len(descriptors) == 1 + param = descriptors[0].fields[0].param + assert isinstance(param, CalldataDescriptorParamCalldataV1) + for value, expected in ( + (param.callee, MAP_ADDRESSES[chain_id]), + (param.selector, MAP_SELECTORS[chain_id]), + (param.amount, MAP_AMOUNTS[chain_id]), + (param.spender, MAP_ADDRESSES[chain_id]), + ): + assert isinstance(value, CalldataDescriptorValueConstantV1) + assert value.value == expected + + +@pytest.mark.parametrize( + ("param", "map_name"), [("selector", "selectors"), ("amount", "amounts"), ("spender", "addresses")] +) +def test_convert_calldata_rejects_optional_map_with_invalid_keys(param: str, map_name: str) -> None: + # an optional parameter that is set but cannot be converted must not be silently omitted: here the map is keyed on + # chain ids, which cannot match the sender address + descriptors = convert_with_maps( + "execute(address target, bytes data)", + { + "path": "data", + "label": "Embedded call", + "format": "calldata", + "params": {"calleePath": "target", param: map_ref(map_name, "@.from")}, + }, + 1, + ) + + assert descriptors == [] + + +@pytest.mark.parametrize("chain_id", [1, 8453]) +def test_convert_nft_name_resolves_collection_map_per_deployment(chain_id: int) -> None: + descriptors = convert_with_maps( + "transferNft(uint256 tokenId)", + {"path": "tokenId", "label": "NFT", "format": "nftName", "params": {"collection": map_ref("addresses")}}, + chain_id, + ) + + assert len(descriptors) == 1 + param = descriptors[0].fields[0].param + assert isinstance(param, CalldataDescriptorParamNFTV1) + assert isinstance(param.collection, CalldataDescriptorValueConstantV1) + assert param.collection.value == MAP_ADDRESSES[chain_id] + + +@pytest.mark.parametrize("chain_id", [1, 8453]) +def test_convert_address_name_resolves_sender_address_map_per_deployment(chain_id: int) -> None: + descriptors = convert_with_maps( + "transfer(address to, uint256 amount)", + { + "path": "to", + "label": "To", + "format": "addressName", + "params": {"types": ["eoa"], "senderAddress": map_ref("addresses")}, + }, + chain_id, + ) + + assert len(descriptors) == 1 + param = descriptors[0].fields[0].param + assert isinstance(param, CalldataDescriptorParamTrustedNameV1) + assert param.sender_addresses == [MAP_ADDRESSES[chain_id].lower()] + + def convert_descriptor(signature: str, field: dict[str, Any]) -> list[Any]: """Build a minimal v2 descriptor around a single display field and return the raw conversion result.""" descriptor = InputERC7730Descriptor.model_validate_json( diff --git a/tests/v2/convert/calldata/test_convert_maps.py b/tests/v2/convert/calldata/test_convert_maps.py new file mode 100644 index 00000000..2cc13322 --- /dev/null +++ b/tests/v2/convert/calldata/test_convert_maps.py @@ -0,0 +1,336 @@ +""" +Tests for map lookups the device does itself: MAP_REF values, and the MAP_ENTRY structs carrying the map values. + +Expected encodings follow app-ethereum (doc/tlv_structs.md#map_entry, src/features/provide_map_entry and +src/features/generic_tx_parser/gtp_value.c): the device reads the key from the transaction, then looks up the MAP_ENTRY +whose key matches it byte for byte. +""" + +import json +from typing import Any + +import pytest + +from erc7730.convert.calldata.convert_erc7730_v2_input_to_calldata import ( + erc7730_v2_descriptor_to_calldata_descriptors, +) +from erc7730.convert.calldata.v1.tlv import tlv_value +from erc7730.model.calldata.v1.descriptor import CalldataDescriptorV1 +from erc7730.model.calldata.v1.param import ( + CalldataDescriptorParamCalldataV1, + CalldataDescriptorParamNFTV1, + CalldataDescriptorParamTokenAmountV1, +) +from erc7730.model.calldata.v1.value import ( + CalldataDescriptorContainerPathV1, + CalldataDescriptorContainerPathValueV1, + CalldataDescriptorDataPathV1, + CalldataDescriptorTypeFamily, + CalldataDescriptorValueConstantV1, + CalldataDescriptorValueMapRefV1, +) +from erc7730.model.input.v2.descriptor import InputERC7730Descriptor + +ADDRESS = "0x0000000000000000000000000000000000000001" +OTHER_ADDRESS = "0x0000000000000000000000000000000000000002" +TOKEN_A = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +TOKEN_B = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + +DEPOSIT = "deposit(uint256 assets, uint256 marketId)" +DEPOSIT_SELECTOR = "0xe2bbb158" + + +def map_ref(map_name: str, key_path: str) -> dict[str, str]: + return {"map": f"$.metadata.maps.{map_name}", "keyPath": key_path} + + +def token_amount(key_path: str, map_name: str = "tokens", path: str = "assets") -> dict[str, Any]: + return {"path": path, "label": "Amount", "format": "tokenAmount", "params": {"token": map_ref(map_name, key_path)}} + + +def convert( + signature: str, + fields: list[dict[str, Any]], + maps: dict[str, dict[str, Any]], + deployments: list[dict[str, Any]] | None = None, +) -> list[CalldataDescriptorV1]: + descriptor = InputERC7730Descriptor.model_validate_json( + json.dumps( + { + "$schema": "specs/erc7730-v2.schema.json", + "context": { + "$id": "test", + "contract": {"deployments": deployments or [{"chainId": 1, "address": ADDRESS}]}, + }, + "metadata": {"owner": "Test Owner", "maps": {name: {"values": v} for name, v in maps.items()}}, + "display": {"formats": {signature: {"intent": "Test intent", "fields": fields}}}, + } + ) + ) + return erc7730_v2_descriptor_to_calldata_descriptors(descriptor) + + +def convert_one(signature: str, fields: list[dict[str, Any]], maps: dict[str, dict[str, Any]]) -> CalldataDescriptorV1: + descriptors = convert(signature, fields, maps) + assert len(descriptors) == 1 + return descriptors[0] + + +def entries(descriptor: CalldataDescriptorV1) -> list[tuple[int, str, str]]: + return [(entry.id, entry.key, entry.value) for entry in descriptor.maps] + + +def word(value: int) -> str: + return "0x" + value.to_bytes(32, "big").hex() + + +def test_data_keyed_map_is_looked_up_by_the_device() -> None: + descriptor = convert_one(DEPOSIT, [token_amount("marketId")], {"tokens": {"1": TOKEN_A, "42": TOKEN_B}}) + + param = descriptor.fields[0].param + assert isinstance(param, CalldataDescriptorParamTokenAmountV1) + token = param.token + assert isinstance(token, CalldataDescriptorValueMapRefV1) + assert token.type_family == CalldataDescriptorTypeFamily.ADDRESS + assert token.type_size == 20 + assert token.map_ref.version == 1 + assert token.map_ref.id == 0 + assert token.map_ref.map == "$.metadata.maps.tokens" + assert isinstance(token.map_ref.key.binary_path, CalldataDescriptorDataPathV1) + assert token.map_ref.key.type_family == CalldataDescriptorTypeFamily.UINT + + # a uint256 calldata key is read as its whole ABI encoded chunk + assert entries(descriptor) == [(0, word(1), TOKEN_A.lower()), (0, word(42), TOKEN_B.lower())] + for entry in descriptor.maps: + assert (entry.chain_id, entry.address, entry.selector) == (1, ADDRESS, DEPOSIT_SELECTOR) + assert [(entry.key_source, entry.value_source) for entry in descriptor.maps] == [("1", TOKEN_A), ("42", TOKEN_B)] + + +def test_map_ref_value_tlv() -> None: + descriptor = convert_one(DEPOSIT, [token_amount("marketId")], {"tokens": {"1": TOKEN_A}}) + param = descriptor.fields[0].param + assert isinstance(param, CalldataDescriptorParamTokenAmountV1) + token = param.token + assert isinstance(token, CalldataDescriptorValueMapRefV1) + + key = tlv_value(token.map_ref.key) + # key: VALUE(version 1, UINT, size 32, DATA_PATH(version 1, TUPLE 1, LEAF STATIC)) + assert key.hex() == "000101" + "010101" + "020120" + "030a" + "000101" + "01020001" + "040103" + + map_ref = "000101" + "010100" + "02" + f"{len(key):02x}" + key.hex() + assert tlv_value(token).hex() == "000101" + "010105" + "020114" + "06" + f"{len(map_ref) // 2:02x}" + map_ref + + # the field hash in TRANSACTION_INFO covers the MAP_REF + assert tlv_value(token).hex() in descriptor.fields[0].descriptor + + +def test_map_entry_tlv() -> None: + descriptor = convert_one(DEPOSIT, [token_amount("marketId")], {"tokens": {"175676": TOKEN_A}}) + + # MAP_ENTRY version is 1 (STRUCT_VERSION in map_entry.c), the signature is appended by CAL + assert descriptor.maps[0].descriptor == ( + "000101" + + "01080000000000000001" + + "0214" + + ADDRESS[2:] + + "0304" + + DEPOSIT_SELECTOR[2:] + + "040100" + + "0520" + + (175676).to_bytes(32, "big").hex() + + "0614" + + TOKEN_A[2:].lower() + ) + + +def test_map_entries_are_bound_to_each_deployment() -> None: + descriptors = convert( + DEPOSIT, + [token_amount("marketId")], + {"tokens": {"1": TOKEN_A}}, + deployments=[{"chainId": 1, "address": ADDRESS}, {"chainId": 8453, "address": OTHER_ADDRESS}], + ) + + assert [(d.maps[0].chain_id, d.maps[0].address) for d in descriptors] == [(1, ADDRESS), (8453, OTHER_ADDRESS)] + + +@pytest.mark.parametrize( + ("signature", "key_path", "key", "expected"), + [ + # static calldata values are read as their whole ABI encoded chunk + ("f(uint256 assets, uint8 k)", "k", "0x2a", word(42)), + ("f(uint256 assets, int16 k)", "k", "-2", "0x" + (-2).to_bytes(32, "big", signed=True).hex()), + ("f(uint256 assets, bool k)", "k", "true", word(1)), + ("f(uint256 assets, address k)", "k", TOKEN_A, "0x" + "00" * 12 + TOKEN_A[2:].lower()), + ("f(uint256 assets, bytes4 k)", "k", "0x12345678", "0x12345678" + "00" * 28), + # dynamic calldata values are read as their raw content + ("f(uint256 assets, string k)", "k", "USDC", "0x" + b"USDC".hex()), + ("f(uint256 assets, bytes k)", "k", "0xabcd", "0xabcd"), + # sliced values are read as the slice + ("f(uint256 assets, bytes k)", "k.[0:4]", "0xa9059cbb", "0xa9059cbb"), + ("f(uint256 assets, uint256 k)", "k.[-20:]", TOKEN_A, TOKEN_A.lower()), + ("f(uint256 assets, uint256 k)", "k.[-2:]", "258", "0x0102"), + # values from the transaction context + ("f(uint256 assets)", "@.from", TOKEN_A, TOKEN_A.lower()), + ("f(uint256 assets)", "@.value", "1000", word(1000)), + ], +) +def test_map_key_encoding(signature: str, key_path: str, key: str, expected: str) -> None: + descriptor = convert_one(signature, [token_amount(key_path)], {"tokens": {key: TOKEN_A}}) + + assert entries(descriptor) == [(0, expected, TOKEN_A.lower())] + + +def test_container_keyed_map_reads_container_value() -> None: + descriptor = convert_one(DEPOSIT, [token_amount("@.from")], {"tokens": {TOKEN_B: TOKEN_A}}) + + param = descriptor.fields[0].param + assert isinstance(param, CalldataDescriptorParamTokenAmountV1) + assert isinstance(param.token, CalldataDescriptorValueMapRefV1) + assert param.token.map_ref.key.binary_path == CalldataDescriptorContainerPathV1( + value=CalldataDescriptorContainerPathValueV1.FROM + ) + + +@pytest.mark.parametrize("key_path", ["@.chainId", "@.to"]) +def test_deployment_keyed_map_is_folded_into_a_constant(key_path: str) -> None: + # the descriptor is bound to a single deployment, so there is nothing to look up on the device + descriptor = convert_one(DEPOSIT, [token_amount(key_path)], {"tokens": {"1": TOKEN_A, ADDRESS: TOKEN_A}}) + + param = descriptor.fields[0].param + assert isinstance(param, CalldataDescriptorParamTokenAmountV1) + assert isinstance(param.token, CalldataDescriptorValueConstantV1) + assert descriptor.maps == [] + + +def test_map_used_twice_shares_its_entries() -> None: + descriptor = convert_one( + DEPOSIT, + [token_amount("marketId"), token_amount("marketId", path="marketId")], + {"tokens": {"1": TOKEN_A}}, + ) + + assert entries(descriptor) == [(0, word(1), TOKEN_A.lower())] + + +def test_map_used_with_another_encoding_gets_another_id() -> None: + # the same map keyed on a uint8 is read the same way, keyed on a string it is not + descriptor = convert_one( + "f(uint256 assets, uint256 a, uint8 b, string c)", + [token_amount("a"), token_amount("b", path="b"), token_amount("c", path="a")], + {"tokens": {"1": TOKEN_A}}, + ) + + assert entries(descriptor) == [(0, word(1), TOKEN_A.lower()), (1, "0x31", TOKEN_A.lower())] + ids = [field.param.token.map_ref.id for field in descriptor.fields] # type: ignore[union-attr] + assert ids == [0, 0, 1] + + +def test_maps_in_calldata_parameters() -> None: + descriptor = convert_one( + "execute(uint256 route, bytes data)", + [ + { + "path": "data", + "label": "Call", + "format": "calldata", + "params": { + "callee": map_ref("targets", "route"), + "selector": map_ref("selectors", "route"), + "amount": map_ref("amounts", "route"), + "spender": map_ref("targets", "route"), + }, + } + ], + {"targets": {"7": TOKEN_A}, "selectors": {"7": "0xa9059cbb"}, "amounts": {"7": 1000}}, + ) + + param = descriptor.fields[0].param + assert isinstance(param, CalldataDescriptorParamCalldataV1) + values = [param.callee, param.selector, param.amount, param.spender] + assert all(isinstance(value, CalldataDescriptorValueMapRefV1) for value in values) + assert [value.map_ref.id for value in values] == [0, 1, 2, 0] # type: ignore[union-attr] + assert entries(descriptor) == [ + (0, word(7), TOKEN_A.lower()), + (1, word(7), "0xa9059cbb"), + (2, word(7), "0x03e8"), + ] + + +def test_map_in_nft_collection() -> None: + descriptor = convert_one( + "transferNft(uint256 tokenId, uint256 kind)", + [{"path": "tokenId", "label": "NFT", "format": "nftName", "params": {"collection": map_ref("c", "kind")}}], + {"c": {"1": TOKEN_A}}, + ) + + param = descriptor.fields[0].param + assert isinstance(param, CalldataDescriptorParamNFTV1) + assert isinstance(param.collection, CalldataDescriptorValueMapRefV1) + assert entries(descriptor) == [(0, word(1), TOKEN_A.lower())] + + +def test_descriptor_with_maps_round_trips() -> None: + descriptor = convert_one(DEPOSIT, [token_amount("marketId")], {"tokens": {"1": TOKEN_A}}) + + assert CalldataDescriptorV1.model_validate_json(descriptor.model_dump_json()) == descriptor + + +@pytest.mark.parametrize( + ("signature", "field", "maps"), + [ + pytest.param(DEPOSIT, token_amount("marketId"), {"tokens": {"one": TOKEN_A}}, id="key_not_an_integer"), + pytest.param(DEPOSIT, token_amount("marketId"), {"tokens": {"-1": TOKEN_A}}, id="negative_unsigned_key"), + pytest.param( + "f(uint256 assets, uint8 k)", token_amount("k"), {"tokens": {"256": TOKEN_A}}, id="key_too_wide_for_type" + ), + pytest.param( + DEPOSIT, token_amount("marketId"), {"tokens": {"1": TOKEN_A, "0x01": TOKEN_B}}, id="duplicate_key" + ), + pytest.param(DEPOSIT, token_amount("marketId"), {"tokens": {"1": "0x1234"}}, id="value_not_an_address"), + pytest.param( + "f(uint256 assets, string k)", + token_amount("k"), + {"tokens": {"x" * 33: TOKEN_A}}, + id="key_too_long_for_device", + ), + pytest.param( + "f(uint256 assets, bytes k)", token_amount("k.[0:4]"), {"tokens": {"0x1234": TOKEN_A}}, id="key_not_slice" + ), + pytest.param( + "f(uint256 assets, uint256[] ks)", token_amount("ks.[]"), {"tokens": {"1": TOKEN_A}}, id="key_is_array" + ), + pytest.param( + "transfer(address to, uint256 key)", + { + "path": "to", + "label": "To", + "format": "addressName", + "params": {"types": ["eoa"], "senderAddress": map_ref("tokens", "key")}, + }, + {"tokens": {"1": TOKEN_A}}, + id="sender_address_cannot_be_looked_up", + ), + ], +) +def test_invalid_map_lookups_are_rejected(signature: str, field: dict[str, Any], maps: dict[str, Any]) -> None: + assert convert(signature, [field], maps) == [] + + +def test_map_value_too_long_for_device_is_rejected() -> None: + field = { + "path": "data", + "label": "Call", + "format": "calldata", + "params": {"calleePath": "target", "selector": map_ref("selectors", "route")}, + } + assert ( + convert("execute(address target, uint256 route, bytes data)", [field], {"selectors": {"1": "0x" + "ab" * 33}}) + == [] + ) + + +def test_single_array_element_key_is_supported() -> None: + descriptor = convert_one("f(uint256 assets, uint256[] ks)", [token_amount("ks.[1]")], {"tokens": {"1": TOKEN_A}}) + + assert entries(descriptor) == [(0, word(1), TOKEN_A.lower())] diff --git a/tests/v2/convert/resolved/data/definition_with_map_input.json b/tests/v2/convert/resolved/data/definition_with_map_input.json new file mode 100644 index 00000000..a8a90842 --- /dev/null +++ b/tests/v2/convert/resolved/data/definition_with_map_input.json @@ -0,0 +1,55 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "token-amount-map-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + }, + { + "chainId": 8453, + "address": "0x0000000000000000000000000000000000000002" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "underlyingToken": { + "$keyType": "chainId", + "values": { + "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + } + } + } + }, + "display": { + "formats": { + "deposit(uint256 assets, address receiver)": { + "intent": "Deposit", + "fields": [ + { + "path": "assets", + "$ref": "$.display.definitions.depositAsset" + } + ] + } + }, + "definitions": { + "depositAsset": { + "label": "Deposit asset", + "format": "tokenAmount", + "params": { + "token": { + "map": "$.metadata.maps.underlyingToken", + "keyPath": "@.chainId" + } + } + } + } + } +} diff --git a/tests/v2/convert/resolved/data/definition_with_map_resolved.json b/tests/v2/convert/resolved/data/definition_with_map_resolved.json new file mode 100644 index 00000000..e9deb360 --- /dev/null +++ b/tests/v2/convert/resolved/data/definition_with_map_resolved.json @@ -0,0 +1,58 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "token-amount-map-test", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0x0000000000000000000000000000000000000001" }, + { "chainId": 8453, "address": "0x0000000000000000000000000000000000000002" } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "underlyingToken": { + "$keyType": "chainId", + "values": { "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" } + } + } + }, + "display": { + "formats": { + "0x6e553f65": { + "intent": "Deposit", + "fields": [ + { + "label": "Deposit asset", + "format": "tokenAmount", + "params": { + "token": { + "type": "map", + "map": "$.metadata.maps.underlyingToken", + "keyPath": { "type": "container", "field": "chainId" }, + "values": { + "1": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "raw": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + }, + "8453": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "raw": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + } + } + }, + "path": "#.assets" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/maps_in_parameters_constant_key_input.json b/tests/v2/convert/resolved/data/maps_in_parameters_constant_key_input.json new file mode 100644 index 00000000..d4ffa4d6 --- /dev/null +++ b/tests/v2/convert/resolved/data/maps_in_parameters_constant_key_input.json @@ -0,0 +1,144 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "maps-in-parameters-constant-key-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + }, + { + "chainId": 8453, + "address": "0x0000000000000000000000000000000000000002" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "addresses": { + "$keyType": "chainId", + "values": { + "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + } + }, + "selectors": { + "$keyType": "chainId", + "values": { + "1": "0xa9059cbb", + "8453": "0x095ea7b3" + } + }, + "amounts": { + "$keyType": "chainId", + "values": { + "1": 1000, + "8453": 2000 + } + }, + "targetChains": { + "$keyType": "chainId", + "values": { + "1": 10, + "8453": 42161 + } + } + }, + "constants": { + "chain": "8453" + } + }, + "display": { + "formats": { + "execute(address target, bytes data)": { + "intent": "Execute", + "fields": [ + { + "path": "data", + "label": "Embedded call", + "format": "calldata", + "params": { + "callee": { + "map": "$.metadata.maps.addresses", + "keyPath": "$.metadata.constants.chain" + }, + "selector": { + "map": "$.metadata.maps.selectors", + "keyPath": "$.metadata.constants.chain" + }, + "amount": { + "map": "$.metadata.maps.amounts", + "keyPath": "$.metadata.constants.chain" + }, + "spender": { + "map": "$.metadata.maps.addresses", + "keyPath": "$.metadata.constants.chain" + } + } + } + ] + }, + "transferNft(uint256 tokenId)": { + "intent": "Transfer NFT", + "fields": [ + { + "path": "tokenId", + "label": "NFT", + "format": "nftName", + "params": { + "collection": { + "map": "$.metadata.maps.addresses", + "keyPath": "$.metadata.constants.chain" + } + } + } + ] + }, + "transfer(address to, uint256 amount, address token)": { + "intent": "Transfer", + "fields": [ + { + "path": "to", + "label": "To", + "format": "addressName", + "params": { + "types": [ + "eoa" + ], + "senderAddress": { + "map": "$.metadata.maps.addresses", + "keyPath": "$.metadata.constants.chain" + } + } + }, + { + "path": "amount", + "label": "Amount", + "format": "tokenAmount", + "params": { + "tokenPath": "token", + "chainId": { + "map": "$.metadata.maps.targetChains", + "keyPath": "$.metadata.constants.chain" + } + } + }, + { + "path": "token", + "label": "Token", + "format": "tokenTicker", + "params": { + "chainId": { + "map": "$.metadata.maps.targetChains", + "keyPath": "$.metadata.constants.chain" + } + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/maps_in_parameters_constant_key_resolved.json b/tests/v2/convert/resolved/data/maps_in_parameters_constant_key_resolved.json new file mode 100644 index 00000000..838dd9d5 --- /dev/null +++ b/tests/v2/convert/resolved/data/maps_in_parameters_constant_key_resolved.json @@ -0,0 +1,97 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "maps-in-parameters-constant-key-test", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0x0000000000000000000000000000000000000001" }, + { "chainId": 8453, "address": "0x0000000000000000000000000000000000000002" } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "constants": { "chain": "8453" }, + "maps": { + "addresses": { + "$keyType": "chainId", + "values": { "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" } + }, + "selectors": { "$keyType": "chainId", "values": { "1": "0xa9059cbb", "8453": "0x095ea7b3" } }, + "amounts": { "$keyType": "chainId", "values": { "1": 1000, "8453": 2000 } }, + "targetChains": { "$keyType": "chainId", "values": { "1": 10, "8453": 42161 } } + } + }, + "display": { + "formats": { + "0x1cff79cd": { + "intent": "Execute", + "fields": [ + { + "label": "Embedded call", + "format": "calldata", + "params": { + "callee": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "raw": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + }, + "selector": { "type": "constant", "type_family": "string", "type_size": 4, "value": "0x095ea7b3", "raw": "0x095ea7b3" }, + "amount": { "type": "constant", "type_family": "uint", "type_size": 2, "value": 2000, "raw": "0x07d0" }, + "spender": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "raw": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + }, + "path": "#.data" + } + ] + }, + "0x8e53f838": { + "intent": "Transfer NFT", + "fields": [ + { + "label": "NFT", + "format": "nftName", + "params": { + "collection": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "raw": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + }, + "path": "#.tokenId" + } + ] + }, + "0xdbba0f01": { + "intent": "Transfer", + "fields": [ + { + "label": "To", + "format": "addressName", + "params": { "types": ["eoa"], "senderAddress": ["0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"] }, + "path": "#.to" + }, + { + "label": "Amount", + "format": "tokenAmount", + "params": { + "token": { "type": "path", "path": { "type": "data", "absolute": true, "elements": [{ "type": "field", "identifier": "token" }] } }, + "chainId": 42161 + }, + "path": "#.amount" + }, + { "label": "Token", "format": "tokenTicker", "params": { "chainId": 42161 }, "path": "#.token" } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/maps_in_parameters_input.json b/tests/v2/convert/resolved/data/maps_in_parameters_input.json new file mode 100644 index 00000000..01e0269a --- /dev/null +++ b/tests/v2/convert/resolved/data/maps_in_parameters_input.json @@ -0,0 +1,141 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "maps-in-parameters-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + }, + { + "chainId": 8453, + "address": "0x0000000000000000000000000000000000000002" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "addresses": { + "$keyType": "chainId", + "values": { + "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + } + }, + "selectors": { + "$keyType": "chainId", + "values": { + "1": "0xa9059cbb", + "8453": "0x095ea7b3" + } + }, + "amounts": { + "$keyType": "chainId", + "values": { + "1": 1000, + "8453": 2000 + } + }, + "targetChains": { + "$keyType": "chainId", + "values": { + "1": 10, + "8453": 42161 + } + } + } + }, + "display": { + "formats": { + "execute(address target, bytes data)": { + "intent": "Execute", + "fields": [ + { + "path": "data", + "label": "Embedded call", + "format": "calldata", + "params": { + "callee": { + "map": "$.metadata.maps.addresses", + "keyPath": "@.chainId" + }, + "selector": { + "map": "$.metadata.maps.selectors", + "keyPath": "@.chainId" + }, + "amount": { + "map": "$.metadata.maps.amounts", + "keyPath": "@.chainId" + }, + "spender": { + "map": "$.metadata.maps.addresses", + "keyPath": "@.chainId" + } + } + } + ] + }, + "transferNft(uint256 tokenId)": { + "intent": "Transfer NFT", + "fields": [ + { + "path": "tokenId", + "label": "NFT", + "format": "nftName", + "params": { + "collection": { + "map": "$.metadata.maps.addresses", + "keyPath": "@.chainId" + } + } + } + ] + }, + "transfer(address to, uint256 amount, address token)": { + "intent": "Transfer", + "fields": [ + { + "path": "to", + "label": "To", + "format": "addressName", + "params": { + "types": [ + "eoa" + ], + "senderAddress": { + "map": "$.metadata.maps.addresses", + "keyPath": "@.chainId" + } + } + }, + { + "path": "amount", + "label": "Amount", + "format": "tokenAmount", + "params": { + "tokenPath": "token", + "chainId": { + "map": "$.metadata.maps.targetChains", + "keyPath": "@.chainId" + } + } + }, + { + "path": "token", + "label": "Token", + "format": "tokenTicker", + "params": { + "chainId": { + "map": "$.metadata.maps.targetChains", + "keyPath": "@.chainId" + } + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/maps_in_parameters_missing_key_input.json b/tests/v2/convert/resolved/data/maps_in_parameters_missing_key_input.json new file mode 100644 index 00000000..c3ee7ff9 --- /dev/null +++ b/tests/v2/convert/resolved/data/maps_in_parameters_missing_key_input.json @@ -0,0 +1,146 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "maps-in-parameters-missing-key-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + }, + { + "chainId": 8453, + "address": "0x0000000000000000000000000000000000000002" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "addresses": { + "$keyType": "chainId", + "values": { + "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + } + }, + "selectors": { + "$keyType": "chainId", + "values": { + "1": "0xa9059cbb", + "8453": "0x095ea7b3" + } + }, + "amounts": { + "$keyType": "chainId", + "values": { + "1": 1000, + "8453": 2000 + } + }, + "targetChains": { + "$keyType": "chainId", + "values": { + "1": 10, + "8453": 42161 + } + }, + "calleeAddresses": { + "values": { + "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + } + }, + "display": { + "formats": { + "execute(address target, bytes data)": { + "intent": "Execute", + "fields": [ + { + "path": "data", + "label": "Embedded call", + "format": "calldata", + "params": { + "callee": { + "map": "$.metadata.maps.calleeAddresses", + "keyPath": "@.chainId" + }, + "selector": { + "map": "$.metadata.maps.selectors", + "keyPath": "@.chainId" + }, + "amount": { + "map": "$.metadata.maps.amounts", + "keyPath": "@.chainId" + }, + "spender": { + "map": "$.metadata.maps.addresses", + "keyPath": "@.chainId" + } + } + } + ] + }, + "transferNft(uint256 tokenId)": { + "intent": "Transfer NFT", + "fields": [ + { + "path": "tokenId", + "label": "NFT", + "format": "nftName", + "params": { + "collection": { + "map": "$.metadata.maps.addresses", + "keyPath": "@.chainId" + } + } + } + ] + }, + "transfer(address to, uint256 amount, address token)": { + "intent": "Transfer", + "fields": [ + { + "path": "to", + "label": "To", + "format": "addressName", + "params": { + "types": [ + "eoa" + ], + "senderAddress": { + "map": "$.metadata.maps.addresses", + "keyPath": "@.chainId" + } + } + }, + { + "path": "amount", + "label": "Amount", + "format": "tokenAmount", + "params": { + "tokenPath": "token", + "chainId": { + "map": "$.metadata.maps.targetChains", + "keyPath": "@.chainId" + } + } + }, + { + "path": "token", + "label": "Token", + "format": "tokenTicker", + "params": { + "chainId": { + "map": "$.metadata.maps.targetChains", + "keyPath": "@.chainId" + } + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/maps_in_parameters_resolved.json b/tests/v2/convert/resolved/data/maps_in_parameters_resolved.json new file mode 100644 index 00000000..b7d471c2 --- /dev/null +++ b/tests/v2/convert/resolved/data/maps_in_parameters_resolved.json @@ -0,0 +1,200 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "maps-in-parameters-test", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0x0000000000000000000000000000000000000001" }, + { "chainId": 8453, "address": "0x0000000000000000000000000000000000000002" } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "addresses": { + "$keyType": "chainId", + "values": { "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" } + }, + "selectors": { "$keyType": "chainId", "values": { "1": "0xa9059cbb", "8453": "0x095ea7b3" } }, + "amounts": { "$keyType": "chainId", "values": { "1": 1000, "8453": 2000 } }, + "targetChains": { "$keyType": "chainId", "values": { "1": 10, "8453": 42161 } } + } + }, + "display": { + "formats": { + "0x1cff79cd": { + "intent": "Execute", + "fields": [ + { + "label": "Embedded call", + "format": "calldata", + "params": { + "callee": { + "type": "map", + "map": "$.metadata.maps.addresses", + "keyPath": { "type": "container", "field": "chainId" }, + "values": { + "1": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "raw": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + }, + "8453": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "raw": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + } + }, + "selector": { + "type": "map", + "map": "$.metadata.maps.selectors", + "keyPath": { "type": "container", "field": "chainId" }, + "values": { + "1": { "type": "constant", "type_family": "string", "type_size": 4, "value": "0xa9059cbb", "raw": "0xa9059cbb" }, + "8453": { "type": "constant", "type_family": "string", "type_size": 4, "value": "0x095ea7b3", "raw": "0x095ea7b3" } + } + }, + "amount": { + "type": "map", + "map": "$.metadata.maps.amounts", + "keyPath": { "type": "container", "field": "chainId" }, + "values": { + "1": { "type": "constant", "type_family": "uint", "type_size": 2, "value": 1000, "raw": "0x03e8" }, + "8453": { "type": "constant", "type_family": "uint", "type_size": 2, "value": 2000, "raw": "0x07d0" } + } + }, + "spender": { + "type": "map", + "map": "$.metadata.maps.addresses", + "keyPath": { "type": "container", "field": "chainId" }, + "values": { + "1": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "raw": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + }, + "8453": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "raw": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + } + } + }, + "path": "#.data" + } + ] + }, + "0x8e53f838": { + "intent": "Transfer NFT", + "fields": [ + { + "label": "NFT", + "format": "nftName", + "params": { + "collection": { + "type": "map", + "map": "$.metadata.maps.addresses", + "keyPath": { "type": "container", "field": "chainId" }, + "values": { + "1": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "raw": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + }, + "8453": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "raw": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + } + } + }, + "path": "#.tokenId" + } + ] + }, + "0xdbba0f01": { + "intent": "Transfer", + "fields": [ + { + "label": "To", + "format": "addressName", + "params": { + "types": ["eoa"], + "senderAddress": { + "type": "map", + "map": "$.metadata.maps.addresses", + "keyPath": { "type": "container", "field": "chainId" }, + "values": { + "1": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "raw": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + }, + "8453": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "raw": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + } + } + }, + "path": "#.to" + }, + { + "label": "Amount", + "format": "tokenAmount", + "params": { + "token": { "type": "path", "path": { "type": "data", "absolute": true, "elements": [{ "type": "field", "identifier": "token" }] } }, + "chainId": { + "type": "map", + "map": "$.metadata.maps.targetChains", + "keyPath": { "type": "container", "field": "chainId" }, + "values": { + "1": { "type": "constant", "type_family": "uint", "type_size": 1, "value": 10, "raw": "0x0a" }, + "8453": { "type": "constant", "type_family": "uint", "type_size": 2, "value": 42161, "raw": "0xa4b1" } + } + } + }, + "path": "#.amount" + }, + { + "label": "Token", + "format": "tokenTicker", + "params": { + "chainId": { + "type": "map", + "map": "$.metadata.maps.targetChains", + "keyPath": { "type": "container", "field": "chainId" }, + "values": { + "1": { "type": "constant", "type_family": "uint", "type_size": 1, "value": 10, "raw": "0x0a" }, + "8453": { "type": "constant", "type_family": "uint", "type_size": 2, "value": 42161, "raw": "0xa4b1" } + } + } + }, + "path": "#.token" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/token_amount_with_map_constant_key_input.json b/tests/v2/convert/resolved/data/token_amount_with_map_constant_key_input.json new file mode 100644 index 00000000..ff09ea6b --- /dev/null +++ b/tests/v2/convert/resolved/data/token_amount_with_map_constant_key_input.json @@ -0,0 +1,53 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "token-amount-map-constant-key-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + }, + { + "chainId": 8453, + "address": "0x0000000000000000000000000000000000000002" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "underlyingToken": { + "$keyType": "chainId", + "values": { + "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + } + } + }, + "constants": { + "chain": 8453 + } + }, + "display": { + "formats": { + "deposit(uint256 assets, address receiver)": { + "intent": "Deposit", + "fields": [ + { + "path": "assets", + "label": "Deposit asset", + "format": "tokenAmount", + "params": { + "token": { + "map": "$.metadata.maps.underlyingToken", + "keyPath": "$.metadata.constants.chain" + } + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/token_amount_with_map_constant_key_resolved.json b/tests/v2/convert/resolved/data/token_amount_with_map_constant_key_resolved.json new file mode 100644 index 00000000..1ad15b1b --- /dev/null +++ b/tests/v2/convert/resolved/data/token_amount_with_map_constant_key_resolved.json @@ -0,0 +1,45 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "token-amount-map-constant-key-test", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0x0000000000000000000000000000000000000001" }, + { "chainId": 8453, "address": "0x0000000000000000000000000000000000000002" } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "constants": { "chain": 8453 }, + "maps": { + "underlyingToken": { + "$keyType": "chainId", + "values": { "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" } + } + } + }, + "display": { + "formats": { + "0x6e553f65": { + "intent": "Deposit", + "fields": [ + { + "label": "Deposit asset", + "format": "tokenAmount", + "params": { + "token": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "raw": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + }, + "path": "#.assets" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/token_amount_with_map_input.json b/tests/v2/convert/resolved/data/token_amount_with_map_input.json new file mode 100644 index 00000000..3d2bf527 --- /dev/null +++ b/tests/v2/convert/resolved/data/token_amount_with_map_input.json @@ -0,0 +1,50 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "token-amount-map-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + }, + { + "chainId": 8453, + "address": "0x0000000000000000000000000000000000000002" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "underlyingToken": { + "$keyType": "chainId", + "values": { + "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + } + } + } + }, + "display": { + "formats": { + "deposit(uint256 assets, address receiver)": { + "intent": "Deposit", + "fields": [ + { + "path": "assets", + "label": "Deposit asset", + "format": "tokenAmount", + "params": { + "token": { + "map": "$.metadata.maps.underlyingToken", + "keyPath": "@.chainId" + } + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/token_amount_with_map_missing_key_input.json b/tests/v2/convert/resolved/data/token_amount_with_map_missing_key_input.json new file mode 100644 index 00000000..46dde969 --- /dev/null +++ b/tests/v2/convert/resolved/data/token_amount_with_map_missing_key_input.json @@ -0,0 +1,49 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "token-amount-map-missing-key-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + }, + { + "chainId": 8453, + "address": "0x0000000000000000000000000000000000000002" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "underlyingToken": { + "$keyType": "chainId", + "values": { + "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + } + }, + "display": { + "formats": { + "deposit(uint256 assets, address receiver)": { + "intent": "Deposit", + "fields": [ + { + "path": "assets", + "label": "Deposit asset", + "format": "tokenAmount", + "params": { + "token": { + "map": "$.metadata.maps.underlyingToken", + "keyPath": "@.chainId" + } + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/token_amount_with_map_resolved.json b/tests/v2/convert/resolved/data/token_amount_with_map_resolved.json new file mode 100644 index 00000000..e9deb360 --- /dev/null +++ b/tests/v2/convert/resolved/data/token_amount_with_map_resolved.json @@ -0,0 +1,58 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "token-amount-map-test", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0x0000000000000000000000000000000000000001" }, + { "chainId": 8453, "address": "0x0000000000000000000000000000000000000002" } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "underlyingToken": { + "$keyType": "chainId", + "values": { "1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" } + } + } + }, + "display": { + "formats": { + "0x6e553f65": { + "intent": "Deposit", + "fields": [ + { + "label": "Deposit asset", + "format": "tokenAmount", + "params": { + "token": { + "type": "map", + "map": "$.metadata.maps.underlyingToken", + "keyPath": { "type": "container", "field": "chainId" }, + "values": { + "1": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "raw": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + }, + "8453": { + "type": "constant", + "type_family": "address", + "type_size": 20, + "value": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "raw": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + } + } + }, + "path": "#.assets" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/test_convert_input_to_resolved.py b/tests/v2/convert/resolved/test_convert_input_to_resolved.py index 3f3dcd7e..4546a18b 100644 --- a/tests/v2/convert/resolved/test_convert_input_to_resolved.py +++ b/tests/v2/convert/resolved/test_convert_input_to_resolved.py @@ -115,6 +115,47 @@ label="token amount with chainId", description="using token amount format with chainId parameter for cross-chain tokens", ), + TestCase( + id="token_amount_with_map", + label="token amount with map", + description="using token amount format with token looked up in a map keyed on the chain id", + ), + TestCase( + id="token_amount_with_map_constant_key", + label="token amount with map and constant key", + description="using token amount format with token looked up in a map using a constant key, resolved as " + "a constant", + ), + TestCase( + id="token_amount_with_map_missing_key", + label="token amount with map missing a deployment key", + description="using token amount format with token looked up in a map keyed on the chain id, without a " + "value for one of the deployments", + error='Map used for "token" of field "Deposit asset" has no value for @.chainId "8453"', + ), + TestCase( + id="maps_in_parameters", + label="maps in parameters", + description="using maps keyed on the chain id for calldata, nft name, address name, token amount and " + "token ticker parameters", + ), + TestCase( + id="maps_in_parameters_constant_key", + label="maps in parameters with constant key", + description="using maps with a constant key in parameters, resolved as constants", + ), + TestCase( + id="maps_in_parameters_missing_key", + label="maps in parameters missing a deployment key", + description="using a map keyed on the chain id for the calldata callee, without a value for one of the " + "deployments", + error='Map used for "callee" of field "Embedded call" has no value for @.chainId "8453"', + ), + TestCase( + id="definition_with_map", + label="definition with map", + description="using a field definition whose parameters look up the token in a map keyed on the chain id", + ), TestCase( id="calldata_with_extended_params", label="calldata with extended parameters", diff --git a/tests/v2/convert/resolved/test_map_references.py b/tests/v2/convert/resolved/test_map_references.py new file mode 100644 index 00000000..1df40408 --- /dev/null +++ b/tests/v2/convert/resolved/test_map_references.py @@ -0,0 +1,217 @@ +import json +from typing import Any + +import pytest + +from erc7730.common.output import ListOutputAdder +from erc7730.convert.ledger.eip712.convert_erc7730_v2_to_eip712 import ERC7730V2toEIP712Converter +from erc7730.convert.resolved.v2.convert_erc7730_input_to_resolved import ERC7730InputToResolved +from erc7730.model.input.v2.descriptor import InputERC7730Descriptor +from erc7730.model.resolved.display import ResolvedValueConstant +from erc7730.model.resolved.v2.display import ResolvedFieldDescription, ResolvedTokenAmountParameters + +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +DEPLOYMENT = {"chainId": 1, "address": "0x0000000000000000000000000000000000000001"} + + +def map_ref(map_name: str, key_path: str) -> dict[str, str]: + return {"map": f"$.metadata.maps.{map_name}", "keyPath": key_path} + + +def descriptor( + signature: str, + field: dict[str, Any], + maps: dict[str, dict[str, Any]], + *, + eip712: bool = False, + constants: dict[str, Any] | None = None, +) -> InputERC7730Descriptor: + """Build a single deployment descriptor around a single display field, with the given maps.""" + context = ( + {"$id": "test", "eip712": {"domain": {"name": "Test"}, "deployments": [DEPLOYMENT]}} + if eip712 + else {"$id": "test", "contract": {"deployments": [DEPLOYMENT]}} + ) + return InputERC7730Descriptor.model_validate_json( + json.dumps( + { + "$schema": "specs/erc7730-v2.schema.json", + "context": context, + "metadata": { + "owner": "Test Owner", + "constants": {"chain": 1} if constants is None else constants, + "maps": {name: {"values": values} for name, values in maps.items()}, + }, + "display": {"formats": {signature: {"intent": "Test intent", "fields": [field]}}}, + } + ) + ) + + +def token_amount_descriptor(token_map: dict[str, Any], key_path: str) -> InputERC7730Descriptor: + return descriptor( + "deposit(uint256 assets)", + { + "path": "assets", + "label": "Deposit asset", + "format": "tokenAmount", + "params": {"token": map_ref("t", key_path)}, + }, + {"t": token_map}, + ) + + +def resolve(input_descriptor: InputERC7730Descriptor) -> tuple[Any, list[str]]: + out = ListOutputAdder() + resolved = ERC7730InputToResolved().convert(input_descriptor, out) + return resolved, [output.message for output in out.outputs] + + +def test_constant_key_missing_value_rejects_descriptor() -> None: + # the field must not be emitted without its token + resolved, messages = resolve(token_amount_descriptor({"8453": USDC}, "$.metadata.constants.chain")) + + assert resolved is None + assert any('has no value for key "1"' in message for message in messages) + + +@pytest.mark.parametrize("flag", [True, False]) +def test_constant_boolean_key(flag: bool) -> None: + # JSON object keys are strings, a boolean key is written "true" or "false" + other = "0x0000000000000000000000000000000000000002" + resolved, messages = resolve( + descriptor( + "deposit(uint256 assets)", + { + "path": "assets", + "label": "Deposit asset", + "format": "tokenAmount", + "params": {"token": map_ref("t", "$.metadata.constants.flag")}, + }, + {"t": {"true": USDC, "false": other}}, + constants={"flag": flag}, + ) + ) + + assert resolved is not None, messages + field = next(iter(resolved.display.formats.values())).fields[0] + assert isinstance(field, ResolvedFieldDescription) + assert isinstance(field.params, ResolvedTokenAmountParameters) + assert isinstance(field.params.token, ResolvedValueConstant) + assert field.params.token.value == (USDC if flag else other) + + +def test_sender_address_map_missing_value_rejects_descriptor() -> None: + resolved, messages = resolve( + descriptor( + "transfer(address to)", + { + "path": "to", + "label": "To", + "format": "addressName", + "params": {"types": ["eoa"], "senderAddress": map_ref("s", "$.metadata.constants.chain")}, + }, + {"s": {"8453": USDC}}, + ) + ) + + assert resolved is None + assert any('has no value for key "1"' in message for message in messages) + + +def test_calldata_optional_map_failure_rejects_descriptor() -> None: + resolved, messages = resolve( + descriptor( + "execute(address target, bytes data)", + { + "path": "data", + "label": "Embedded call", + "format": "calldata", + "params": {"calleePath": "target", "amount": map_ref("a", "$.metadata.constants.chain")}, + }, + {"a": {"8453": 1000}}, + ) + ) + + assert resolved is None + assert any('has no value for key "1"' in message for message in messages) + + +def test_map_address_value_must_be_20_bytes() -> None: + resolved, messages = resolve(token_amount_descriptor({"1": "0x01"}, "@.chainId")) + + assert resolved is None + assert any("is not a valid 20 bytes address" in message for message in messages) + + +@pytest.mark.parametrize("key_path", ["@.chainId", "#.key"]) +def test_map_hex_value_must_have_even_number_of_digits(key_path: str) -> None: + resolved, messages = resolve( + descriptor( + "execute(address target, bytes data, uint256 key)", + { + "path": "data", + "label": "Embedded call", + "format": "calldata", + "params": {"calleePath": "target", "amount": map_ref("a", key_path)}, + }, + {"a": {"1": "0x1"}}, + ) + ) + + assert resolved is None + assert any("must have an even number of digits" in message for message in messages) + + +@pytest.mark.parametrize("key_path", ["$.metadata.constants.owner", "@.to", "#.owner"]) +def test_map_keys_only_differing_by_case_rejects_descriptor(key_path: str) -> None: + other = "0x0000000000000000000000000000000000000002" + resolved, messages = resolve( + descriptor( + "deposit(uint256 assets, address owner)", + { + "path": "assets", + "label": "Deposit asset", + "format": "tokenAmount", + "params": {"token": map_ref("t", key_path)}, + }, + {"t": {USDC: USDC, USDC.lower(): other}}, + constants={"owner": USDC}, + ) + ) + + assert resolved is None + assert any("only differ by case" in message for message in messages) + + +def test_constant_key_only_encodes_selected_value() -> None: + # the value for 8453 is not a valid address, but is never selected + resolved, messages = resolve(token_amount_descriptor({"1": USDC, "8453": True}, "$.metadata.constants.chain")) + + assert resolved is not None, messages + field = next(iter(resolved.display.formats.values())).fields[0] + assert isinstance(field, ResolvedFieldDescription) + assert isinstance(field.params, ResolvedTokenAmountParameters) + assert isinstance(field.params.token, ResolvedValueConstant) + assert field.params.token.value == USDC + + +def test_legacy_eip712_rejects_sender_address_map() -> None: + out = ListOutputAdder() + result = ERC7730V2toEIP712Converter().convert( + descriptor( + "Order(address owner,address to)", + { + "path": "to", + "label": "To", + "format": "addressName", + "params": {"types": ["eoa"], "senderAddress": map_ref("s", "owner")}, + }, + {"s": {USDC: USDC}}, + eip712=True, + ), + out, + ) + + assert result is None + assert any('Map lookup "senderAddress" of field "To"' in output.message for output in out.outputs) diff --git a/tests/v2/lint/test_lint_validate_map_keys.py b/tests/v2/lint/test_lint_validate_map_keys.py new file mode 100644 index 00000000..69ee8c1e --- /dev/null +++ b/tests/v2/lint/test_lint_validate_map_keys.py @@ -0,0 +1,116 @@ +import json + +import pytest + +from erc7730.common.output import ListOutputAdder +from erc7730.convert.resolved.v2.convert_erc7730_input_to_resolved import ERC7730InputToResolved +from erc7730.lint.v2.lint_validate_map_keys import ValidateMapKeysLinter +from erc7730.model.input.v2.descriptor import InputERC7730Descriptor + +TOKEN_A = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +TOKEN_B = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + + +def lint(key_path: str, keys: list[str], signature: str = "deposit(uint256 assets, uint32 marketId)") -> list[str]: + """Lint a descriptor looking up a token amount token in a map, with the given keys.""" + descriptor = InputERC7730Descriptor.model_validate_json( + json.dumps( + { + "$schema": "specs/erc7730-v2.schema.json", + "context": { + "$id": "test", + "contract": { + "deployments": [{"chainId": 1, "address": "0x0000000000000000000000000000000000000001"}] + }, + }, + "metadata": { + "owner": "Test Owner", + "maps": {"tokens": {"values": dict(zip(keys, [TOKEN_A, TOKEN_B], strict=False))}}, + }, + "display": { + "formats": { + signature: { + "intent": "Deposit", + "fields": [ + { + "path": "assets", + "label": "Amount", + "format": "tokenAmount", + "params": {"token": {"map": "$.metadata.maps.tokens", "keyPath": key_path}}, + } + ], + } + } + }, + } + ) + ) + out = ListOutputAdder() + resolved = ERC7730InputToResolved().convert(descriptor, out) + assert resolved is not None, out.outputs + ValidateMapKeysLinter().lint(descriptor, resolved, out) + return [output.message for output in out.outputs] + + +def test_valid_keys() -> None: + assert lint("#.marketId", ["1", "4294967295"]) == [] + + +@pytest.mark.parametrize( + "key_path,keys,expected", + [ + ("#.marketId", ["1", "4294967296"], 'Key "4294967296" of map $.metadata.maps.tokens cannot be matched'), + ("#.marketId", ["1", "foo"], 'Key "foo" of map $.metadata.maps.tokens cannot be matched'), + ("#.marketId", ["1", "0x01"], 'Keys "1" and "0x01" of map $.metadata.maps.tokens encode to the same'), + ("@.from", ["1", TOKEN_A], 'Key "1" of map $.metadata.maps.tokens cannot be matched'), + ], +) +def test_invalid_keys(key_path: str, keys: list[str], expected: str) -> None: + messages = lint(key_path, keys) + assert any(expected in message for message in messages), messages + + +def test_reports_all_invalid_keys() -> None: + messages = lint("#.marketId", ["4294967296", "foo"]) + assert len(messages) == 2, messages + + +def test_deployment_keys_not_checked() -> None: + # keys of maps keyed on the chain id are checked against deployments during resolution + assert lint("@.chainId", ["1", "foo"]) == [] + + +def test_selector_format_key_not_checked() -> None: + # without a declared signature, the key type is unknown + assert lint("#.marketId", ["1", "4294967296"], signature="0xe2bbb158") == [] + + +def test_format_without_map_lookup_not_converted_to_abi_tree() -> None: + # fixed point numbers cannot be converted to an ABI tree, which must not fail descriptors that do not need it + descriptor = InputERC7730Descriptor.model_validate_json( + json.dumps( + { + "$schema": "specs/erc7730-v2.schema.json", + "context": { + "$id": "test", + "contract": { + "deployments": [{"chainId": 1, "address": "0x0000000000000000000000000000000000000001"}] + }, + }, + "metadata": {"owner": "Test Owner"}, + "display": { + "formats": { + "setRate(fixed128x18 rate)": { + "intent": "Set rate", + "fields": [{"path": "rate", "label": "Rate", "format": "raw"}], + } + } + }, + } + ) + ) + out = ListOutputAdder() + resolved = ERC7730InputToResolved().convert(descriptor, out) + assert resolved is not None, out.outputs + ValidateMapKeysLinter().lint(descriptor, resolved, out) + assert out.outputs == []