diff --git a/pdm.lock b/pdm.lock index 1de35300..437c340d 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:fcd73d41a44f479d4d44815663f04e6dea4aab64ac56414b2247975179be0685" +content_hash = "sha256:931f414b35c7aeea09da02ff2557664d6556b7015f5be5cc9997ae1c726893bd" [[metadata.targets]] requires_python = ">=3.12,<3.13" @@ -121,6 +121,17 @@ files = [ {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, ] +[[package]] +name = "case-switcher" +version = "1.3.13" +requires_python = ">=3.9,<4.0" +summary = "Library to change the casing of strings." +groups = ["default"] +files = [ + {file = "case-switcher-1.3.13.tar.gz", hash = "sha256:e8c9df5437ac58aa396617bde347093a025812b77fd73fa48439c3d4ae8294c8"}, + {file = "case_switcher-1.3.13-py3-none-any.whl", hash = "sha256:564e5dba2062e38862d1766187446362bbdaf9fe80eac9675757310d190712ca"}, +] + [[package]] name = "cattrs" version = "24.1.2" @@ -319,6 +330,22 @@ files = [ {file = "eip712_clearsign-3.0.3-py3-none-any.whl", hash = "sha256:37af75ae15ae69a60ba67f704ff2e9672b9709a28f26c67c70dd4f316a277b21"}, ] +[[package]] +name = "eth-abi" +version = "5.1.0" +requires_python = "<4,>=3.8" +summary = "eth_abi: Python utilities for working with Ethereum ABI definitions, especially encoding and decoding" +groups = ["default"] +dependencies = [ + "eth-typing>=3.0.0", + "eth-utils>=2.0.0", + "parsimonious<0.11.0,>=0.10.0", +] +files = [ + {file = "eth_abi-5.1.0-py3-none-any.whl", hash = "sha256:84cac2626a7db8b7d9ebe62b0fdca676ab1014cc7f777189e3c0cd721a4c16d8"}, + {file = "eth_abi-5.1.0.tar.gz", hash = "sha256:33ddd756206e90f7ddff1330cc8cac4aa411a824fe779314a0a52abea2c8fc14"}, +] + [[package]] name = "eth-hash" version = "0.7.0" @@ -762,6 +789,19 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "parsimonious" +version = "0.10.0" +summary = "(Soon to be) the fastest pure-Python PEG parser I could muster" +groups = ["default"] +dependencies = [ + "regex>=2022.3.15", +] +files = [ + {file = "parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f"}, + {file = "parsimonious-0.10.0.tar.gz", hash = "sha256:8281600da180ec8ae35427a4ab4f7b82bfec1e3d1e52f80cb60ea82b9512501c"}, +] + [[package]] name = "pbr" version = "6.1.0" @@ -921,7 +961,7 @@ files = [ [[package]] name = "pydantic-settings" -version = "2.6.0" +version = "2.6.1" requires_python = ">=3.8" summary = "Settings management using Pydantic" groups = ["dev"] @@ -930,8 +970,8 @@ dependencies = [ "python-dotenv>=0.21.0", ] files = [ - {file = "pydantic_settings-2.6.0-py3-none-any.whl", hash = "sha256:4a819166f119b74d7f8c765196b165f95cc7487ce58ea27dec8a5a26be0970e0"}, - {file = "pydantic_settings-2.6.0.tar.gz", hash = "sha256:44a1804abffac9e6a30372bb45f6cafab945ef5af25e66b1c634c01dd39e0188"}, + {file = "pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87"}, + {file = "pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0"}, ] [[package]] @@ -1141,6 +1181,31 @@ files = [ {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, ] +[[package]] +name = "regex" +version = "2024.9.11" +requires_python = ">=3.8" +summary = "Alternative regular expression module, to replace re." +groups = ["default"] +files = [ + {file = "regex-2024.9.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b0d0a6c64fcc4ef9c69bd5b3b3626cc3776520a1637d8abaa62b9edc147a58f7"}, + {file = "regex-2024.9.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:49b0e06786ea663f933f3710a51e9385ce0cba0ea56b67107fd841a55d56a231"}, + {file = "regex-2024.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5b513b6997a0b2f10e4fd3a1313568e373926e8c252bd76c960f96fd039cd28d"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee439691d8c23e76f9802c42a95cfeebf9d47cf4ffd06f18489122dbb0a7ad64"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8f877c89719d759e52783f7fe6e1c67121076b87b40542966c02de5503ace42"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23b30c62d0f16827f2ae9f2bb87619bc4fba2044911e2e6c2eb1af0161cdb766"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85ab7824093d8f10d44330fe1e6493f756f252d145323dd17ab6b48733ff6c0a"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8dee5b4810a89447151999428fe096977346cf2f29f4d5e29609d2e19e0199c9"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98eeee2f2e63edae2181c886d7911ce502e1292794f4c5ee71e60e23e8d26b5d"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:57fdd2e0b2694ce6fc2e5ccf189789c3e2962916fb38779d3e3521ff8fe7a822"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d552c78411f60b1fdaafd117a1fca2f02e562e309223b9d44b7de8be451ec5e0"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a0b2b80321c2ed3fcf0385ec9e51a12253c50f146fddb2abbb10f033fe3d049a"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:18406efb2f5a0e57e3a5881cd9354c1512d3bb4f5c45d96d110a66114d84d23a"}, + {file = "regex-2024.9.11-cp312-cp312-win32.whl", hash = "sha256:e464b467f1588e2c42d26814231edecbcfe77f5ac414d92cbf4e7b55b2c2a776"}, + {file = "regex-2024.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:9e8719792ca63c6b8340380352c24dcb8cd7ec49dae36e963742a275dfae6009"}, + {file = "regex-2024.9.11.tar.gz", hash = "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd"}, +] + [[package]] name = "requests" version = "2.32.3" @@ -1176,25 +1241,25 @@ files = [ [[package]] name = "rpds-py" -version = "0.20.0" +version = "0.20.1" requires_python = ">=3.8" summary = "Python bindings to Rust's persistent data structures (rpds)" groups = ["default"] files = [ - {file = "rpds_py-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6"}, - {file = "rpds_py-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174"}, - {file = "rpds_py-0.20.0-cp312-none-win32.whl", hash = "sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139"}, - {file = "rpds_py-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585"}, - {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, + {file = "rpds_py-0.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:36785be22066966a27348444b40389f8444671630063edfb1a2eb04318721e17"}, + {file = "rpds_py-0.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:142c0a5124d9bd0e2976089484af5c74f47bd3298f2ed651ef54ea728d2ea42c"}, + {file = "rpds_py-0.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbddc10776ca7ebf2a299c41a4dde8ea0d8e3547bfd731cb87af2e8f5bf8962d"}, + {file = "rpds_py-0.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15a842bb369e00295392e7ce192de9dcbf136954614124a667f9f9f17d6a216f"}, + {file = "rpds_py-0.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be5ef2f1fc586a7372bfc355986226484e06d1dc4f9402539872c8bb99e34b01"}, + {file = "rpds_py-0.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbcf360c9e3399b056a238523146ea77eeb2a596ce263b8814c900263e46031a"}, + {file = "rpds_py-0.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecd27a66740ffd621d20b9a2f2b5ee4129a56e27bfb9458a3bcc2e45794c96cb"}, + {file = "rpds_py-0.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0b937b2a1988f184a3e9e577adaa8aede21ec0b38320d6009e02bd026db04fa"}, + {file = "rpds_py-0.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6889469bfdc1eddf489729b471303739bf04555bb151fe8875931f8564309afc"}, + {file = "rpds_py-0.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:19b73643c802f4eaf13d97f7855d0fb527fbc92ab7013c4ad0e13a6ae0ed23bd"}, + {file = "rpds_py-0.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3c6afcf2338e7f374e8edc765c79fbcb4061d02b15dd5f8f314a4af2bdc7feb5"}, + {file = "rpds_py-0.20.1-cp312-none-win32.whl", hash = "sha256:dc73505153798c6f74854aba69cc75953888cf9866465196889c7cdd351e720c"}, + {file = "rpds_py-0.20.1-cp312-none-win_amd64.whl", hash = "sha256:8bbe951244a838a51289ee53a6bae3a07f26d4e179b96fc7ddd3301caf0518eb"}, + {file = "rpds_py-0.20.1.tar.gz", hash = "sha256:e1791c4aabd117653530dccd24108fa03cc6baf21f58b950d0a73c3b3b29a350"}, ] [[package]] @@ -1623,7 +1688,7 @@ files = [ [[package]] name = "virtualenv" -version = "20.27.0" +version = "20.27.1" requires_python = ">=3.8" summary = "Virtual Python Environment builder" groups = ["dev"] @@ -1634,8 +1699,8 @@ dependencies = [ "platformdirs<5,>=3.9.1", ] files = [ - {file = "virtualenv-20.27.0-py3-none-any.whl", hash = "sha256:44a72c29cceb0ee08f300b314848c86e57bf8d1f13107a5e671fb9274138d655"}, - {file = "virtualenv-20.27.0.tar.gz", hash = "sha256:2ca56a68ed615b8fe4326d11a0dca5dfbe8fd68510fb6c6349163bed3c15f2b2"}, + {file = "virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4"}, + {file = "virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 468a88ab..71ad5819 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ dependencies = [ "hishel>=0.0.33", "xdg-base-dirs>=6.0.2", "limiter>=0.5.0", + "eth-abi>=5.1.0", + "case-switcher>=1.3.13", ] [project.urls] diff --git a/src/erc7730/common/abi.py b/src/erc7730/common/abi.py index d3564f16..04427bf3 100644 --- a/src/erc7730/common/abi.py +++ b/src/erc7730/common/abi.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from enum import StrEnum, auto from typing import Any, cast from eth_typing import ABIFunction @@ -114,3 +115,16 @@ def get_functions(abis: list[ABI]) -> Functions: if abi.name in ("proxyType", "getImplementation", "implementation", "proxy__getImplementation"): functions.proxy = True return functions + + +class ABIDataType(StrEnum): + """Solidity data type.""" + + UINT = auto() + INT = auto() + UFIXED = auto() + FIXED = auto() + ADDRESS = auto() + BOOL = auto() + BYTES = auto() + STRING = auto() diff --git a/src/erc7730/convert/ledger/eip712/convert_eip712_to_erc7730.py b/src/erc7730/convert/ledger/eip712/convert_eip712_to_erc7730.py index d3ad2b58..38ee5491 100644 --- a/src/erc7730/convert/ledger/eip712/convert_eip712_to_erc7730.py +++ b/src/erc7730/convert/ledger/eip712/convert_eip712_to_erc7730.py @@ -9,7 +9,7 @@ from erc7730.common.output import ExceptionsToOutput, OutputAdder from erc7730.convert import ERC7730Converter -from erc7730.model.context import EIP712JsonSchema +from erc7730.model.context import EIP712Schema from erc7730.model.display import ( DateEncoding, FieldFormat, @@ -46,13 +46,13 @@ def convert( for contract in descriptor.contracts: formats: dict[str, InputFormat] = {} - schemas: list[EIP712JsonSchema | HttpUrl] = [] + schemas: list[EIP712Schema | HttpUrl] = [] for message in contract.messages: if (primary_type := self._get_primary_type(message.schema_, out)) is None: return None - schemas.append(EIP712JsonSchema(primaryType=primary_type, types=message.schema_)) + schemas.append(EIP712Schema(primaryType=primary_type, types=message.schema_)) formats[primary_type] = InputFormat( intent=message.mapper.label, 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 ed36c7a5..48858cd2 100644 --- a/src/erc7730/convert/ledger/eip712/convert_erc7730_to_eip712.py +++ b/src/erc7730/convert/ledger/eip712/convert_erc7730_to_eip712.py @@ -9,7 +9,7 @@ from erc7730.common.ledger import ledger_network_id from erc7730.common.output import ExceptionsToOutput, OutputAdder from erc7730.convert import ERC7730Converter -from erc7730.model.context import EIP712JsonSchema +from erc7730.model.context import EIP712Schema from erc7730.model.display import FieldFormat from erc7730.model.paths import ContainerField, ContainerPath, DataPath from erc7730.model.paths.path_ops import data_path_concat, to_relative @@ -96,7 +96,7 @@ def _build_network_descriptor( @classmethod def _get_schema( - cls, primary_type: str, schemas: list[EIP712JsonSchema], out: OutputAdder + cls, primary_type: str, schemas: list[EIP712Schema], out: OutputAdder ) -> dict[str, list[EIP712SchemaField]] | None: for schema in schemas: if schema.primaryType == primary_type: diff --git a/src/erc7730/convert/resolved/convert_erc7730_input_to_resolved.py b/src/erc7730/convert/resolved/convert_erc7730_input_to_resolved.py index 59c75fcf..c208e6cb 100644 --- a/src/erc7730/convert/resolved/convert_erc7730_input_to_resolved.py +++ b/src/erc7730/convert/resolved/convert_erc7730_input_to_resolved.py @@ -11,7 +11,7 @@ from erc7730.convert.resolved.parameters import resolve_field_parameters from erc7730.convert.resolved.references import resolve_reference from erc7730.model.abi import ABI -from erc7730.model.context import EIP712JsonSchema +from erc7730.model.context import EIP712Schema from erc7730.model.display import ( FieldFormat, ) @@ -232,9 +232,7 @@ def _resolve_domain(cls, domain: InputDomain, out: OutputAdder) -> ResolvedDomai ) @classmethod - def _resolve_schemas( - cls, schemas: list[EIP712JsonSchema | HttpUrl], out: OutputAdder - ) -> list[EIP712JsonSchema] | None: + def _resolve_schemas(cls, schemas: list[EIP712Schema | HttpUrl], out: OutputAdder) -> list[EIP712Schema] | None: resolved_schemas = [] for schema in schemas: if (resolved_schema := cls._resolve_schema(schema, out)) is not None: @@ -242,17 +240,17 @@ def _resolve_schemas( return resolved_schemas @classmethod - def _resolve_schema(cls, schema: EIP712JsonSchema | HttpUrl, out: OutputAdder) -> EIP712JsonSchema | None: + def _resolve_schema(cls, schema: EIP712Schema | HttpUrl, out: OutputAdder) -> EIP712Schema | None: match schema: case HttpUrl() as url: try: - return client.get(url=url, model=EIP712JsonSchema) + return client.get(url=url, model=EIP712Schema) except Exception as e: return out.error( title="Failed to fetch EIP-712 schema from URL", message=f'Failed to fetch EIP-712 schema from URL "{url}": {e}', ) - case EIP712JsonSchema(): + case EIP712Schema(): return schema case _: assert_never(schema) diff --git a/src/erc7730/generate/generate.py b/src/erc7730/generate/generate.py index 7f0c829e..cc36eb46 100644 --- a/src/erc7730/generate/generate.py +++ b/src/erc7730/generate/generate.py @@ -1,54 +1,246 @@ -from erc7730.common.abi import compute_signature +from collections.abc import Generator +from pathlib import Path +from typing import Any, assert_never + +from caseswitcher import to_title +from pydantic import TypeAdapter +from pydantic_string_url import HttpUrl + +from erc7730.common.abi import ABIDataType, compute_signature, get_functions from erc7730.common.client import get_contract_abis -from erc7730.model.abi import Function, InputOutput -from erc7730.model.display import FieldFormat -from erc7730.model.input.context import InputContract, InputContractContext, InputDeployment +from erc7730.generate.schema_tree import ( + SchemaArray, + SchemaLeaf, + SchemaStruct, + SchemaTree, + abi_function_to_tree, + eip712_schema_to_tree, +) +from erc7730.model.abi import ABI +from erc7730.model.context import EIP712Schema +from erc7730.model.display import AddressNameType, DateEncoding, FieldFormat +from erc7730.model.input.context import ( + InputContract, + InputContractContext, + InputDeployment, + InputEIP712, + InputEIP712Context, +) from erc7730.model.input.descriptor import InputERC7730Descriptor -from erc7730.model.input.display import InputDisplay, InputField, InputFieldDescription, InputFormat +from erc7730.model.input.display import ( + InputAddressNameParameters, + InputDateParameters, + InputDisplay, + InputField, + InputFieldDescription, + InputFieldParameters, + InputFormat, + InputNestedFields, +) from erc7730.model.input.metadata import InputMetadata -from erc7730.model.paths import DataPath, Field +from erc7730.model.metadata import OwnerInfo +from erc7730.model.paths import ROOT_DATA_PATH, Array, ArrayElement, ArraySlice, DataPath, Field +from erc7730.model.paths.path_ops import data_path_append from erc7730.model.types import Address -def generate_contract(chain_id: int, contract_address: Address) -> InputERC7730Descriptor: +def generate_descriptor( + chain_id: int, + contract_address: Address, + abi_file: Path | None = None, + eip712_schema_file: Path | None = None, + owner: str | None = None, + legal_name: str | None = None, + url: HttpUrl | None = None, +) -> InputERC7730Descriptor: """ - Generate an ERC-7730 descriptor for the given contract address. + Generate an ERC-7730 descriptor. + + If an EIP-712 schema file is provided, an EIP-712 descriptor is generated for this schema, otherwise a calldata + descriptor. If no ABI file is supplied, the ABIs are fetched from Etherscan using the chain id / contract address. :param chain_id: contract chain id :param contract_address: contract address + :param abi_file: path to a JSON ABI file (to generate a calldata descriptor) + :param eip712_schema_file: path to an EIP-712 schema (to generate an EIP-712 descriptor) + :param owner: the display name of the owner or target of the contract / message to be clear signed + :param legal_name: the full legal name of the owner if different from the owner field + :param url: URL with more info on the entity the user interacts with :return: a generated ERC-7730 descriptor """ - if (abis := get_contract_abis(chain_id, contract_address)) is None: - raise Exception("Failed to fetch contract ABIs") - return InputERC7730Descriptor( - context=InputContractContext( - contract=InputContract( - abi=abis, - deployments=[InputDeployment(chainId=chain_id, address=contract_address)], - ) - ), - metadata=InputMetadata(), - display=InputDisplay( - formats={ - compute_signature(abi): InputFormat(fields=_generate_abi_fields(abi)) - for abi in abis - if isinstance(abi, Function) - } - ), + context, trees = _generate_context(chain_id, contract_address, abi_file, eip712_schema_file) + metadata = _generate_metadata(legal_name, owner, url) + display = _generate_display(trees) + + return InputERC7730Descriptor(context=context, metadata=metadata, display=display) + + +def _generate_metadata(owner: str | None, legal_name: str | None, url: HttpUrl | None) -> InputMetadata: + info = OwnerInfo(legalName=legal_name, url=url) if legal_name is not None and url is not None else None + return InputMetadata(owner=owner, info=info) + + +def _generate_context( + chain_id: int, contract_address: Address, abi_file: Path | None, eip712_schema_file: Path | None +) -> tuple[InputContractContext | InputEIP712Context, dict[str, SchemaTree]]: + if eip712_schema_file is not None: + return _generate_context_eip712(chain_id, contract_address, eip712_schema_file) + return _generate_context_calldata(chain_id, contract_address, abi_file) + + +def _generate_context_eip712( + chain_id: int, contract_address: Address, eip712_schema_file: Path +) -> tuple[InputEIP712Context, dict[str, SchemaTree]]: + with open(eip712_schema_file, "rb") as f: + schemas = TypeAdapter(list[EIP712Schema]).validate_json(f.read()) + + context = InputEIP712Context( + eip712=InputEIP712(schemas=schemas, deployments=[InputDeployment(chainId=chain_id, address=contract_address)]) ) + trees = {schema.primaryType: eip712_schema_to_tree(schema) for schema in schemas} + + return context, trees -def _generate_abi_fields(function: Function) -> list[InputField]: - if not (inputs := function.inputs): - return [] - return [_generate_abi_field(input) for input in inputs] +def _generate_context_calldata( + chain_id: int, contract_address: Address, abi_file: Path | None +) -> tuple[InputContractContext, dict[str, SchemaTree]]: + if abi_file is not None: + with open(abi_file, "rb") as f: + abis = TypeAdapter(list[ABI]).validate_json(f.read()) -def _generate_abi_field(input: InputOutput) -> InputField: - # TODO must recursive into ABI types - return InputFieldDescription( - path=DataPath(absolute=True, elements=[Field(identifier=input.name)]), - label=input.name, - format=FieldFormat.RAW, # TODO adapt format based on type + elif (abis := get_contract_abis(chain_id, contract_address)) is None: + raise Exception("Failed to fetch contract ABIs") + + functions = list(get_functions(abis).functions.values()) + + context = InputContractContext( + contract=InputContract(abi=functions, deployments=[InputDeployment(chainId=chain_id, address=contract_address)]) ) + + trees = {compute_signature(function): abi_function_to_tree(function) for function in functions} + + return context, trees + + +def _generate_display(trees: dict[str, SchemaTree]) -> InputDisplay: + return InputDisplay(formats=_generate_formats(trees)) + + +def _generate_formats(trees: dict[str, SchemaTree]) -> dict[str, InputFormat]: + formats: dict[str, InputFormat] = {} + for name, tree in trees.items(): + if fields := list(_generate_fields(schema=tree, path=ROOT_DATA_PATH)): + formats[name] = InputFormat(fields=fields) + return formats + + +def _generate_fields(schema: SchemaTree, path: DataPath) -> Generator[InputField, Any, Any]: + match schema: + case SchemaStruct(components=components) if path == ROOT_DATA_PATH: + for name, component in components.items(): + if name: + yield from _generate_fields(component, data_path_append(path, Field(identifier=name))) + + case SchemaStruct(components=components): + fields = [ + field + for name, component in components.items() + for field in _generate_fields(component, DataPath(absolute=False, elements=[Field(identifier=name)])) + if name + ] + yield InputNestedFields(path=path, fields=fields) + + case SchemaArray(component=component): + match component: + case SchemaStruct() | SchemaArray(): + yield InputNestedFields( + path=data_path_append(path, Array()), + fields=list(_generate_fields(component, DataPath(absolute=False, elements=[]))), + ) + case SchemaLeaf(): + yield from _generate_fields(component, data_path_append(path, Array())) + case _: + assert_never(schema) + + case SchemaLeaf(data_type=data_type): + name = _get_leaf_name(path) + format, params = _generate_field(name, data_type) + yield InputFieldDescription(path=path, label=name, format=format, params=params) + + case _: + assert_never(schema) + + +def _generate_field(name: str, data_type: ABIDataType) -> tuple[FieldFormat, InputFieldParameters | None]: + match data_type: + case ABIDataType.UINT | ABIDataType.INT: + # other applicable formats could be TOKEN_AMOUNT, UNIT or ENUM, but we can't tell + + if _contains_any_of(name, "duration"): + return FieldFormat.DURATION, None + + if _contains_any_of(name, "height"): + return FieldFormat.DATE, InputDateParameters(encoding=DateEncoding.BLOCKHEIGHT) + + if _contains_any_of(name, "deadline", "expiration", "until", "time", "timestamp"): + return FieldFormat.DATE, InputDateParameters(encoding=DateEncoding.TIMESTAMP) + + if _contains_any_of(name, "amount", "value", "price"): + return FieldFormat.AMOUNT, None + + return FieldFormat.RAW, None + + case ABIDataType.UFIXED | ABIDataType.FIXED: + return FieldFormat.RAW, None + + case ABIDataType.ADDRESS: + if _contains_any_of(name, "collection", "nft"): + return FieldFormat.NFT_NAME, InputAddressNameParameters(types=[AddressNameType.COLLECTION]) + + if _contains_any_of(name, "spender"): + return FieldFormat.ADDRESS_NAME, InputAddressNameParameters(types=[AddressNameType.CONTRACT]) + + if _contains_any_of(name, "asset", "token"): + return FieldFormat.ADDRESS_NAME, InputAddressNameParameters(types=[AddressNameType.TOKEN]) + + if _contains_any_of(name, "from", "to", "owner", "recipient", "receiver", "account"): + return FieldFormat.ADDRESS_NAME, InputAddressNameParameters( + types=[AddressNameType.EOA, AddressNameType.WALLET] + ) + + return FieldFormat.ADDRESS_NAME, InputAddressNameParameters(types=list(AddressNameType)) + + case ABIDataType.BOOL: + return FieldFormat.RAW, None + + case ABIDataType.BYTES: + if _contains_any_of(name, "calldata"): + return FieldFormat.CALL_DATA, None + + return FieldFormat.RAW, None + + case ABIDataType.STRING: + return FieldFormat.RAW, None + + case _: + assert_never(data_type) + + +def _get_leaf_name(path: DataPath) -> str: + for element in reversed(path.elements): + match element: + case Field(identifier=name): + return to_title(name).strip() + case Array() | ArrayElement() | ArraySlice(): + continue + case _: + assert_never(element) + return "unknown" + + +def _contains_any_of(name: str, *values: str) -> bool: + name_lower = name.lower() + return any(value in name_lower for value in values) diff --git a/src/erc7730/generate/schema_tree.py b/src/erc7730/generate/schema_tree.py new file mode 100644 index 00000000..cccac063 --- /dev/null +++ b/src/erc7730/generate/schema_tree.py @@ -0,0 +1,184 @@ +from abc import ABC +from typing import assert_never + +import eth_abi +from eip712.model.schema import EIP712SchemaField, EIP712Type +from eth_abi.grammar import BasicType, TupleType +from pydantic import Field + +from erc7730.common.abi import ABIDataType +from erc7730.model.abi import Component, Function, InputOutput +from erc7730.model.base import Model +from erc7730.model.context import EIP712Schema + + +class SchemaNode(Model, ABC): + """Represents a node in the tree defined by a function ABI / EIP-712 schema.""" + + +class SchemaStruct(SchemaNode): + """Schema node representing a function or a tuple.""" + + components: dict[str, "SchemaTree"] = Field(title="Struct components") + + +class SchemaArray(SchemaNode): + """Schema node representing an array.""" + + component: "SchemaTree" = Field(title="Array element type") + + +class SchemaLeaf(SchemaNode): + """Schema node representing a scalar type.""" + + data_type: ABIDataType = Field(title="Data type") + + +SchemaTree = SchemaStruct | SchemaArray | SchemaLeaf + + +def eip712_schema_to_tree(schema: EIP712Schema) -> SchemaTree: + """ + Convert an EIP-712 schema to a schema tree. + + A schema tree is a tree representation of the EIP-712 schema, enriched with some metadata to ease crafting + paths to access values in the message. + + :param schema: EIP-712 schema + :return: Schema tree + """ + if (primary_type_fields := schema.types.get(schema.primaryType)) is None: + raise ValueError("Primary type not found in schema types") + + return _eip712_struct_type_to_tree(fields=primary_type_fields, types=schema.types) + + +def _eip712_struct_type_to_tree( + fields: list[EIP712SchemaField], types: dict[EIP712Type, list[EIP712SchemaField]] +) -> SchemaTree: + """ + Convert a complex EIP-712 type to a schema tree node (can be the primary type directly). + + :param fields: type fields + :param types: all schema types + :return: Schema tree + """ + return SchemaStruct(components={field.name: _eip712_field_to_tree(field, types) for field in fields}) + + +def _eip712_field_to_tree(field: EIP712SchemaField, types: dict[EIP712Type, list[EIP712SchemaField]]) -> SchemaTree: + """ + Convert an EIP-712 type to a schema tree node. + + :param field: ABI element (can be a single component, or a function input) + :param types: all schema types + :return: Schema tree + """ + match eth_abi.grammar.parse(field.type): + case TupleType(): + return _eip712_struct_type_to_tree(types[field.type], types) + + case BasicType() as tp: + if tp.is_array: + match tp.base: + case "tuple" | "struct": + component = _eip712_struct_type_to_tree(types[field.type], types) + case _: + component = _eip712_field_to_tree( + field=field.model_copy(update={"type": tp.item_type.to_type_str()}), types=types + ) + + return SchemaArray(component=component) + + match tp.base: + case "tuple" | "struct": + return _eip712_struct_type_to_tree(types[field.type], types) + case base if base in set(ABIDataType): + type_family = ABIDataType(base) + case base_type: + if (base_type_fields := types.get(base_type)) is None: + raise Exception(f"Unexpected EIP-712 type: {base_type}") + return _eip712_struct_type_to_tree(base_type_fields, types) + + return SchemaLeaf(data_type=type_family) + + case unknown: + raise Exception(f"Unexpected EIP-712 type: {type(unknown)}") + + +def abi_function_to_tree(function: Function) -> SchemaTree: + """ + Convert a function ABI to a schema tree. + + A schema tree is a tree representation of the ABI of a function inputs, enriched with some metadata to ease + crafting paths to access values in the serialized calldata. + + :param function: function ABI + :return: Schema tree + """ + return _abi_struct_component_to_tree(function) + + +def _abi_struct_component_to_tree(inp: Function | InputOutput | Component) -> SchemaTree: + """ + Convert a struct-like ABI component to a schema tree node (can be the top level function directly). + + :param inp: ABI element + :return: Schema tree + """ + + # get inputs/components list based on argument type + input_components: list[InputOutput | Component] = [] + match inp: + case Function(inputs=inputs): + if inputs is not None: + input_components.extend(inputs) + case InputOutput(components=inp_components): + if inp_components is not None: + input_components.extend(inp_components) + case Component(components=inp_components): + if inp_components is not None: + input_components.extend(inp_components) + case list() as eip712_fields: + input_components.extend(eip712_fields) + case _: + assert_never(inp) + + return SchemaStruct( + components={component.name: _abi_component_to_tree(component) for component in input_components} + ) + + +def _abi_component_to_tree(inp: InputOutput | Component) -> SchemaTree: + """ + Convert an ABI component to a schema tree node. + + :param inp: ABI element (can be a single component, or a function input) + :return: Schema tree + """ + match eth_abi.grammar.parse(inp.type): + case TupleType(): + return _abi_struct_component_to_tree(inp) + + case BasicType() as tp: + if tp.is_array: + match tp.base: + case "tuple" | "struct": + component = _abi_struct_component_to_tree(inp) + case _: + component = _abi_component_to_tree(inp.model_copy(update={"type": tp.item_type.to_type_str()})) + + return SchemaArray(component=component) + + match tp.base: + case "tuple" | "struct": + return _abi_struct_component_to_tree(inp) + case base if base in set(ABIDataType): + type_family = ABIDataType(base) + case unknown: + raise Exception(f"Unexpected ABI type: {unknown}") + + return SchemaLeaf(data_type=type_family) + + case unknown: + raise Exception(f"Unexpected ABI type: {type(unknown)}") diff --git a/src/erc7730/lint/classifier/__init__.py b/src/erc7730/lint/classifier/__init__.py index cc2fc267..404052c9 100644 --- a/src/erc7730/lint/classifier/__init__.py +++ b/src/erc7730/lint/classifier/__init__.py @@ -3,7 +3,7 @@ from typing import Generic, TypeVar from erc7730.model.abi import ABI -from erc7730.model.context import EIP712JsonSchema +from erc7730.model.context import EIP712Schema class TxClass(StrEnum): @@ -13,7 +13,7 @@ class TxClass(StrEnum): WITHDRAW = auto() -Schema = TypeVar("Schema", list[ABI], EIP712JsonSchema) +Schema = TypeVar("Schema", list[ABI], EIP712Schema) class Classifier(ABC, Generic[Schema]): diff --git a/src/erc7730/lint/classifier/eip712_classifier.py b/src/erc7730/lint/classifier/eip712_classifier.py index aae90ba3..f35eebd8 100644 --- a/src/erc7730/lint/classifier/eip712_classifier.py +++ b/src/erc7730/lint/classifier/eip712_classifier.py @@ -1,17 +1,17 @@ from typing import final, override from erc7730.lint.classifier import Classifier, TxClass -from erc7730.model.context import EIP712JsonSchema +from erc7730.model.context import EIP712Schema @final -class EIP712Classifier(Classifier[EIP712JsonSchema]): +class EIP712Classifier(Classifier[EIP712Schema]): """Given an EIP712 schema, classify the transaction type with some predefined ruleset. (implemented a basic detection of a permit) """ @override - def classify(self, schema: EIP712JsonSchema) -> TxClass | None: + def classify(self, schema: EIP712Schema) -> TxClass | None: if "permit" in schema.primaryType.lower(): return TxClass.PERMIT return None diff --git a/src/erc7730/lint/lint_transaction_type_classifier.py b/src/erc7730/lint/lint_transaction_type_classifier.py index 57e83a08..547a3d93 100644 --- a/src/erc7730/lint/lint_transaction_type_classifier.py +++ b/src/erc7730/lint/lint_transaction_type_classifier.py @@ -5,7 +5,7 @@ from erc7730.lint.classifier import TxClass from erc7730.lint.classifier.abi_classifier import ABIClassifier from erc7730.lint.classifier.eip712_classifier import EIP712Classifier -from erc7730.model.resolved.context import EIP712JsonSchema, ResolvedContractContext, ResolvedEIP712Context +from erc7730.model.resolved.context import EIP712Schema, ResolvedContractContext, ResolvedEIP712Context from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor from erc7730.model.resolved.display import ResolvedDisplay, ResolvedFormat @@ -34,7 +34,7 @@ def _determine_tx_class(cls, descriptor: ResolvedERC7730Descriptor) -> TxClass | classifier = EIP712Classifier() if descriptor.context.eip712.schemas is not None: first_schema = descriptor.context.eip712.schemas[0] - if isinstance(first_schema, EIP712JsonSchema): + if isinstance(first_schema, EIP712Schema): return classifier.classify(first_schema) # url should have been resolved earlier elif isinstance(descriptor.context, ResolvedContractContext): diff --git a/src/erc7730/lint/lint_validate_display_fields.py b/src/erc7730/lint/lint_validate_display_fields.py index 9ef49e87..34bfa434 100644 --- a/src/erc7730/lint/lint_validate_display_fields.py +++ b/src/erc7730/lint/lint_validate_display_fields.py @@ -10,7 +10,7 @@ compute_eip712_schema_paths, compute_format_schema_paths, ) -from erc7730.model.resolved.context import EIP712JsonSchema, ResolvedContractContext, ResolvedEIP712Context +from erc7730.model.resolved.context import EIP712Schema, ResolvedContractContext, ResolvedEIP712Context from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor AUTHORIZED_MISSING_DISPLAY_FIELDS = { @@ -36,7 +36,7 @@ def _validate_eip712_paths(cls, descriptor: ResolvedERC7730Descriptor, out: Outp if isinstance(descriptor.context, ResolvedEIP712Context) and descriptor.context.eip712.schemas is not None: primary_types: set[str] = set() for schema in descriptor.context.eip712.schemas: - if isinstance(schema, EIP712JsonSchema): + if isinstance(schema, EIP712Schema): primary_types.add(schema.primaryType) if schema.primaryType not in schema.types: out.error( diff --git a/src/erc7730/main.py b/src/erc7730/main.py index d8112c0f..f41d286d 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -4,6 +4,8 @@ from eip712.convert.input_to_resolved import EIP712InputToResolvedConverter from eip712.model.input.descriptor import InputEIP712DAppDescriptor +from pydantic_string_url import HttpUrl +from rich import print from typer import Argument, Exit, Option, Typer from erc7730.common.output import ConsoleOutputAdder @@ -11,7 +13,7 @@ from erc7730.convert.ledger.eip712.convert_eip712_to_erc7730 import EIP712toERC7730Converter from erc7730.convert.ledger.eip712.convert_erc7730_to_eip712 import ERC7730toEIP712Converter from erc7730.convert.resolved.convert_erc7730_input_to_resolved import ERC7730InputToResolved -from erc7730.generate.generate import generate_contract +from erc7730.generate.generate import generate_descriptor from erc7730.lint.lint import lint_all_and_print_errors from erc7730.model import ERC7730ModelType from erc7730.model.base import Model @@ -109,10 +111,21 @@ def resolve( def generate( chain_id: Annotated[int, Option(help="The EIP-155 chain id")], address: Annotated[Address, Option(help="The contract address")], + abi: Annotated[Path | None, Option(help="Path to a JSON ABI file (to generate a calldata descriptor)")] = None, + schema: Annotated[Path | None, Option(help="Path to an EIP-712 schema (to generate an EIP-712 descriptor)")] = None, + owner: Annotated[str | None, Option(help="The display name of the owner or target of the contract")] = None, + legal_name: Annotated[str | None, Option(help="The full legal name of the owner")] = None, + url: Annotated[str | None, Option(help="URL with more info on the entity interacted with")] = None, ) -> None: - # TODO: add support for providing ABI file - # TODO: add support for providing EIP-712 schema file - descriptor = generate_contract(chain_id, address) + descriptor = generate_descriptor( + chain_id=chain_id, + contract_address=address, + abi_file=abi, + eip712_schema_file=schema, + owner=owner, + legal_name=legal_name, + url=HttpUrl(url) if url is not None else None, + ) print(descriptor.to_json_string()) diff --git a/src/erc7730/model/context.py b/src/erc7730/model/context.py index aa6c1311..7db6a1fc 100644 --- a/src/erc7730/model/context.py +++ b/src/erc7730/model/context.py @@ -1,13 +1,12 @@ from eip712.model.schema import EIP712SchemaField, EIP712Type from pydantic import Field -from pydantic_string_url import HttpUrl from erc7730.model.base import Model # ruff: noqa: N815 - camel case field names are tolerated to match schema -class EIP712JsonSchema(Model): +class EIP712Schema(Model): """ EIP-712 message schema. """ @@ -17,13 +16,3 @@ class EIP712JsonSchema(Model): types: dict[EIP712Type, list[EIP712SchemaField]] = Field( title="Types", description="The schema types reachable from primary type." ) - - -class EIP712Schema(Model): - """ - EIP-712 message schema. - """ - - eip712Schema: HttpUrl | EIP712JsonSchema = Field( - title="EIP-712 message schema", description="The EIP-712 message schema." - ) diff --git a/src/erc7730/model/input/context.py b/src/erc7730/model/input/context.py index 910d892f..3755292b 100644 --- a/src/erc7730/model/input/context.py +++ b/src/erc7730/model/input/context.py @@ -3,7 +3,7 @@ from erc7730.model.abi import ABI from erc7730.model.base import Model -from erc7730.model.context import EIP712JsonSchema +from erc7730.model.context import EIP712Schema from erc7730.model.types import Id, MixedCaseAddress # ruff: noqa: N815 - camel case field names are tolerated to match schema @@ -110,7 +110,7 @@ class InputEIP712(InputBindingContext): description="The domain separator value that must be matched by the message. In hex string representation.", ) - schemas: list[EIP712JsonSchema | HttpUrl] = Field( + schemas: list[EIP712Schema | HttpUrl] = Field( title="EIP-712 messages schemas", description="Schemas of all messages." ) diff --git a/src/erc7730/model/paths/path_schemas.py b/src/erc7730/model/paths/path_schemas.py index 76efbe59..1137caa7 100644 --- a/src/erc7730/model/paths/path_schemas.py +++ b/src/erc7730/model/paths/path_schemas.py @@ -4,7 +4,7 @@ from eip712.model.schema import EIP712SchemaField from erc7730.model.abi import Component, Function, InputOutput -from erc7730.model.context import EIP712JsonSchema +from erc7730.model.context import EIP712Schema from erc7730.model.paths import ( ROOT_DATA_PATH, Array, @@ -38,7 +38,7 @@ class FormatPaths: container_paths: set[ContainerPath] # References to values in the container -def compute_eip712_schema_paths(schema: EIP712JsonSchema) -> set[DataPath]: +def compute_eip712_schema_paths(schema: EIP712Schema) -> set[DataPath]: """ Compute the sets of valid schema paths for an EIP-712 schema. diff --git a/src/erc7730/model/resolved/context.py b/src/erc7730/model/resolved/context.py index cf60e820..bbb647cf 100644 --- a/src/erc7730/model/resolved/context.py +++ b/src/erc7730/model/resolved/context.py @@ -3,7 +3,7 @@ from erc7730.model.abi import ABI from erc7730.model.base import Model -from erc7730.model.context import EIP712JsonSchema +from erc7730.model.context import EIP712Schema from erc7730.model.types import Address, Id # ruff: noqa: N815 - camel case field names are tolerated to match schema @@ -111,7 +111,7 @@ class ResolvedEIP712(ResolvedBindingContext): description="The domain separator value that must be matched by the message. In hex string representation.", ) - schemas: list[EIP712JsonSchema] = Field(title="EIP-712 messages schemas", description="Schemas of all messages.") + schemas: list[EIP712Schema] = Field(title="EIP-712 messages schemas", description="Schemas of all messages.") class ResolvedContractContext(Model): diff --git a/tests/generate/__init__.py b/tests/generate/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/generate/data/abis001.json b/tests/generate/data/abis001.json new file mode 100644 index 00000000..c8546f40 --- /dev/null +++ b/tests/generate/data/abis001.json @@ -0,0 +1,1033 @@ +[ + { + "type": "function", + "name": "WETH9", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "approveMax", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "approveMaxMinusOne", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "approveZeroThenMax", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "approveZeroThenMaxMinusOne", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "callPositionManager", + "inputs": [ + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "result", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "checkOracleSlippage", + "inputs": [ + { + "name": "paths", + "type": "bytes[]", + "internalType": "bytes[]" + }, + { + "name": "amounts", + "type": "uint128[]", + "internalType": "uint128[]" + }, + { + "name": "maximumTickDivergence", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "secondsAgo", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "view" + }, + { + "type": "function", + "name": "checkOracleSlippage", + "inputs": [ + { + "name": "path", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "maximumTickDivergence", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "secondsAgo", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "view" + }, + { + "type": "function", + "name": "exactInput", + "inputs": [ + { + "name": "params", + "type": "tuple", + "internalType": "struct IV3SwapRouter.ExactInputParams", + "components": [ + { + "name": "path", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amountOutMinimum", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "outputs": [ + { + "name": "amountOut", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "exactInputSingle", + "inputs": [ + { + "name": "params", + "type": "tuple", + "internalType": "struct IV3SwapRouter.ExactInputSingleParams", + "components": [ + { + "name": "tokenIn", + "type": "address", + "internalType": "address" + }, + { + "name": "tokenOut", + "type": "address", + "internalType": "address" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amountOutMinimum", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "sqrtPriceLimitX96", + "type": "uint160", + "internalType": "uint160" + } + ] + } + ], + "outputs": [ + { + "name": "amountOut", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "exactOutput", + "inputs": [ + { + "name": "params", + "type": "tuple", + "internalType": "struct IV3SwapRouter.ExactOutputParams", + "components": [ + { + "name": "path", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amountOut", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amountInMaximum", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "outputs": [ + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "exactOutputSingle", + "inputs": [ + { + "name": "params", + "type": "tuple", + "internalType": "struct IV3SwapRouter.ExactOutputSingleParams", + "components": [ + { + "name": "tokenIn", + "type": "address", + "internalType": "address" + }, + { + "name": "tokenOut", + "type": "address", + "internalType": "address" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "amountOut", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amountInMaximum", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "sqrtPriceLimitX96", + "type": "uint160", + "internalType": "uint160" + } + ] + } + ], + "outputs": [ + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "factory", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "factoryV2", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getApprovalType", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enum IApproveAndCall.ApprovalType" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "increaseLiquidity", + "inputs": [ + { + "name": "params", + "type": "tuple", + "internalType": "struct IApproveAndCall.IncreaseLiquidityParams", + "components": [ + { + "name": "token0", + "type": "address", + "internalType": "address" + }, + { + "name": "token1", + "type": "address", + "internalType": "address" + }, + { + "name": "tokenId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amount0Min", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amount1Min", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "outputs": [ + { + "name": "result", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "mint", + "inputs": [ + { + "name": "params", + "type": "tuple", + "internalType": "struct IApproveAndCall.MintParams", + "components": [ + { + "name": "token0", + "type": "address", + "internalType": "address" + }, + { + "name": "token1", + "type": "address", + "internalType": "address" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "tickLower", + "type": "int24", + "internalType": "int24" + }, + { + "name": "tickUpper", + "type": "int24", + "internalType": "int24" + }, + { + "name": "amount0Min", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amount1Min", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ] + } + ], + "outputs": [ + { + "name": "result", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "multicall", + "inputs": [ + { + "name": "previousBlockhash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "data", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "multicall", + "inputs": [ + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "multicall", + "inputs": [ + { + "name": "data", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "outputs": [ + { + "name": "results", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "positionManager", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pull", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "refundETH", + "inputs": [], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "selfPermit", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "selfPermitAllowed", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expiry", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "selfPermitAllowedIfNecessary", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expiry", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "selfPermitIfNecessary", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "swapExactTokensForTokens", + "inputs": [ + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amountOutMin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "path", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "amountOut", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "swapTokensForExactTokens", + "inputs": [ + { + "name": "amountOut", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amountInMax", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "path", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "sweepToken", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amountMinimum", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "sweepToken", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amountMinimum", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "sweepTokenWithFee", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amountMinimum", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "feeBips", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "feeRecipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "sweepTokenWithFee", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amountMinimum", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "feeBips", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "feeRecipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "uniswapV3SwapCallback", + "inputs": [ + { + "name": "amount0Delta", + "type": "int256", + "internalType": "int256" + }, + { + "name": "amount1Delta", + "type": "int256", + "internalType": "int256" + }, + { + "name": "_data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "unwrapWETH9", + "inputs": [ + { + "name": "amountMinimum", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "unwrapWETH9", + "inputs": [ + { + "name": "amountMinimum", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "unwrapWETH9WithFee", + "inputs": [ + { + "name": "amountMinimum", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "feeBips", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "feeRecipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "unwrapWETH9WithFee", + "inputs": [ + { + "name": "amountMinimum", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "feeBips", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "feeRecipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "wrapETH", + "inputs": [ + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "payable" + } +] diff --git a/tests/generate/data/schemas001.json b/tests/generate/data/schemas001.json new file mode 100644 index 00000000..1739e0a8 --- /dev/null +++ b/tests/generate/data/schemas001.json @@ -0,0 +1,120 @@ +[ + { + "primaryType": "Order", + "types": { + "Asset": [ + { + "name": "assetType", + "type": "AssetType" + }, + { + "name": "value", + "type": "uint256" + } + ], + "AssetType": [ + { + "name": "assetClass", + "type": "bytes4" + }, + { + "name": "data", + "type": "bytes" + } + ], + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Order": [ + { + "name": "maker", + "type": "address" + }, + { + "name": "makeAsset", + "type": "Asset" + }, + { + "name": "taker", + "type": "address" + }, + { + "name": "takeAsset", + "type": "Asset" + }, + { + "name": "salt", + "type": "uint256" + }, + { + "name": "start", + "type": "uint256" + }, + { + "name": "end", + "type": "uint256" + }, + { + "name": "dataType", + "type": "bytes4" + }, + { + "name": "data", + "type": "bytes" + } + ] + } + }, + { + "primaryType": "MetaTransaction", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "verifyingContract", + "type": "address" + }, + { + "name": "salt", + "type": "bytes32" + } + ], + "MetaTransaction": [ + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "from", + "type": "address" + }, + { + "name": "functionSignature", + "type": "bytes" + } + ] + } + } +] diff --git a/tests/generate/test_generate.py b/tests/generate/test_generate.py new file mode 100644 index 00000000..0b614090 --- /dev/null +++ b/tests/generate/test_generate.py @@ -0,0 +1,58 @@ +from glob import glob +from pathlib import Path + +import pytest +from rich import print + +from erc7730.generate.generate import generate_descriptor +from erc7730.model.input.descriptor import InputERC7730Descriptor +from erc7730.model.input.lenses import get_deployments +from erc7730.model.types import Address + +DATA = Path(__file__).resolve().parent / "data" + + +@pytest.mark.parametrize( + "label,chain_id,contract_address", + [ + ("Tether USD", 42161, Address("0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9")), + ("POAP Bridge", 1, Address("0x0bb4d3e88243f4a057db77341e6916b0e449b158")), + ("QuickSwap", 137, Address("0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff")), + ("1inch Aggregation router v5", 10, Address("0x1111111254EEB25477B68fb85Ed929f73A960582")), + ("Paraswap v5 AugustusSwapper", 137, Address("0xDEF171Fe48CF0115B1d80b88dc8eAB59176FEe57")), + ("Uniswap v3 Router 2", 8453, Address("0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD")), + ], +) +def test_generate_from_contract_address(label: str, chain_id: int, contract_address: Address) -> None: + """Generate descriptor using a provided ABI file.""" + _assert_descriptor_valid(generate_descriptor(chain_id=chain_id, contract_address=contract_address, owner=label)) + + +@pytest.mark.parametrize("test_file", sorted(glob(str(DATA / "abis*.json"))), ids=lambda f: Path(f).stem) +def test_generate_from_abis(test_file: str) -> None: + """Generate descriptor using a provided ABI file.""" + _assert_descriptor_valid( + generate_descriptor( + chain_id=1, contract_address=Address("0x0000000000000000000000000000000000000000"), abi_file=Path(test_file) + ) + ) + + +@pytest.mark.parametrize("test_file", sorted(glob(str(DATA / "schemas*.json"))), ids=lambda f: Path(f).stem) +def test_generate_from_eip712_schemas(test_file: str) -> None: + """Generate descriptor using a provided EIP-712 file.""" + _assert_descriptor_valid( + generate_descriptor( + chain_id=1, + contract_address=Address("0x0000000000000000000000000000000000000000"), + eip712_schema_file=Path(test_file), + ) + ) + + +def _assert_descriptor_valid(descriptor: InputERC7730Descriptor) -> None: + print(descriptor.to_json_string()) + assert len(get_deployments(descriptor)) == 1 + assert len(descriptor.display.formats) > 0 + for format in descriptor.display.formats.values(): + assert len(format.fields) > 0 diff --git a/tests/model/paths/test_path_schemas.py b/tests/model/paths/test_path_schemas.py index c214a06f..c875434a 100644 --- a/tests/model/paths/test_path_schemas.py +++ b/tests/model/paths/test_path_schemas.py @@ -1,7 +1,7 @@ from eip712.model.schema import EIP712SchemaField from erc7730.model.abi import Component, Function, InputOutput -from erc7730.model.context import EIP712JsonSchema +from erc7730.model.context import EIP712Schema from erc7730.model.paths.path_parser import to_path from erc7730.model.paths.path_schemas import compute_abi_schema_paths, compute_eip712_schema_paths @@ -69,7 +69,7 @@ def test_compute_abi_paths_with_multiple_nested_params() -> None: def test_compute_eip712_paths_with_slicable_params() -> None: - schema = EIP712JsonSchema( + schema = EIP712Schema( primaryType="Foo", types={"Foo": [EIP712SchemaField(name="bar", type="bytes")]}, ) @@ -81,7 +81,7 @@ def test_compute_eip712_paths_with_slicable_params() -> None: def test_compute_eip712_paths_with_multiple_nested_params() -> None: - schema = EIP712JsonSchema( + schema = EIP712Schema( primaryType="Foo", types={ "Foo": [ diff --git a/tests/test_main.py b/tests/test_main.py index 98c5b865..748f1620 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -53,6 +53,18 @@ def test_resolve_registry_files(input_file: Path) -> None: assert json.loads(out) is not None +@pytest.mark.parametrize( + "label,chain_id,contract_address", + [ + ("Uniswap v3 Router 2", 8453, "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"), + ], +) +def test_generate_from_contract_address(label: str, chain_id: int, contract_address: str) -> None: + result = runner.invoke(app, ["generate", f"--chain-id={chain_id}", f"--address={contract_address}"]) + out = "".join(result.stdout.splitlines()) + assert json.loads(out) is not None + + @pytest.mark.parametrize("input_file", LEGACY_EIP712_DESCRIPTORS, ids=path_id) def test_convert_legacy_registry_eip712_files(input_file: Path, tmp_path: Path) -> None: result = runner.invoke(app, ["convert", "eip712-to-erc7730", str(input_file), str(tmp_path / input_file.name)])