From 2626f910485f95e01083220fb89183465921fe91 Mon Sep 17 00:00:00 2001 From: venugopalanvip <102638505+venugopalanvip@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:33:34 +0530 Subject: [PATCH 1/9] fix(calldata): accept boolean enum keys instead of failing the descriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An enum key is the field value it matches, and a bool holds 0 or 1 in calldata, so `true` and `false` are the keys a descriptor writes for a bool field. JSON has no boolean object key, so they arrive as text. `convert_enums` ran `int(ordinal)` on them. That raises, and the exception is caught only at the top of the conversion, so one bool enum discards every format and every deployment of the descriptor and returns an empty list. Two descriptors in the clear-signing registry produce nothing today: registry/flyingtulip/calldata-PftNft.json {"True": …, "False": …} 0 -> 6 registry/ekubo/calldata-MEVCaptureRouter.json {"false": …, "true": …} 0 -> 168 The casing differs because nothing constrains it: the v2 schema says "enumeration keys are the field values" and allows any string. Boolean literals now map to the value the bool holds, case-insensitively. A key that is neither decimal nor boolean still raises, because silently mapping it to a number would build an entry the device can never match. Co-Authored-By: Claude Opus 5 --- src/erc7730/convert/calldata/v1/enum.py | 22 +++++++++- .../convert/calldata/v1/test_enum_ordinal.py | 40 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/convert/calldata/v1/test_enum_ordinal.py diff --git a/src/erc7730/convert/calldata/v1/enum.py b/src/erc7730/convert/calldata/v1/enum.py index 3b1bfe84..3d78f6ab 100644 --- a/src/erc7730/convert/calldata/v1/enum.py +++ b/src/erc7730/convert/calldata/v1/enum.py @@ -9,6 +9,26 @@ from erc7730.model.resolved.context import ResolvedDeployment from erc7730.model.types import Id, Selector +# A bool holds 0 or 1 in calldata, and an enum key is the field value it matches, so these are +# the keys a descriptor writes for a bool field. JSON has no bool object key, so they arrive as +# text, and the casing follows whoever wrote the descriptor rather than any rule. +BOOLEAN_ORDINALS = {"true": 1, "false": 0} + + +def enum_ordinal(ordinal: str) -> int: + """Value the device compares an enum entry against. + + :param ordinal: enum key, as written in the descriptor + :return: the numeric field value it stands for + :raises ValueError: if the key is neither decimal nor a boolean literal + """ + try: + return int(ordinal) + except ValueError: + if (value := BOOLEAN_ORDINALS.get(ordinal.strip().lower())) is not None: + return value + raise + def convert_enums( deployment: ResolvedDeployment, selector: Selector, enums: dict[Id, EnumDefinition] | None @@ -29,7 +49,7 @@ def convert_enums( selector=selector, id=i, enum_id=enum_id, - value=int(ordinal), + value=enum_ordinal(ordinal), name=name, ) for i, (enum_id, enum) in enumerate(enums.items()) diff --git a/tests/convert/calldata/v1/test_enum_ordinal.py b/tests/convert/calldata/v1/test_enum_ordinal.py new file mode 100644 index 00000000..ef830a04 --- /dev/null +++ b/tests/convert/calldata/v1/test_enum_ordinal.py @@ -0,0 +1,40 @@ +import pytest + +from erc7730.convert.calldata.v1.enum import enum_ordinal + + +@pytest.mark.parametrize( + "ordinal,expected", + [ + pytest.param("0", 0, id="zero"), + pytest.param("1", 1, id="one"), + pytest.param("42", 42, id="larger"), + pytest.param("-1", -1, id="negative"), + ], +) +def test_a_decimal_key_is_its_own_value(ordinal: str, expected: int) -> None: + assert enum_ordinal(ordinal) == expected + + +@pytest.mark.parametrize( + "ordinal,expected", + [ + pytest.param("false", 0, id="false"), + pytest.param("true", 1, id="true"), + pytest.param("False", 0, id="False"), + pytest.param("True", 1, id="True"), + pytest.param("FALSE", 0, id="FALSE"), + pytest.param("TRUE", 1, id="TRUE"), + pytest.param(" true ", 1, id="padded"), + ], +) +def test_a_boolean_key_is_the_value_the_bool_holds_in_calldata(ordinal: str, expected: int) -> None: + """A bool holds 0 or 1, and the casing follows whoever wrote the descriptor.""" + assert enum_ordinal(ordinal) == expected + + +@pytest.mark.parametrize("ordinal", ["maybe", "", "0x1", "1.0", "yes"]) +def test_a_key_that_is_neither_still_raises(ordinal: str) -> None: + """Silently mapping an unrecognised key to a number would build an entry that never matches.""" + with pytest.raises(ValueError): + enum_ordinal(ordinal) From 30626f88dc9d56349defe2d76f78ad7762f32fb1 Mon Sep 17 00:00:00 2001 From: Pierre Aoun <98907864+paoun-ledger@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:46:25 +0200 Subject: [PATCH 2/9] feat(v2): support map references in field parameters (#351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(v2): support map references in field parameters Map references ({"map": ..., "keyPath": ...}) in v2 field parameters were dropped with a warning. They are now resolved: - a constant key ($.…) selects the map value at conversion time - a data/container key is kept as a ResolvedValueMap lookup in the resolved model (token, chainId, senderAddress, callee, selector, amount, spender, collection) - maps keyed on @.chainId / @.to are checked against every deployment, and resolved per deployment when generating calldata descriptors - legacy EIP-712 conversion reports map lookups as unsupported Co-Authored-By: Claude Opus 5.5 * feat(v2): emit device side maps in calldata descriptors Map lookups keyed on calldata (#.…) or on @.from / @.value were rejected by the calldata converter. They are now looked up by the device, as specified in app-ethereum doc/tlv_structs.md#map_entry: - add a MAP_REF value source (VALUE tag 0x06: version, map id, key as a nested VALUE with a data or container path) - emit one MAP_ENTRY instruction per map key (version 0x01, chain id, contract address, selector, id, key, value) in a new "maps" list next to "enums", for CAL to sign with the calldata key and serve - encode keys exactly as the device reads them: 32 bytes ABI word for static values, raw content for dynamic ones, the slice for sliced paths, 20 bytes for @.from, 32 bytes for @.value - allocate map ids per descriptor, sharing them between lookups with the same key and value encodings - reject keys / values the device cannot store or match (empty or over 32 bytes, not fitting the key type, duplicate encodings, multi-valued key paths, key VALUE over 48 bytes) - keep folding @.chainId / @.to maps into constants, as a descriptor is bound to a single deployment - keep the referenced map path in ResolvedValueMap to identify entries Note MAP_ENTRY version is 0x01 as enforced by map_entry.c, the doc still states 0x0. Co-Authored-By: Claude Opus 5.5 * fix(v2): address review findings on map references - map keys linter: only build the ABI tree for formats with map lookups keyed on transaction data, so formats using fixed point types still lint - reject hex constants with an odd number of digits, which could not be decoded when serializing map values and constants - reject maps with hex keys only differing by case, since hex keys are matched case-insensitively - calldata: warn when a tokenAmount chainId differs from the deployment chain, as PARAM_TOKEN_AMOUNT cannot encode it Co-Authored-By: Claude Opus 5.5 * fix(v2): reject token amounts on another chain, simplify map references - reject a tokenAmount chainId that differs from the deployment chain (plain value or map lookup): PARAM_TOKEN_AMOUNT cannot encode it, and the device would display the token with a wrong ticker and decimals - reuse the values encoded at resolution for MAP_ENTRY structs - share is_deployment_key between the converter and the map keys linter - use a single pattern to reject optional parameters failing to convert Co-Authored-By: Claude Opus 5.5 * fix(v2): reject token tickers and chain id paths on another chain * test(v2): fix token chain test field typing --------- Co-authored-by: Claude Opus 5.5 --- .../convert_erc7730_input_to_calldata.py | 12 +- .../convert_erc7730_v2_input_to_calldata.py | 127 +++++- src/erc7730/convert/calldata/v1/map.py | 429 ++++++++++++++++++ src/erc7730/convert/calldata/v1/tlv.py | 57 +++ .../eip712/convert_erc7730_v2_to_eip712.py | 13 +- .../v2/convert_erc7730_input_to_resolved.py | 60 ++- src/erc7730/convert/resolved/v2/parameters.py | 292 ++++++------ src/erc7730/convert/resolved/v2/values.py | 179 +++++++- src/erc7730/lint/v2/lint.py | 2 + .../lint/v2/lint_validate_display_fields.py | 44 +- src/erc7730/lint/v2/lint_validate_map_keys.py | 73 +++ src/erc7730/lint/v2/path_schemas.py | 15 +- src/erc7730/main.py | 9 +- src/erc7730/model/calldata/v1/descriptor.py | 8 + src/erc7730/model/calldata/v1/instruction.py | 87 +++- src/erc7730/model/calldata/v1/value.py | 48 +- src/erc7730/model/input/path.py | 27 +- src/erc7730/model/resolved/v2/display.py | 90 +++- tests/test_main.py | 37 ++ .../convert/calldata/test_convert_fields.py | 206 +++++++++ .../v2/convert/calldata/test_convert_maps.py | 336 ++++++++++++++ .../data/definition_with_map_input.json | 55 +++ .../data/definition_with_map_resolved.json | 58 +++ ...maps_in_parameters_constant_key_input.json | 144 ++++++ ...s_in_parameters_constant_key_resolved.json | 97 ++++ .../data/maps_in_parameters_input.json | 141 ++++++ .../maps_in_parameters_missing_key_input.json | 146 ++++++ .../data/maps_in_parameters_resolved.json | 200 ++++++++ ...en_amount_with_map_constant_key_input.json | 53 +++ ...amount_with_map_constant_key_resolved.json | 45 ++ .../data/token_amount_with_map_input.json | 50 ++ ...ken_amount_with_map_missing_key_input.json | 49 ++ .../data/token_amount_with_map_resolved.json | 58 +++ .../test_convert_input_to_resolved.py | 41 ++ .../convert/resolved/test_map_references.py | 217 +++++++++ tests/v2/lint/test_lint_validate_map_keys.py | 116 +++++ 36 files changed, 3362 insertions(+), 259 deletions(-) create mode 100644 src/erc7730/convert/calldata/v1/map.py create mode 100644 src/erc7730/lint/v2/lint_validate_map_keys.py create mode 100644 tests/v2/convert/calldata/test_convert_maps.py create mode 100644 tests/v2/convert/resolved/data/definition_with_map_input.json create mode 100644 tests/v2/convert/resolved/data/definition_with_map_resolved.json create mode 100644 tests/v2/convert/resolved/data/maps_in_parameters_constant_key_input.json create mode 100644 tests/v2/convert/resolved/data/maps_in_parameters_constant_key_resolved.json create mode 100644 tests/v2/convert/resolved/data/maps_in_parameters_input.json create mode 100644 tests/v2/convert/resolved/data/maps_in_parameters_missing_key_input.json create mode 100644 tests/v2/convert/resolved/data/maps_in_parameters_resolved.json create mode 100644 tests/v2/convert/resolved/data/token_amount_with_map_constant_key_input.json create mode 100644 tests/v2/convert/resolved/data/token_amount_with_map_constant_key_resolved.json create mode 100644 tests/v2/convert/resolved/data/token_amount_with_map_input.json create mode 100644 tests/v2/convert/resolved/data/token_amount_with_map_missing_key_input.json create mode 100644 tests/v2/convert/resolved/data/token_amount_with_map_resolved.json create mode 100644 tests/v2/convert/resolved/test_map_references.py create mode 100644 tests/v2/lint/test_lint_validate_map_keys.py 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 == [] From 3baf12c4acbd9773040e62b49c63583dc28053f9 Mon Sep 17 00:00:00 2001 From: Github CI Bot <68245635+ldg-github-ci@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:50:10 +0000 Subject: [PATCH 3/9] dependencies: update dependencies (#344) Co-authored-by: ckorchane-ledger <104824609+ckorchane-ledger@users.noreply.github.com> --- pdm.lock | 120 +++++++++++++++++++++++++++---------------------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/pdm.lock b/pdm.lock index eebbb854..07d3788c 100644 --- a/pdm.lock +++ b/pdm.lock @@ -864,47 +864,47 @@ files = [ [[package]] name = "pydantic" -version = "2.13.5" -requires_python = ">=3.9" +version = "2.14.0" +requires_python = ">=3.10" summary = "Data validation using Python type hints" groups = ["default", "dev"] dependencies = [ "annotated-types>=0.6.0", - "pydantic-core==2.46.5", - "typing-extensions>=4.14.1", - "typing-inspection>=0.4.2", + "pydantic-core==2.50.0", + "typing-extensions>=4.16.0", + "typing-inspection>=0.4.4", ] files = [ - {file = "pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73"}, - {file = "pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08"}, + {file = "pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b"}, + {file = "pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae"}, ] [[package]] name = "pydantic-core" -version = "2.46.5" -requires_python = ">=3.9" +version = "2.50.0" +requires_python = ">=3.10" summary = "Core functionality for Pydantic validation and serialization" groups = ["default", "dev"] dependencies = [ - "typing-extensions>=4.14.1", + "typing-extensions>=4.16.0", ] files = [ - {file = "pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d"}, - {file = "pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e"}, - {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29"}, - {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4"}, - {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a"}, - {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62"}, - {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2"}, - {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869"}, - {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5"}, - {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3"}, - {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b"}, - {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0"}, - {file = "pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b"}, - {file = "pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8"}, - {file = "pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084"}, - {file = "pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc"}, + {file = "pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a"}, + {file = "pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30"}, + {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5"}, + {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f"}, + {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2"}, + {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145"}, + {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e"}, + {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f"}, + {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c"}, + {file = "pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0"}, + {file = "pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4"}, + {file = "pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa"}, + {file = "pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe"}, + {file = "pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1"}, + {file = "pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7"}, + {file = "pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e"}, ] [[package]] @@ -1219,29 +1219,29 @@ files = [ [[package]] name = "ruff" -version = "0.16.7" +version = "0.16.10" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." groups = ["dev"] files = [ - {file = "ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9"}, - {file = "ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929"}, - {file = "ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728"}, - {file = "ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0"}, - {file = "ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0"}, - {file = "ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9"}, - {file = "ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1"}, - {file = "ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9"}, - {file = "ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55"}, - {file = "ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f"}, - {file = "ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656"}, - {file = "ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750"}, - {file = "ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5"}, - {file = "ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f"}, - {file = "ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7"}, - {file = "ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6"}, - {file = "ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b"}, - {file = "ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6"}, + {file = "ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511"}, + {file = "ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d"}, + {file = "ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d"}, + {file = "ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a"}, + {file = "ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca"}, + {file = "ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b"}, + {file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"}, ] [[package]] @@ -1561,7 +1561,7 @@ files = [ [[package]] name = "typer" -version = "0.27.2" +version = "0.27.3" requires_python = ">=3.10" summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." groups = ["default", "dev"] @@ -1572,33 +1572,33 @@ dependencies = [ "shellingham>=1.3.0", ] files = [ - {file = "typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d"}, - {file = "typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945"}, + {file = "typer-0.27.3-py3-none-any.whl", hash = "sha256:e50022f28b82a86313e54501317a1db64bf8f8d036ff8cfe5ca7e47675454aff"}, + {file = "typer-0.27.3.tar.gz", hash = "sha256:d0396f770a560ab1b0a8504e13b5f254b728cedb05c61cf0359e944e50ce8901"}, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" requires_python = ">=3.9" summary = "Backported and Experimental Type Hints for Python 3.9+" groups = ["default", "dev"] files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] [[package]] name = "typing-inspection" -version = "0.4.2" -requires_python = ">=3.9" +version = "0.4.4" +requires_python = ">=3.10" summary = "Runtime typing introspection tools" groups = ["default", "dev"] dependencies = [ - "typing-extensions>=4.12.0", + "typing-extensions>=4.15.0", ] files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, + {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, + {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, ] [[package]] @@ -1625,11 +1625,11 @@ files = [ [[package]] name = "xdg-base-dirs" -version = "6.0.2" -requires_python = "<4.0,>=3.10" +version = "6.0.3" +requires_python = ">=3.10" summary = "Variables defined by the XDG Base Directory Specification" groups = ["default"] files = [ - {file = "xdg_base_dirs-6.0.2-py3-none-any.whl", hash = "sha256:3c01d1b758ed4ace150ac960ac0bd13ce4542b9e2cdf01312dcda5012cfebabe"}, - {file = "xdg_base_dirs-6.0.2.tar.gz", hash = "sha256:950504e14d27cf3c9cb37744680a43bf0ac42efefc4ef4acf98dc736cab2bced"}, + {file = "xdg_base_dirs-6.0.3-py3-none-any.whl", hash = "sha256:c80886a8f4e369d6148fdebd7abf182bf053ce6c744f7b7eec5853fec835ff44"}, + {file = "xdg_base_dirs-6.0.3.tar.gz", hash = "sha256:9e8274f6d2514b587d257bda21d7fcf758180343c4ad5a47be0b202548211ec2"}, ] From 6e68b5ffc16dff9e14bc267b6313f4714edc2c7f Mon Sep 17 00:00:00 2001 From: Github CI Bot <68245635+ldg-github-ci@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:56:20 +0000 Subject: [PATCH 4/9] chore: update submodules - 2026-10-08 07:14 (#343) Co-authored-by: ldg-github-ci --- tests/registries/clear-signing-erc7730-registry | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/registries/clear-signing-erc7730-registry b/tests/registries/clear-signing-erc7730-registry index 59caf861..2555cefa 160000 --- a/tests/registries/clear-signing-erc7730-registry +++ b/tests/registries/clear-signing-erc7730-registry @@ -1 +1 @@ -Subproject commit 59caf861229afdc584767bb127eea9a6c8c729a6 +Subproject commit 2555cefabd9d273496bceb34ee9b77c34280f001 From 13951ca765d3803ac5b4a592c7507a3f89dd16c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:10:28 +0200 Subject: [PATCH 5/9] [ci] Bump jdx/mise-action from 4.3.0 to 5.0.1 (#350) Bumps [jdx/mise-action](https://github.com/jdx/mise-action) from 4.3.0 to 5.0.1. - [Release notes](https://github.com/jdx/mise-action/releases) - [Changelog](https://github.com/jdx/mise-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/jdx/mise-action/compare/v4.3.0...v5.0.1) --- updated-dependencies: - dependency-name: jdx/mise-action dependency-version: 5.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ckorchane-ledger <104824609+ckorchane-ledger@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/update_dependencies.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 956cc1f0..b6d50951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - name: Setup mise timeout-minutes: 10 - uses: jdx/mise-action@v4.3.0 + uses: jdx/mise-action@v5.0.1 - name: Install dependencies timeout-minutes: 10 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c918633..b4d6c0ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: Setup mise timeout-minutes: 10 - uses: jdx/mise-action@v4.3.0 + uses: jdx/mise-action@v5.0.1 - name: Install dependencies timeout-minutes: 10 diff --git a/.github/workflows/update_dependencies.yml b/.github/workflows/update_dependencies.yml index 1855a8d5..0ee35885 100644 --- a/.github/workflows/update_dependencies.yml +++ b/.github/workflows/update_dependencies.yml @@ -29,7 +29,7 @@ jobs: - name: Setup mise timeout-minutes: 10 - uses: jdx/mise-action@v4.3.0 + uses: jdx/mise-action@v5.0.1 - name: Update dependencies timeout-minutes: 30 From e88b2a68cd8234804b700d177e1288217c7f065b Mon Sep 17 00:00:00 2001 From: Pierre Aoun <98907864+paoun-ledger@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:33:19 +0200 Subject: [PATCH 6/9] fix(calldata): always emit DECIMALS in PARAM_UNIT (#353) app-ethereum >= 1.22.4 rejects PARAM_UNIT structs without DECIMALS (6a80), although its spec documents the tag as optional with a default of 0. Unit fields without decimals (e.g. opencover "Cover duration") therefore fall back to blind signing on released devices. Always serialize DECIMALS, defaulting to 0, which is byte-for-byte the value the app is specified to assume. Co-authored-by: Claude Opus 5.5 --- src/erc7730/convert/calldata/v1/tlv.py | 4 ++-- .../convert/calldata/test_convert_fields.py | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/erc7730/convert/calldata/v1/tlv.py b/src/erc7730/convert/calldata/v1/tlv.py index ab63bf19..672220c6 100644 --- a/src/erc7730/convert/calldata/v1/tlv.py +++ b/src/erc7730/convert/calldata/v1/tlv.py @@ -473,8 +473,8 @@ def tlv_param_unit(obj: CalldataDescriptorParamUnitV1) -> bytes: out += tlv(CalldataDescriptorParamUnitTag.VALUE, tlv_value(obj.value)) out += tlv(CalldataDescriptorParamUnitTag.BASE, obj.base) - if (decimals := obj.decimals) is not None: - out += tlv(CalldataDescriptorParamUnitTag.DECIMALS, decimals.to_bytes(1)) + # always emit DECIMALS (defaulting to 0): app-ethereum >= 1.22.4 rejects PARAM_UNIT structs without it + out += tlv(CalldataDescriptorParamUnitTag.DECIMALS, (obj.decimals or 0).to_bytes(1)) if (prefix := obj.prefix) is not None: out += tlv(CalldataDescriptorParamUnitTag.PREFIX, prefix.to_bytes(1)) diff --git a/tests/v2/convert/calldata/test_convert_fields.py b/tests/v2/convert/calldata/test_convert_fields.py index 694c86e6..7e83bc98 100644 --- a/tests/v2/convert/calldata/test_convert_fields.py +++ b/tests/v2/convert/calldata/test_convert_fields.py @@ -8,7 +8,12 @@ from erc7730.convert.calldata.convert_erc7730_v2_input_to_calldata import ( erc7730_v2_descriptor_to_calldata_descriptors, ) -from erc7730.convert.calldata.v1.tlv import CalldataDescriptorFieldTag, tlv_field +from erc7730.convert.calldata.v1.tlv import ( + CalldataDescriptorFieldTag, + CalldataDescriptorParamUnitTag, + tlv_field, + tlv_param_unit, +) from erc7730.model.calldata.v1.instruction import ( CalldataDescriptorFieldVisibilityV1, CalldataDescriptorInstructionFieldV1, @@ -204,6 +209,18 @@ def test_convert_unit_params() -> None: assert field.param.prefix is False +@pytest.mark.parametrize(("decimals", "expected"), [pytest.param(None, 0, id="unset"), pytest.param(4, 4, id="set")]) +def test_convert_unit_always_serializes_decimals(decimals: int | None, expected: int) -> None: + params: dict[str, Any] = {"base": "s"} if decimals is None else {"base": "s", "decimals": decimals} + field = convert_field( + "setDuration(uint256 duration)", {"path": "duration", "label": "Duration", "format": "unit", "params": params} + ) + + assert isinstance(field.param, CalldataDescriptorParamUnitV1) + values = dict(parse_tlv(tlv_param_unit(field.param))) + assert values[CalldataDescriptorParamUnitTag.DECIMALS] == bytes([expected]) + + def test_convert_token_ticker_has_no_native_currencies() -> None: field = convert_field( "getTokenTicker(address token)", From 85ab9cd0c221acd29b8338b17c77cc002a73fa3a Mon Sep 17 00:00:00 2001 From: Github CI Bot <68245635+ldg-github-ci@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:14:18 +0000 Subject: [PATCH 7/9] dependencies: update dependencies (#354) --- pdm.lock | 80 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/pdm.lock b/pdm.lock index 07d3788c..a19aa666 100644 --- a/pdm.lock +++ b/pdm.lock @@ -864,24 +864,24 @@ files = [ [[package]] name = "pydantic" -version = "2.14.0" +version = "2.14.1" requires_python = ">=3.10" summary = "Data validation using Python type hints" groups = ["default", "dev"] dependencies = [ "annotated-types>=0.6.0", - "pydantic-core==2.50.0", + "pydantic-core==2.50.1", "typing-extensions>=4.16.0", "typing-inspection>=0.4.4", ] files = [ - {file = "pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b"}, - {file = "pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae"}, + {file = "pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454"}, + {file = "pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26"}, ] [[package]] name = "pydantic-core" -version = "2.50.0" +version = "2.50.1" requires_python = ">=3.10" summary = "Core functionality for Pydantic validation and serialization" groups = ["default", "dev"] @@ -889,22 +889,22 @@ dependencies = [ "typing-extensions>=4.16.0", ] files = [ - {file = "pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a"}, - {file = "pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30"}, - {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5"}, - {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f"}, - {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2"}, - {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145"}, - {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e"}, - {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f"}, - {file = "pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c"}, - {file = "pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0"}, - {file = "pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4"}, - {file = "pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa"}, - {file = "pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe"}, - {file = "pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1"}, - {file = "pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7"}, - {file = "pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e"}, + {file = "pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0"}, + {file = "pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9"}, + {file = "pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff"}, + {file = "pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4"}, + {file = "pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2"}, + {file = "pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e"}, + {file = "pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498"}, + {file = "pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1"}, + {file = "pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09"}, ] [[package]] @@ -1219,29 +1219,29 @@ files = [ [[package]] name = "ruff" -version = "0.16.10" +version = "0.17.0" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." groups = ["dev"] files = [ - {file = "ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511"}, - {file = "ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d"}, - {file = "ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550"}, - {file = "ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d"}, - {file = "ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221"}, - {file = "ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a"}, - {file = "ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8"}, - {file = "ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e"}, - {file = "ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a"}, - {file = "ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3"}, - {file = "ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3"}, - {file = "ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03"}, - {file = "ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be"}, - {file = "ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d"}, - {file = "ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a"}, - {file = "ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca"}, - {file = "ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b"}, - {file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"}, + {file = "ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e"}, + {file = "ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b"}, + {file = "ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9"}, + {file = "ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0"}, + {file = "ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1"}, + {file = "ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14"}, + {file = "ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d"}, + {file = "ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329"}, + {file = "ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c"}, + {file = "ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399"}, + {file = "ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859"}, + {file = "ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866"}, + {file = "ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589"}, + {file = "ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540"}, + {file = "ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd"}, + {file = "ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c"}, + {file = "ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88"}, + {file = "ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322"}, ] [[package]] From 55fe3615211ab5b40e5d02e4bdb683d8340a5764 Mon Sep 17 00:00:00 2001 From: Github CI Bot <68245635+ldg-github-ci@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:25:38 +0000 Subject: [PATCH 8/9] chore: update submodules - 2026-10-10 07:08 (#352) Co-authored-by: ldg-github-ci --- tests/registries/clear-signing-erc7730-registry | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/registries/clear-signing-erc7730-registry b/tests/registries/clear-signing-erc7730-registry index 2555cefa..f4373a0f 160000 --- a/tests/registries/clear-signing-erc7730-registry +++ b/tests/registries/clear-signing-erc7730-registry @@ -1 +1 @@ -Subproject commit 2555cefabd9d273496bceb34ee9b77c34280f001 +Subproject commit f4373a0f0c402032129c15bb37d292c2900b420d From 796e12ae769c34b71b595aac666db9b3aabee227 Mon Sep 17 00:00:00 2001 From: Venugopalan Hariharan <102638505+venugopalanvip@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:03:01 +0530 Subject: [PATCH 9/9] feat(schema): add --v2, so the v2 model's schema can be printed (#347) `erc7730 schema` was the only command with no way to reach the v2 models. `lint`, `resolve`, `calldata` and `convert` all take --v2 or detect it from $schema; `schema` printed the v1 model either way, so the schema that `erc7730 lint --v2` actually enforces could not be obtained from the tool. That is the schema worth having. A descriptor is validated twice by the clear-signing registry: against `specs/erc7730-v2.schema.json`, which is maintained by hand, and against these models, by `erc7730 lint`. When the two disagree the descriptor passes one check and fails the other, which is what issue #338 is. Printing the model's schema is how that comparison gets made. The flag switches both forms, so `schema resolved --v2` works too. Tests cover all four combinations and assert the flag reaches the v2 model rather than only that a schema was printed: `mustMatch` is a v2 visibility rule and `excluded` a v1 field, so each name appears in exactly one of the two schemas. Co-authored-by: Claude Opus 5 --- src/erc7730/main.py | 30 +++++++++++++++++++++++------- tests/test_main.py | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/erc7730/main.py b/src/erc7730/main.py index de475881..a52d288e 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -75,19 +75,35 @@ def _any_v2_descriptor(paths: list[Path]) -> bool: short_help="Print ERC-7730 descriptor JSON schema.", help=""" Print ERC-7730 descriptor JSON schema. + + The schema is generated from the model this tool validates against, so it is the one + `erc7730 lint` enforces. Pass --v2 for the v2 model. """, ) def command_schema( model_type: Annotated[ERC7730ModelType, Argument(help="The descriptor form ")] = ERC7730ModelType.INPUT, + v2: Annotated[bool, Option("--v2", help="Print the schema of the v2 model")] = False, ) -> None: descriptor_type: type[Model] - match model_type: - case ERC7730ModelType.INPUT: - descriptor_type = InputERC7730Descriptor - case ERC7730ModelType.RESOLVED: - descriptor_type = ResolvedERC7730Descriptor - case _: - assert_never(model_type) + if v2: + from erc7730.model.input.v2.descriptor import InputERC7730Descriptor as InputERC7730DescriptorV2 + from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor as ResolvedERC7730DescriptorV2 + + match model_type: + case ERC7730ModelType.INPUT: + descriptor_type = InputERC7730DescriptorV2 + case ERC7730ModelType.RESOLVED: + descriptor_type = ResolvedERC7730DescriptorV2 + case _: + assert_never(model_type) + else: + match model_type: + case ERC7730ModelType.INPUT: + descriptor_type = InputERC7730Descriptor + case ERC7730ModelType.RESOLVED: + descriptor_type = ResolvedERC7730Descriptor + case _: + assert_never(model_type) builtins.print(json.dumps(descriptor_type.model_json_schema(by_alias=True), indent=4)) diff --git a/tests/test_main.py b/tests/test_main.py index b80ebf23..6e765f2a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -34,6 +34,27 @@ def test_schema(model_type: ERC7730ModelType) -> None: assert json.loads(out) is not None +@pytest.mark.parametrize("model_type", list(ERC7730ModelType)) +def test_schema_v2(model_type: ERC7730ModelType) -> None: + result = runner.invoke(app, ["schema", model_type, "--v2"]) + out = "".join(result.stdout.splitlines()) + assert result.exit_code == 0 + assert json.loads(out) is not None + + +def test_schema_v2_reaches_the_v2_model() -> None: + """Exit code 0 only proves a schema was printed, not which model it came from. + + `mustMatch` is a v2 visibility rule and `excluded` a v1 field, so each name appears + in exactly one of the two schemas. + """ + v1 = "".join(runner.invoke(app, ["schema", ERC7730ModelType.INPUT]).stdout.splitlines()) + v2 = "".join(runner.invoke(app, ["schema", ERC7730ModelType.INPUT, "--v2"]).stdout.splitlines()) + + assert "excluded" in v1 and "mustMatch" not in v1 + assert "mustMatch" in v2 and "excluded" not in v2 + + def test_list() -> None: result = runner.invoke(app, ["list", str(ERC7730_REGISTRY_ROOT)]) out = "".join(result.stdout.splitlines())