diff --git a/src/erc7730/convert/ledger/eip712/convert_erc7730_to_eip712.py b/src/erc7730/convert/ledger/eip712/convert_erc7730_to_eip712.py index 48858cd..84488ad 100644 --- a/src/erc7730/convert/ledger/eip712/convert_erc7730_to_eip712.py +++ b/src/erc7730/convert/ledger/eip712/convert_erc7730_to_eip712.py @@ -11,7 +11,7 @@ from erc7730.convert import ERC7730Converter from erc7730.model.context import EIP712Schema from erc7730.model.display import FieldFormat -from erc7730.model.paths import ContainerField, ContainerPath, DataPath +from erc7730.model.paths import Array, ContainerField, ContainerPath, DataPath from erc7730.model.paths.path_ops import data_path_concat, to_relative from erc7730.model.resolved.context import ResolvedDeployment, ResolvedEIP712Context from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor @@ -132,10 +132,20 @@ def convert_field_description( field_path: DataPath asset_path: DataPath | None = None field_format: EIP712Format | None = None + in_array: bool = False match field.path: case DataPath() as field_path: field_path = data_path_concat(prefix, field_path) + + for element in field_path.elements: + match element: + case Array(): + in_array = True + break + case _: + pass + case ContainerPath() as container_path: return out.error(f"Path {container_path} is not supported") case _: @@ -163,20 +173,24 @@ def convert_field_description( case FieldFormat.AMOUNT: field_format = EIP712Format.AMOUNT case FieldFormat.TOKEN_AMOUNT: - field_format = EIP712Format.AMOUNT - if field.params is not None and isinstance(field.params, ResolvedTokenAmountParameters): - match field.params.tokenPath: - case None: - pass - case DataPath() as token_path: - asset_path = data_path_concat(prefix, token_path) - case ContainerPath() as container_path if container_path.field == ContainerField.TO: - # In EIP-712 protocol, format=token with no token path => refers to verifyingContract - asset_path = None - case ContainerPath() as container_path: - return out.error(f"Path {container_path} is not supported") - case _: - assert_never(field.params.tokenPath) + if in_array: + # EIP-712 does not support token references in arrays, fallback to raw format + field_format = EIP712Format.RAW + else: + field_format = EIP712Format.AMOUNT + if field.params is not None and isinstance(field.params, ResolvedTokenAmountParameters): + match field.params.tokenPath: + case None: + pass + case DataPath() as token_path: + asset_path = data_path_concat(prefix, token_path) + case ContainerPath() as container_path if container_path.field == ContainerField.TO: + # In EIP-712 protocol, format=token with no token path => refers to verifyingContract + asset_path = None + case ContainerPath() as container_path: + return out.error(f"Path {container_path} is not supported") + case _: + assert_never(field.params.tokenPath) case _: assert_never(field.format) diff --git a/tests/assertions.py b/tests/assertions.py index 5c80dc0..ea9dd50 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -1,4 +1,5 @@ import json +from pathlib import Path from typing import Any import jsonschema @@ -19,6 +20,12 @@ def assert_json_str_equals(expected: str, actual: str) -> None: assert_dict_equals(json.loads(expected), json.loads(actual)) +def assert_json_file_equals(expected: Path, actual: Path) -> None: + """Assert deserialized JSON files are equal.""" + with open(expected) as exp, open(actual) as act: + assert_dict_equals(json.load(exp), json.load(act)) + + def assert_model_json_equals(expected: _BaseModel, actual: _BaseModel) -> None: """Assert models serialize to same JSON, pretty printing differences to console.""" assert_json_str_equals(model_to_json_str(expected), model_to_json_str(actual)) diff --git a/tests/convert/ledger/eip712/data/eip712-UniswapX-DutchOrder.json b/tests/convert/ledger/eip712/data/eip712-UniswapX-DutchOrder.json new file mode 100644 index 0000000..a885e05 --- /dev/null +++ b/tests/convert/ledger/eip712/data/eip712-UniswapX-DutchOrder.json @@ -0,0 +1,174 @@ +{ + "blockchainName": "ethereum", + "chainId": 1, + "name": "Permit2", + "contracts": [ + { + "address": "0x000000000022d473030f116ddee9f6b43ac78ba3", + "contractName": "Uniswap", + "messages": [ + { + "schema": { + "DutchOrder": [ + { + "name": "info", + "type": "OrderInfo" + }, + { + "name": "decayStartTime", + "type": "uint256" + }, + { + "name": "decayEndTime", + "type": "uint256" + }, + { + "name": "inputToken", + "type": "address" + }, + { + "name": "inputStartAmount", + "type": "uint256" + }, + { + "name": "inputEndAmount", + "type": "uint256" + }, + { + "name": "outputs", + "type": "DutchOutput[]" + } + ], + "DutchOutput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ], + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "OrderInfo": [ + { + "name": "reactor", + "type": "address" + }, + { + "name": "swapper", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "additionalValidationContract", + "type": "address" + }, + { + "name": "additionalValidationData", + "type": "bytes" + } + ], + "PermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "DutchOrder" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ] + }, + "mapper": { + "label": "UniswapX Dutch Order", + "fields": [ + { + "path": "spender", + "label": "Approve to spender", + "format": "raw" + }, + { + "path": "permitted.amount", + "label": "Approve amount", + "assetPath": "permitted.token", + "format": "amount" + }, + { + "path": "witness.inputStartAmount", + "label": "Spend max", + "assetPath": "witness.inputToken", + "format": "amount" + }, + { + "path": "witness.outputs.[].endAmount", + "label": "Minimum amounts to receive", + "format": "raw" + }, + { + "path": "witness.outputs.[].recipient", + "label": "On Addresses", + "format": "raw" + }, + { + "path": "deadline", + "label": "Approval expire", + "format": "datetime" + } + ] + } + } + ] + } + ] +} diff --git a/tests/convert/ledger/eip712/test_convert_erc7730_to_eip712.py b/tests/convert/ledger/eip712/test_convert_erc7730_to_eip712.py index bba1129..a798497 100644 --- a/tests/convert/ledger/eip712/test_convert_erc7730_to_eip712.py +++ b/tests/convert/ledger/eip712/test_convert_erc7730_to_eip712.py @@ -1,15 +1,22 @@ from pathlib import Path import pytest +from eip712.model.input.descriptor import InputEIP712DAppDescriptor +from erc7730.common.json import dict_from_json_file +from erc7730.common.pydantic import model_to_json_dict from erc7730.convert.convert import convert_and_print_errors from erc7730.convert.ledger.eip712.convert_erc7730_to_eip712 import ERC7730toEIP712Converter from erc7730.convert.resolved.convert_erc7730_input_to_resolved import ERC7730InputToResolved from erc7730.model.input.descriptor import InputERC7730Descriptor +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor +from tests.assertions import assert_dict_equals from tests.cases import path_id from tests.files import ERC7730_EIP712_DESCRIPTORS from tests.schemas import assert_valid_legacy_eip_712 -from tests.skip import single_or_skip +from tests.skip import single_or_first, single_or_skip + +DATA = Path(__file__).resolve().parent / "data" @pytest.mark.parametrize("input_file", ERC7730_EIP712_DESCRIPTORS, ids=path_id) @@ -26,3 +33,22 @@ def test_erc7730_registry_files(input_file: Path) -> None: output_descriptor = convert_and_print_errors(resolved_erc7730_descriptor, ERC7730toEIP712Converter()) output_descriptor = single_or_skip(output_descriptor) assert_valid_legacy_eip_712(output_descriptor) + + +@pytest.mark.parametrize("input_file", ERC7730_EIP712_DESCRIPTORS, ids=path_id) +def test_erc7730_registry_files_by_reference(input_file: Path) -> None: + """ + Test converting ERC-7730 => Ledger legacy EIP-712. + + Note the test only applies to descriptors with a single contract and message, and only checks output files are + compliant with the Ledger legacy EIP-712 json schema. + """ + reference_path = DATA / input_file.name + if not reference_path.is_file(): + pytest.skip(f"No reference file at {reference_path}") + input_erc7730_descriptor = InputERC7730Descriptor.load(input_file) + resolved_erc7730_descriptors = convert_and_print_errors(input_erc7730_descriptor, ERC7730InputToResolved()) + resolved_erc7730_descriptor: ResolvedERC7730Descriptor = single_or_first(resolved_erc7730_descriptors) + output_descriptors = convert_and_print_errors(resolved_erc7730_descriptor, ERC7730toEIP712Converter()) + output_descriptor: InputEIP712DAppDescriptor = single_or_first(output_descriptors) + assert_dict_equals(dict_from_json_file(reference_path), model_to_json_dict(output_descriptor)) diff --git a/tests/skip.py b/tests/skip.py index 604d16d..aa7b889 100644 --- a/tests/skip.py +++ b/tests/skip.py @@ -9,3 +9,10 @@ def single_or_skip(value: _T | dict[str, _T] | None) -> _T: if isinstance(value, dict): pytest.skip("Multiple descriptors tests not supported") return value + + +def single_or_first(value: _T | dict[str, _T] | None) -> _T: + assert value is not None + if isinstance(value, dict): + return next(iter(value.values())) + return value