From e5480399a0d3833f73468527da84fd572a660ff7 Mon Sep 17 00:00:00 2001 From: Frederic Samier Date: Thu, 12 Feb 2026 18:06:26 +0100 Subject: [PATCH 01/15] doc: add schema specifications --- specs/erc7730-v1.schema.json | 1014 ++++++++++++++++++++++++++ specs/erc7730-v2.schema.json | 1317 ++++++++++++++++++++++++++++++++++ 2 files changed, 2331 insertions(+) create mode 100644 specs/erc7730-v1.schema.json create mode 100644 specs/erc7730-v2.schema.json diff --git a/specs/erc7730-v1.schema.json b/specs/erc7730-v1.schema.json new file mode 100644 index 0000000..d476838 --- /dev/null +++ b/specs/erc7730-v1.schema.json @@ -0,0 +1,1014 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "description": "ERC7730 Clear Signing Specification Schema. Specification located at https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs", + + "properties": { + + "$schema": { + "title": "Schema", + "type": "string", + "format": "uri-reference", + "description": "The schema that the document should conform to. This should be the URL of a version of the clear signing JSON schemas available under https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs" + }, + + "includes": { + "title": "External includes", + "type": "string", + "format": "uri-reference", + "description": "An URL of another ERC 7730 file that should be merged into this one. Includes are merged into this file before analysis. This can be used to manage interfaces definitions without redundancy." + }, + + "context": { + "$ref": "#/$context/main" + }, + + "metadata": { + "$ref": "#/$metadata/main" + }, + + "display": { + "$ref": "#/$display/main" + } + }, + "additionalProperties": false, + + "$context" : { + "main" : { + "title": "Binding Context Section", + "type": "object", + "description": "The binding context is a set of constraints that are used to bind the ERC7730 file to a specific structured data being displayed. Currently, supported contexts include contract-specific constraints or EIP712 message specific constraints.", + + "properties": { + "$id" : { + "$ref": "#/$definitions/id" + } + }, + + "oneOf": [ + { + "$ref": "#/$context/contract" + }, + { + "$ref": "#/$context/EIP712" + } + ], + "unresolvedProperties": false + }, + + "contract": { + "type": "object", + + "properties": { + "contract": { + "title": "Contract Binding Context", + "type": "object", + "description": "The contract binding context is a set constraints that are used to bind the ERC7730 file to a specific smart contract.", + + "properties": { + "abi": { + "oneOf": [ + { + "$ref": "#/$definitions/abi-json-schema" + }, + { + "title": "An ABI url", + "description": "URL of an ABI bound to this file.", + "type": "string", + "format": "uri-reference" + } + ] + }, + "deployments": { + "$ref": "#/$context/deployments" + }, + "addressMatcher": { + "title": "Address Matcher constraint", + "type": "string", + "format": "uri", + "description": "An URL of a contract address matcher that should be used to match the contract address." + }, + "factory": { + "title": "Factory constraint", + "type": "object", + "description": "A factory constraint is used to check whether the target contract is deployed by a specified factory.", + "properties": { + "deployments": { + "$ref": "#/$context/deployments" + }, + "deployEvent": { + "title": "Deploy Event signature", + "type": "string", + "description": "The event signature that is emitted by the factory when deploying a new contract." + } + }, + "required": [ + "deployments", + "deployEvent" + ], + "additionalProperties": false + } + + }, + "additionalProperties": false + } + }, + "required": [ + "contract" + ] + }, + + "EIP712": { + "type": "object", + "properties": { + "eip712" : { + "title": "EIP 712 Binding", + "type": "object", + "description": "The EIP-712 binding context is a set of constraints that must be verified by the message being signed.", + + "properties" : { + "schemas" : { + "oneOf": [ + { + "title": "An EIP712 Schemas url", + "description": "URL of an array of EIP712 schemas that can be used to validate the message. The message types should match exactly one of those schema.", + "type": "string", + "format": "uri-reference" + }, + { + "title": "EIP 712 Schemas constraint", + "type": "array", + "description": "An array of EIP712 schemas that can be used to validate the message. The message types should match exactly one of those schema.", + "items": { + "oneOf": [ + { + "$ref": "#/$definitions/eip712-json-schema" + }, + { + "title": "An EIP712 Schema url", + "description": "URL of an EIP712 Schema bound to this file.", + "type": "string", + "format": "uri-reference" + } + ] + } + } + ] + }, + "domain": { + "title": "EIP 712 Domain Binding constraint", + "type": "object", + "description": "Each value of the domain constraint MUST match the corresponding eip 712 message domain value.", + + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "chainId": { + "type": "integer", + "format": "eip155" + }, + "verifyingContract": { + "type": "string", + "format": "eip55" + } + } + }, + "domainSeparator": { + "title": "Domain Separator constraint", + "type": "string", + "description": "The domain separator value that must be matched by the message. In hex string representation." + }, + "deployments": { + "description": "An array of deployments describing what the chainId and verifyingContract in the domain should match.", + "$ref": "#/$context/deployments" + } + }, + "additionalProperties": false + } + }, + "required": [ + "eip712" + ] + }, + + "deployments": { + "title": "Deployments constraint", + "type": "array", + "description": "An array of deployments describing where the contract is deployed. The target contract (Tx to or factory) MUST match one of those deployments.", + "items": { + "properties": { + "chainId": { + "type": "integer", + "format": "eip155" + }, + "address": { + "type": "string", + "format": "eip55" + } + } + } + } + }, + + "$metadata": { + "main": { + "title": "Metadata Section", + "type": "object", + "description": "The metadata section contains information about constant values relevant in the scope of the current contract / message (as matched by the `context` section)", + + "properties": { + + "owner": { + "title": "Owner display name", + "type": "string", + "description": "The display name of the owner or target of the contract / message to be clear signed." + }, + + "info": { + "$ref": "#/$metadata/info" + }, + + "token": { + "$ref": "#/$metadata/token" + }, + + "constants": { + "$ref": "#/$metadata/constants" + }, + + "enums": { + "$ref": "#/$metadata/enums" + } + } + + }, + + "info" : { + "title": "Main contract's owner detailed information", + "type": "object", + "description": "The owner info section contains detailed information about the owner or target of the contract / message to be clear signed.", + + "properties": { + "legalName": { + "title": "Owner Legal Name", + "type": "string", + "description": "The full legal name of the owner if different from the owner field." + }, + "deploymentDate": { + "title": "Deployment date of the contract / message", + "type": "string", + "format": "date-time", + "description": "The date of deployment of the contract / message." + }, + "url": { + "title": "Owner URL", + "type": "string", + "format": "uri", + "description": "URL with more info on the entity the user interacts with." + } + }, + "required": [ + "legalName", + "url" + ], + "additionalProperties": false + }, + + "token" : { + "title": "Token Description", + "type": "object", + "description": "A description of an ERC20 token exported by this format, that should be trusted. Not mandatory if the corresponding metadata can be fetched from the contract itself.", + + "properties": { + "name": { + "title": "Token Name", + "type": "string" + }, + "ticker": { + "title": "Token Ticker", + "type": "string", + "description": "A short capitalized ticker for the token, that will be displayed in front of corresponding amounts." + }, + "decimals": { + "title": "Token Decimals", + "type": "integer", + "description": "The number of decimals of the token ticker, used to display amounts." + } + }, + "required": [ + "name", + "ticker", + "decimals" + ], + "additionalProperties": false + + }, + + "constants": { + "title": "Constant values", + "type": "object", + "description": "A set of values that can be used in format parameters. Can be referenced with a path expression like $.metadata.constants.CONSTANT_NAME", + "additionalProperties": { + "type": ["string", "integer", "number", "boolean", "null"] + } + }, + + "enums" : { + "title": "Enums", + "type": "object", + "description": "A set of enums that are used to format fields replacing values with human readable strings.", + + "additionalProperties": { + "oneOf": [ + { + "title": "A dynamic enum", + "type": "string", + "description": "A dynamic enum contains an URL which returns a json file with simple key-values mapping values display name. It is assumed those values can change between two calls to clear sign." + }, + { + "title": "Enumeration", + "type": "object", + "description": "A set of values that will be used to replace a field value with a human readable string. Enumeration keys are the field values and enumeration values are the displayable strings", + + "additionalProperties": { + "type": "string" + } + } + ] + } + } + }, + + "$display": { + "main": { + "title": "Display Formatting Info Section", + "type": "object", + "description": "The display section contains all the information needed to format the data in a human readable way. It contains the constants and formatters used to display the data contained in the bound structure.", + + "properties": { + + "definitions": { + "type": "object", + "title": "Common Formatter Definitions", + "description": "A set of definitions that can be used to share formatting information between multiple messages / functions. The definitions can be referenced by the key name in an internal path.", + "additionalProperties": { + "$ref": "#/$format/field" + } + }, + + "formats": { + "title": "List of field formats", + "description": "The list includes formatting info for each field of a structure. This list is indexed by a key identifying uniquely the message's type in the abi. For smartcontracts, it is the selector of the function or its signature; and for EIP712 messages it is the primaryType of the message.", + "type": "object", + + "additionalProperties": { + "title": "A structured data format specification", + "description": "A structured data format specification contains formatting information of fields in a single type of message.", + "type": "object", + + "properties": { + "$id": { + "$ref": "#/$definitions/id" + }, + "intent": { + "$ref": "#/$display/intent" + }, + "fields": { + "$ref": "#/$display/fields" + }, + "required": { + "$ref": "#/$display/required" + }, + "excluded": { + "$ref": "#/$display/excluded" + }, + "screens": { + "title": "Screens grouping information", + "description": "Screens section is used to group multiple fields to display into screens. Each key is a wallet type name. The format of the screens is wallet type dependent, as well as what can be done (reordering fields, max number of screens, etc...). See each wallet manufacturer documentation for more information.", + "type": "object", + + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/$display/screens" + } + } + } + }, + "additionalProperties": false + } + } + }, + "required": [ + "formats" + ], + "additionalProperties": false + }, + "intent": { + "oneOf": [ + { + "title": "Simple intent message", + "description": "A description of the intent of the structured data signing, that will be displayed to the user.", + "type": "string" + }, + { + "title": "Complex intent message", + "description": "A description of the intent of the structured data signing, that will be displayed to the user.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + + ] + }, + "required": { + "title": "Required fields", + "description": "A list of fields that are required to be displayed to the user. A field that has a formatter and is not in this list is optional. A field that does not have a formatter should be silent, ie not shown", + "type": "array", + "items": { + "type": "string" + } + }, + "excluded": { + "title": "Excluded fields", + "description": "A list of fields that are intentionally not shown to the user. A field that has no formatter and is not declared in this list may be considered as an error by the wallet when interpreting the descriptor.", + "type": "array", + "items": { + "type": "string" + } + }, + "screens": { + "title": "Screen information", + "description": "ADD DEVICE SPECIFIC SCHEMAS LATER. A screen is a group of fields that will be displayed together in a wallet. The format of the screen is wallet type dependent, as well as what can be done (reordering fields, max number of screens, etc..). See each wallet manufacturer documentatio for more information.", + "type": "object" + }, + "fields": { + "title": "Field Formats set", + "type": "array", + "description": "An array containing the ordered definitions of fields formats. See the specification for more details.", + + "items": { + "oneOf": [ + { + "$ref": "#/$format/field" + }, + { + "$ref": "#/$display/nestedFields" + }, + { + "$ref": "#/$display/reference" + } + ] + }, + "unresolvedProperties": false + }, + "nestedFields": { + "title": "A single set of field formats, allowing recursivity in the schema", + "description": "A set of field formats used to group whole definitions for structures for instance. This allows nesting definitions of formats, but note that support for deep nesting will be device dependent.", + "type": "object", + + "properties": { + "path": { + "$ref": "#/$format/path" + }, + "fields": { + "$ref": "#/$display/fields" + } + }, + "required": [ + "fields" + ], + "additionalProperties": false + }, + "reference": { + "title": "Reference", + "description": "A reference to a shared definition that should be used as the field formatting definition. The value is the key in the display definitions section, as a path expression $.display.definitions.DEFINITION_NAME. It is used to share definitions between multiple messages / functions.", + "properties": { + "path": { + "$ref": "#/$format/path" + }, + "value": { + "$ref": "#/$format/value" + }, + "$ref": { + "title": "Internal Definition", + "description": "An internal definition that should be used as the field formatting definition. The value is the key in the display definitions section, as a path expression $.display.definitions.DEFINITION_NAME.", + "type": "string" + }, + "params": { + "title": "Parameters", + "description": "Parameters override. These values takes precedence over the ones in the definition itself", + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "required": [ "$ref" ], + "allOf": [ + { + "not": { "required": [ "path", "value" ] } + } + ], + "additionalProperties": false + } + }, + + "$format": { + "path": { + "title": "Path", + "type": "string", + "description": "A path to the field in the structured data. The path is a JSON path expression that can be used to extract the field value from the structured data." + }, + "value": { + "title": "Value", + "type": ["string", "integer", "number", "boolean"], + "description": "A literal value on which the format should be applied instead of looking up a field in the structured data." + }, + "field": { + "title": "Field formatter", + "description": "A field formatter contains formatting information of a single field in a message.", + "type": "object", + + "properties": { + "$id": { + "$ref": "#/$definitions/id" + }, + "path": { + "$ref": "#/$format/path" + }, + "value": { + "$ref": "#/$format/value" + }, + "label": { + "title": "Field Label", + "description": "The label of the field, that will be displayed to the user in front of the formatted field value.", + "type": "string" + }, + "format": { + "title": "Field Format", + "description": "The format of the field, that will be used to format the field value in a human readable way.", + "type": "string", + "$ref": "#/$format/names" + } + }, + "required": [ "label", "format" ], + "allOf" : [ + { + "not": { "required": [ "path", "value" ] } + }, + { + "if": { "properties": { "format": { "const": "addressName" } } }, + "then": { + "properties": { + "params": { "$ref": "#/$format/addressNameParameters" } + } + } + }, + { + "if": { "properties": { "format": { "const": "calldata" } } }, + "then": { + "properties": { + "params": { "$ref": "#/$format/calldataParameters" } + } + } + }, + { + "if": { "properties": { "format": { "const": "tokenAmount" } } }, + "then": { + "properties": { + "params": { "$ref": "#/$format/tokenAmountParameters" } + } + } + }, + { + "if": { "properties": { "format": { "const": "nftName" } } }, + "then": { + "properties": { + "params": { "$ref": "#/$format/nftNameParameters" } + } + } + }, + { + "if": { "properties": { "format": { "const": "date" } } }, + "then": { + "properties": { + "params": { "$ref": "#/$format/dateParameters" } + } + } + }, + { + "if": { "properties": { "format": { "const": "percentage" } } }, + "then": { + "properties": { + "params": { "$ref": "#/$format/unitParameters" } + } + } + }, + { + "if": { "properties": { "format": { "const": "enum" } } }, + "then": { + "properties": { + "params": { "$ref": "#/$format/enumParameters" } + } + } + } + ], + "unresolvedProperties": false + }, + "names": { + "anyOf": [ + { + "title": "Raw format", + "const": "raw", + "description": "The field should be displayed as the natural representation of the underlying structured data type." + }, + { + "title": "address format", + "const": "addressName", + "description": "The field should be displayed as a trusted name, or as a raw address if no names are found in trusted sources. List of trusted sources can be optionally specified in parameters." + }, + { + "title": "bytes format", + "const": "calldata", + "description": "The field is itself a calldata embedded in main call. Another ERC 7730 should be used to parse this field. If not available or not supported, the wallet MAY display a hash of the embedded calldata instead." + + }, + { + "title": "integer format", + "const": "amount", + "description": "The field should be displayed as an amount in underlying currency, converted using the best magnitude / ticker available." + }, + { + "title": "integer format", + "const": "tokenAmount", + "description": "The field should be displayed as an amount, preceded by the ticker. The magnitude and ticker should be derived from the token or tokenPath parameter corresponding metadata." + }, + { + "title": "integer format", + "const": "nftName", + "description": "The field should be displayed as a single NFT names, or as a raw token Id if a specific name is not found. Collection is specified by the collection or collectionPath parameter." + }, + { + "title": "integer format", + "const": "date", + "description": "The field should be displayed as a date. Suggested RFC3339 representation. Parameter specifies the encoding of the date." + }, + { + "title": "integer format", + "const": "duration", + "description": "The field should be displayed as a duration in HH:MM:ss form. Value is interpreted as a number of seconds." + }, + { + "title": "integer format", + "const": "unit", + "description": "The field should be displayed as a percentage. Magnitude of the percentage encoding is specified as a parameter. Example: a value of 3000 with magnitude 4 is displayed as 0.3%." + }, + { + "title": "integer format", + "const": "enum", + "description": "The field should be displayed as a human readable string by converting the value using the enum referenced in parameters." + } + ] + }, + "addressNameParameters" : { + "title": "Address Names Formatting Parameters", + "type": "object", + "properties": { + "types": { + "title": "Address Type", + "type": "array", + "description": "The types of address to display. Restrict allowable sources of names and MAY lead to additional checks from wallets.", + "items": { + "type": "string", + "enum": [ "wallet", "eoa", "contract", "token", "collection" ] + } + }, + "sources": { + "title": "Trusted Sources", + "description": "Trusted Sources for names, in order of preferences. Sources values are wallet manufacturer specific, example values are \"local\" or \"ens\". See specification for more details on sources values.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderAddress": { + "title": "Sender Address", + "oneOf": [ + { + "type": "string", + "description": "An address equal to this value is interpreted as the sender referenced by `@.from`." + }, + { + "type": "array", + "description": "An array of addresses, any of which are interpreted as the sender referenced by `@.from`.", + "items": { + "type": "string" + } + } + ] + } + }, + "additionalProperties": false + }, + "calldataParameters" : { + "title": "Embedded Calldata Formatting Parameters", + "type": "object", + "properties": { + "selector": { + "title": "Called Selector (Optional)", + "type": "string", + "description": "The selector being called, if not contained in the calldata. Hex string representation." + }, + "callee": { + "title": "Callee Address", + "type": "string", + "description": "The address of the contract being called by this embedded calldata." + }, + "calleePath": { + "title": "Callee Path", + "type": "string", + "description": "The path to the address of the contract being called by this embedded calldata." + } + }, + "anyOf": [ + {"required": ["callee"]}, + {"required": ["calleePath"]} + ], + "not": { + "required": ["callee", "calleePath"] + }, + "additionalProperties": false + }, + "tokenAmountParameters": { + "title": "Token Amount Formatting Parameters", + "type": "object", + "properties": { + "token": { + "title": "Token", + "type": "string", + "description": "The token address, or a path to a constant in the ERC 7730 file." + }, + "tokenPath": { + "title": "Token Path", + "type": "string", + "description": "The path to the token address in the structured data." + }, + "nativeCurrencyAddress": { + "title": "Native Currency Address", + "oneOf": [ + { + "type": "string", + "description": "An address equal to this value is interpreted as an amount in native currency rather than a token." + }, + { + "type": "array", + "description": "An array of addresses, any of which are interpreted as an amount in native currency rather than a token.", + "items": { + "type": "string" + } + } + ] + }, + "threshold": { + "title": "Unlimited Threshold", + "type": "string", + "description": "The threshold above which the amount should be displayed using the message parameter rather than the real amount." + }, + "message": { + "title": "Unlimited Message", + "type": "string", + "description": "The message to display when the amount is above the threshold." + } + }, + "not": { + "required": ["token", "tokenPath"] + }, + "additionalProperties": false + }, + "nftNameParameters" : { + "title": "NFT Names Formatting Parameters", + "type": "object", + "properties": { + "collection": { + "title": "Collection Address", + "type": "string", + "description": "The collection address, or a path to a constant in the ERC 7730 file." + }, + "collectionPath": { + "title": "Collection Path", + "type": "string", + "description": "The path to the collection in the structured data." + } + }, + "anyOf": [ + {"required": ["collection"]}, + {"required": ["collectionPath"]} + ], + "not": { + "required": ["collection", "collectionPath"] + }, + "additionalProperties": false + }, + "dateParameters": { + "title": "Date Formatting Parameters", + "type": "object", + "properties": { + "encoding": { + "title": "Date Encoding", + "type": "string", + "description": "The encoding of the date.", + "enum": [ + "blockheight", + "timestamp" + ] + } + }, + "required": [ + "encoding" + ], + "additionalProperties": false + }, + + "unitParameters": { + "title": "Unit Formatting Parameters", + "type": "object", + "properties": { + "base": { + "title": "Unit base symbol", + "type": "integer", + "description": "The base symbol of the unit, displayed after the converted value. It can be an SI unit symbol or acceptable dimensionless symbols like % or bps." + }, + "decimals": { + "title": "Decimals", + "type": "integer", + "description": "The number of decimals of the value, used to convert to a float." + }, + "prefix": { + "title": "Prefix", + "type": "boolean", + "description": "Whether the value should be converted to a prefixed unit, like k, M, G, etc." + } + }, + "required": [ + "base" + ], + "additionalProperties": false + }, + + "enumParameters": { + "title": "Enum Formatting Parameters", + "type": "object", + "properties": { + "$ref": { + "title": "Enum reference", + "type": "string", + "description": "The internal path to the enum definition used to convert this value." + } + }, + "required": [ + "$ref" + ], + "additionalProperties": false + } + }, + + "$definitions": { + "id": { + "title": "ID", + "type": "string", + "description": "An internal identifier that can be used either for clarity specifying what the element is or as a reference in device specific sections." + }, + + "eip712-json-schema": { + "title": "An EIP712 Schema", + "type": "object", + "description": "EIP712 typed data schema, restricted to type definitions and primary type only. See https://eips.ethereum.org/EIPS/eip-712#data-structures for more information.", + "properties": { + "types": { + "type": "object", + "description": "Type definitions for the EIP712 typed data. See https://eips.ethereum.org/EIPS/eip-712#data-structures for more information.", + "properties": { + "EIP712Domain": { + "type": "array", + "description": "EIP712 domain type definition. The domain is used as a separator between EIP712 messages to avoid reuse of signatures. Actual separator values are contained in a \"domain\" key of the message. Fields are up to the implementer, but must often include at least name, version, chainId and verifyingContract." + } + }, + "additionalProperties": { + "type": "array", + "description": "Type definition for a specific type. Each type is an array of fields, where each field is an object with a name and a type. The type is a string, and may be a reference to another type.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "name", + "type" + ] + } + }, + "required": [ + "EIP712Domain" + ] + }, + "primaryType": { + "type": "string", + "description": "The primary type of the EIP712 typed data. This is the type that will be used as the top level type for the message field. See https://eips.ethereum.org/EIPS/eip-712#data-structures for more information." + } + }, + "required": [ + "types", + "primaryType" + ] + }, + + "abi-json-schema": { + "title": "An EVM ABI", + "type": "array", + "description": "JSON schema for the json representation of a solidity ABI", + "items": { + "type": "object", + "properties": { + "inputs": { + "type": "array", + "description": "an array of object with input parameters", + "items": { + "$ref": "#/$definitions/abi-parameter" + } + }, + "name": { + "type": "string", + "description": "the name of the function" + }, + "outputs": { + "type": "array", + "description": "an array of object with output parameters", + "items": { + "$ref": "#/$definitions/abi-parameter" + } + }, + "stateMutability": { + "type": "string", + "enum": [ + "pure", + "view", + "nonpayable", + "payable" + ] + }, + "type": { + "type": "string", + "description": "the type of object being described", + "enum": [ + "function", + "constructor", + "receive", + "fallback" + ] + } + }, + "required": [ + "inputs", + "type" + ] + } + }, + + "abi-parameter": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the parameter, used in the selector computation" + }, + "type": { + "type": "string", + "description": "the canonical type of the parameter" + }, + "internalType": { + "type": "string", + "description": "fully qualified type name in solidity source code" + }, + "components": { + "type": "array", + "items": { + "$ref": "#/$definitions/abi-parameter" + } + } + }, + "required": [ + "name", + "type" + ] + } + } +} diff --git a/specs/erc7730-v2.schema.json b/specs/erc7730-v2.schema.json new file mode 100644 index 0000000..9536035 --- /dev/null +++ b/specs/erc7730-v2.schema.json @@ -0,0 +1,1317 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "description": "ERC7730 Clear Signing Specification Schema. Specification located at https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs", + "properties": { + "$schema": { + "title": "Schema", + "type": "string", + "format": "uri-reference", + "description": "The schema that the document should conform to. This should be the URL of a version of the clear signing JSON schemas available under https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs" + }, + "$comment": { + "title": "Schema", + "type": "string", + "description": "An optional comment string that can be used to document the purpose of the file." + }, + "includes": { + "title": "External includes", + "type": "string", + "format": "uri-reference", + "description": "An URL of another ERC 7730 file that should be merged into this one. Includes are merged into this file before analysis. This can be used to manage interfaces definitions without redundancy." + }, + "context": { + "$ref": "#/$context/main" + }, + "metadata": { + "$ref": "#/$metadata/main" + }, + "display": { + "$ref": "#/$display/main" + } + }, + "additionalProperties": false, + "$context": { + "main": { + "title": "Binding Context Section", + "type": "object", + "description": "The binding context is a set of constraints that are used to bind the ERC7730 file to a specific structured data being displayed. Currently, supported contexts include contract-specific constraints or EIP712 message specific constraints.", + "properties": { + "$id": { + "$ref": "#/$definitions/id" + } + }, + "oneOf": [ + { + "$ref": "#/$context/contract" + }, + { + "$ref": "#/$context/EIP712" + } + ], + "unevaluatedProperties": false + }, + "contract": { + "type": "object", + "properties": { + "contract": { + "title": "Contract Binding Context", + "type": "object", + "description": "The contract binding context is a set constraints that are used to bind the ERC7730 file to a specific smart contract.", + "properties": { + "abi": { + "description": "[Deprecated] ABI definition bound to this file. Continue providing it for backward compatibility only; new specs should rely on display formats." + }, + "deployments": { + "$ref": "#/$context/deployments" + }, + "factory": { + "title": "Factory constraint", + "type": "object", + "description": "A factory constraint is used to check whether the target contract is deployed by a specified factory.", + "properties": { + "deployments": { + "$ref": "#/$context/deployments" + }, + "deployEvent": { + "title": "Deploy Event signature", + "type": "string", + "description": "The event signature that is emitted by the factory when deploying a new contract." + } + }, + "required": [ + "deployments", + "deployEvent" + ], + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "required": [ + "contract" + ] + }, + "EIP712": { + "type": "object", + "properties": { + "eip712": { + "title": "EIP 712 Binding", + "type": "object", + "description": "The EIP-712 binding context is a set of constraints that must be verified by the message being signed.", + "properties": { + "schemas": { + "description": "[Deprecated] Schema definition bound to this file. Continue providing it for backward compatibility only; new specs should rely on display formats." + }, + "domain": { + "title": "EIP 712 Domain Binding constraint", + "type": "object", + "description": "Each value of the domain constraint MUST match the corresponding eip 712 message domain value.", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "chainId": { + "type": "integer", + "format": "eip155" + }, + "verifyingContract": { + "type": "string", + "format": "eip55" + } + } + }, + "domainSeparator": { + "title": "Domain Separator constraint", + "type": "string", + "description": "The domain separator value that must be matched by the message. In hex string representation." + }, + "deployments": { + "description": "An array of deployments describing what the chainId and verifyingContract in the domain should match.", + "$ref": "#/$context/deployments" + } + }, + "additionalProperties": false + } + }, + "required": [ + "eip712" + ] + }, + "deployments": { + "title": "Deployments constraint", + "type": "array", + "description": "An array of deployments describing where the contract is deployed. The target contract (Tx to or factory) MUST match one of those deployments.", + "items": { + "properties": { + "chainId": { + "type": "integer", + "format": "eip155" + }, + "address": { + "type": "string", + "format": "eip55" + } + } + } + } + }, + "$metadata": { + "main": { + "title": "Metadata Section", + "type": "object", + "description": "The metadata section contains information about constant values relevant in the scope of the current contract / message (as matched by the `context` section)", + "properties": { + "owner": { + "title": "Owner display name", + "type": "string", + "description": "The display name of the owner or target of the contract / message to be clear signed." + }, + "contractName": { + "title": "Contract Name", + "type": "string", + "description": "The name of the contract targeted by the transaction or message." + }, + "info": { + "$ref": "#/$metadata/info" + }, + "token": { + "$ref": "#/$metadata/token" + }, + "constants": { + "$ref": "#/$metadata/constants" + }, + "enums": { + "$ref": "#/$metadata/enums" + } + } + }, + "info": { + "title": "Main contract's owner detailed information", + "type": "object", + "description": "The owner info section contains detailed information about the owner or target of the contract / message to be clear signed.", + "properties": { + "deploymentDate": { + "title": "Deployment date of the contract / message", + "type": "string", + "format": "date-time", + "description": "The date of deployment of the contract / message." + }, + "url": { + "title": "Owner URL", + "type": "string", + "format": "uri", + "description": "URL with more info on the entity the user interacts with." + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "token": { + "title": "Token Description", + "type": "object", + "description": "A description of an ERC20 token exported by this format, that should be trusted. Not mandatory if the corresponding metadata can be fetched from the contract itself.", + "properties": { + "name": { + "title": "Token Name", + "type": "string" + }, + "ticker": { + "title": "Token Ticker", + "type": "string", + "description": "A short capitalized ticker for the token, that will be displayed in front of corresponding amounts." + }, + "decimals": { + "title": "Token Decimals", + "type": "integer", + "description": "The number of decimals of the token ticker, used to display amounts." + } + }, + "required": [ + "name", + "ticker", + "decimals" + ], + "additionalProperties": false + }, + "constants": { + "title": "Constant values", + "type": "object", + "description": "A set of values that can be used in format parameters. Can be referenced with a path expression like $.metadata.constants.CONSTANT_NAME", + "additionalProperties": { + "type": [ + "string", + "integer", + "number", + "boolean", + "null" + ] + } + }, + "enums": { + "title": "Enums", + "type": "object", + "description": "A set of enums that are used to format fields replacing values with human readable strings.", + "additionalProperties": { + "title": "Enumeration", + "type": "object", + "description": "A set of values that will be used to replace a field value with a human readable string. Enumeration keys are the field values and enumeration values are the displayable strings", + "additionalProperties": { + "type": "string" + } + } + }, + "maps": { + "title": "Maps", + "type": "object", + "description": "A set of maps that are used to manage context dependent constants. Maps can be used in place of constants, and the corresponding constant is based on the map key resolved value. Each key is a map name that can be used as a reference.", + "additionalProperties": { + "type": "object", + "properties": { + "$keyType": { + "title": "Key Type", + "type": "string", + "description": "An informational representation of the expected key type." + }, + "values": { + "title": "Map Values", + "type": "object", + "description": "A set of values that can be used as constants based on the resolved key. Each key is a possible key value, and each value is the corresponding constant value.", + "additionalProperties": { + "type": [ + "string", + "integer", + "number", + "boolean", + "null" + ] + } + }, + "unresolvedProperties": false + } + } + } + }, + "$display": { + "main": { + "title": "Display Formatting Info Section", + "type": "object", + "description": "The display section contains all the information needed to format the data in a human readable way. It contains the constants and formatters used to display the data contained in the bound structure.", + "properties": { + "definitions": { + "type": "object", + "title": "Common Formatter Definitions", + "description": "A set of definitions that can be used to share formatting information between multiple messages / functions. The definitions can be referenced by the key name in an internal path.", + "additionalProperties": { + "$ref": "#/$format/field" + } + }, + "formats": { + "title": "List of field formats", + "description": "The list includes formatting info for each field of a structure. For contract bindings, entries are keyed by the full function signature with parameter names; for EIP712 bindings, entries are keyed by the string returned by EIP 712 encodeType on the primary type.", + "type": "object", + "propertyNames": { + "anyOf": [ + { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*\\(\\s*(?:(?:(?:tuple(?:\\s*\\((?:[^()]|\\([^()]*\\))*\\))?|[A-Za-z0-9_]+)(?:\\[[0-9]*\\])*(?:\\s+(?:memory|calldata|storage))?\\s+[A-Za-z_][A-Za-z0-9_]*)(?:\\s*,\\s*(?:(?:tuple(?:\\s*\\((?:[^()]|\\([^()]*\\))*\\))?|[A-Za-z0-9_]+)(?:\\[[0-9]*\\])*(?:\\s+(?:memory|calldata|storage))?\\s+[A-Za-z_][A-Za-z0-9_]*))*\\s*)?\\)$" + }, + { + "pattern": "^\\s*[A-Za-z_][A-Za-z0-9_]*\\s*\\(\\s*(?:[A-Za-z_][A-Za-z0-9_]*(?:\\[\\])?\\s+[A-Za-z_][A-Za-z0-9_]*\\s*(?:,\\s*[A-Za-z_][A-Za-z0-9_]*(?:\\[\\])?\\s+[A-Za-z_][A-Za-z0-9_]*\\s*)*)?\\)\\s*(?:\\s+[A-Za-z_][A-Za-z0-9_]*\\s*\\(\\s*(?:[A-Za-z_][A-Za-z0-9_]*(?:\\[\\])?\\s+[A-Za-z_][A-Za-z0-9_]*\\s*(?:,\\s*[A-Za-z_][A-Za-z0-9_]*(?:\\[\\])?\\s+[A-Za-z_][A-Za-z0-9_]*\\s*)*)?\\)\\s*)*$" + } + ] + }, + "additionalProperties": { + "title": "A structured data format specification", + "description": "A structured data format specification contains formatting information of fields in a single type of message.", + "type": "object", + "properties": { + "$id": { + "$ref": "#/$definitions/id" + }, + "intent": { + "$ref": "#/$display/intent" + }, + "interpolatedIntent": { + "$ref": "#/$display/interpolatedIntent" + }, + "fields": { + "$ref": "#/$display/fields" + } + }, + "additionalProperties": false + } + } + }, + "required": [ + "formats" + ], + "additionalProperties": false + }, + "intent": { + "oneOf": [ + { + "title": "Simple intent message", + "description": "A description of the intent of the structured data signing, that will be displayed to the user.", + "type": "string" + }, + { + "title": "Complex intent message", + "description": "A description of the intent of the structured data signing, that will be displayed to the user.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + ] + }, + "interpolatedIntent": { + "title": "Interpolated intent message", + "description": "An optional intent string with embedded field values using {path} interpolation syntax. This provides a dynamic, contextual description by embedding actual transaction/message values directly in the intent string. Wallets should prefer displaying interpolatedIntent when available and fall back to intent if interpolation fails. See the specification for detailed formatting behavior and security considerations.", + "type": "string" + }, + "fields": { + "title": "Field Formats set", + "type": "array", + "description": "An array containing the ordered definitions of fields formats. See the specification for more details.", + "items": { + "oneOf": [ + { + "$ref": "#/$format/field" + }, + { + "$ref": "#/$display/fieldGroup" + }, + { + "$ref": "#/$display/reference" + } + ] + }, + "unevaluatedProperties": false + }, + "fieldGroup": { + "title": "A group of field formats, allowing recursivity in the schema and control over grouping and iteration.", + "description": "A set of field formats used to group whole definitions for structures for instance. This allows nesting definitions of formats, but note that support for deep nesting will be device dependent.", + "type": "object", + "properties": { + "$id": { + "$ref": "#/$definitions/id" + }, + "path": { + "$ref": "#/$format/path" + }, + "label": { + "title": "Group Label", + "description": "The group label of the field group, that will be displayed to the user in front of the formatted field values.", + "type": "string" + }, + "iteration": { + "title": "Group arrays iteration mode", + "description": "Specifies how iteration over arrays in the group should be handled. Sequential mode displays elements grouped by array, ie arr_0[0] ... arr_0[N] arr_1[0] ... arr_1[M]. Bundled mode displays elements of the arrays grouped by index, ie arr_0[0] arr_1[0] arr_0[1] arr_1[1] ... arr_0[N] arr_1[N]. In bundled mode, all arrays MUST be of the same length.", + "type": "string", + "enum": [ + "sequential", + "bundled" + ] + }, + "fields": { + "$ref": "#/$display/fields" + } + }, + "required": [ + "fields" + ], + "additionalProperties": false + }, + "reference": { + "title": "Reference", + "description": "A reference to a shared definition that should be used as the field formatting definition. The value is the key in the display definitions section, as a path expression $.display.definitions.DEFINITION_NAME. It is used to share definitions between multiple messages / functions.", + "properties": { + "path": { + "$ref": "#/$format/path" + }, + "value": { + "$ref": "#/$format/value" + }, + "$ref": { + "title": "Internal Definition", + "description": "An internal definition that should be used as the field formatting definition. The value is the key in the display definitions section, as a path expression $.display.definitions.DEFINITION_NAME.", + "type": "string" + }, + "params": { + "title": "Parameters", + "description": "Parameters override. These values takes precedence over the ones in the definition itself", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "$ref" + ], + "allOf": [ + { + "not": { + "required": [ + "path", + "value" + ] + } + } + ], + "additionalProperties": false + } + }, + "$format": { + "path": { + "title": "Path", + "type": "string", + "description": "A path to the field in the structured data. The path is a JSON path expression that can be used to extract the field value from the structured data." + }, + "value": { + "title": "Value", + "type": [ + "string", + "integer", + "number", + "boolean" + ], + "description": "A literal value on which the format should be applied instead of looking up a field in the structured data." + }, + "field": { + "title": "Field formatter", + "description": "A field formatter contains formatting information of a single field in a message.", + "type": "object", + "properties": { + "$id": { + "$ref": "#/$definitions/id" + }, + "path": { + "$ref": "#/$format/path" + }, + "value": { + "$ref": "#/$format/value" + }, + "visible": { + "$ref": "#/$format/rules" + }, + "label": { + "title": "Field Label", + "description": "The label of the field, that will be displayed to the user in front of the formatted field value.", + "type": "string" + }, + "format": { + "title": "Field Format", + "description": "The format of the field, that will be used to format the field value in a human readable way.", + "type": "string", + "$ref": "#/$format/names" + }, + "separator": { + "title": "Field Separator", + "description": "An optional separator string that will be used to separate multiple values when the field is an array. A separator use the interpolated string format with one specific parameter {index} replaced by the index of the element in the array.", + "type": "string" + }, + "encryption": { + "$ref": "#/$format/encryptionParameters", + "description": "If present, the field value is encrypted. The format specifies how to display the decrypted value." + } + }, + "allOf": [ + { + "not": { + "required": [ + "path", + "value" + ] + } + }, + { + "if": { + "properties": { + "format": { + "const": "addressName" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/addressNameParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "interoperableAddressName" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/interoperableAddressNameParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "calldata" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/calldataParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "tokenAmount" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/tokenAmountParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "tokenTicker" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/tokenTickerParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "nftName" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/nftNameParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "date" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/dateParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "unit" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/unitParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "enum" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/enumParameters" + } + } + } + } + ], + "unevaluatedProperties": false + }, + "rules": { + "title": "Display Rule", + "description": "Specifies when a field should be displayed based on its value or context. Defaults to 'always' if not specified.", + "oneOf": [ + { + "type": "string", + "enum": [ + "always", + "never", + "optional" + ], + "description": "Simple display rule: 'always' means always display, 'never' means always skip display, 'optional' means display only if wallet can." + }, + { + "type": "object", + "properties": { + "ifNotIn": { + "type": "array", + "description": "Display the field only if its value is NOT in this list.", + "items": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "minItems": 1 + }, + "mustBe": { + "type": "array", + "description": "Always skip display, but value MUST match one of these values.", + "items": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "minItems": 1 + } + }, + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "notIn", + "mustBe" + ] + } + } + ], + "description": "Conditional display rules. Either 'notIn' or 'mustBe' must be defined, but not both." + } + ] + }, + "mapReference": { + "title": "Map Reference", + "type": "object", + "properties": { + "map": { + "type": "string", + "description": "The path to the referenced map." + }, + "keyPath": { + "type": "string", + "description": "The path to the key used to resolve a value using the referenced map." + } + } + }, + "names": { + "anyOf": [ + { + "title": "Raw format", + "const": "raw", + "description": "The field should be displayed as the natural representation of the underlying structured data type." + }, + { + "title": "address format", + "const": "addressName", + "description": "The field should be displayed as a trusted name, or as a raw address if no names are found in trusted sources. List of trusted sources can be optionally specified in parameters." + }, + { + "title": "address format", + "const": "tokenTicker", + "description": "The field should be displayed as an ERC 20 token ticker, or as a raw address if no token definition are found." + }, + { + "title": "bytes format", + "const": "calldata", + "description": "The field is itself a calldata embedded in main call. Another ERC 7730 should be used to parse this field. If not available or not supported, the wallet MAY display a hash of the embedded calldata instead." + }, + { + "title": "integer format", + "const": "amount", + "description": "The field should be displayed as an amount in underlying currency, converted using the best magnitude / ticker available." + }, + { + "title": "integer format", + "const": "tokenAmount", + "description": "The field should be displayed as an amount, preceded by the ticker. The magnitude and ticker should be derived from the token or tokenPath parameter corresponding metadata." + }, + { + "title": "integer format", + "const": "nftName", + "description": "The field should be displayed as a single NFT names, or as a raw token Id if a specific name is not found. Collection is specified by the collection or collectionPath parameter." + }, + { + "title": "integer format", + "const": "date", + "description": "The field should be displayed as a date. Suggested RFC3339 representation. Parameter specifies the encoding of the date." + }, + { + "title": "integer format", + "const": "duration", + "description": "The field should be displayed as a duration in HH:MM:ss form. Value is interpreted as a number of seconds." + }, + { + "title": "integer format", + "const": "unit", + "description": "The field should be displayed as a percentage. Magnitude of the percentage encoding is specified as a parameter. Example: a value of 3000 with magnitude 4 is displayed as 0.3%." + }, + { + "title": "integer format", + "const": "enum", + "description": "The field should be displayed as a human readable string by converting the value using the enum referenced in parameters." + }, + { + "title": "integer format", + "const": "chainId", + "description": "The field should be displayed as a Blockchain explicit name, as defined in EIP-155, based on the chain id value." + }, + { + "title": "integer format", + "const": "interoperableAddressName", + "description": "The field should be displayed as a trusted name or as an EIP-7930 Interoperable Address human readable format. List of trusted sources can be optionally specified in parameters." + } + ] + }, + "addressNameParameters": { + "title": "Address Names Formatting Parameters", + "type": "object", + "properties": { + "types": { + "title": "Address Type", + "type": "array", + "description": "The types of address to display. Restrict allowable sources of names and MAY lead to additional checks from wallets.", + "items": { + "type": "string", + "enum": [ + "wallet", + "eoa", + "contract", + "token", + "collection" + ] + } + }, + "sources": { + "title": "Trusted Sources", + "description": "Trusted Sources for names, in order of preferences. Sources values are wallet manufacturer specific, example values are \"local\" or \"ens\". See specification for more details on sources values.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderAddress": { + "title": "Sender Address", + "oneOf": [ + { + "type": "string", + "description": "An address equal to this value is interpreted as the sender referenced by `@.from`." + }, + { + "type": "array", + "description": "An array of addresses, any of which are interpreted as the sender referenced by `@.from`.", + "items": { + "type": "string" + } + } + ] + } + }, + "additionalProperties": false + }, + "calldataParameters": { + "title": "Embedded Calldata Formatting Parameters", + "type": "object", + "properties": { + "callee": { + "title": "Callee Address", + "type": [ + "string", + "object" + ], + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The address of the contract being called by this embedded calldata." + }, + "calleePath": { + "title": "Callee Path", + "type": "string", + "description": "The path to the address of the contract being called by this embedded calldata." + }, + "selector": { + "title": "Called Selector (Optional)", + "type": [ + "string", + "object" + ], + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The selector being called, if not contained in the calldata. Hex string representation." + }, + "selectorPath": { + "title": "Called Selector Path (Optional)", + "type": "string", + "description": "The path to the selector being called, if not contained in the calldata." + }, + "amount": { + "title": "Amount (Optional)", + "type": [ + "integer", + "object" + ], + "anyOf": [ + { + "type": "integer" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The associated amount in native currency, if the calldata can be associated with a container value." + }, + "amountPath": { + "title": "Amoun Path (Optional)", + "type": "string", + "description": "The path to the associated amount in native currency, if the calldata can be associated with a container value." + }, + "spender": { + "title": "Spender Path (Optional)", + "type": [ + "string", + "object" + ], + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The associated spender, if the calldata can be associated with a container value." + }, + "spenderPath": { + "title": "Spender Path (Optional)", + "type": "string", + "description": "The path to the associated spender, if the calldata can be associated with a container value." + } + }, + "anyOf": [ + { + "required": [ + "callee" + ] + }, + { + "required": [ + "calleePath" + ] + } + ], + "allOf": [ + { + "not": { + "required": [ + "callee", + "calleePath" + ] + } + }, + { + "not": { + "required": [ + "selector", + "selectorPath" + ] + } + }, + { + "not": { + "required": [ + "amount", + "amountPath" + ] + } + }, + { + "not": { + "required": [ + "spender", + "spenderPath" + ] + } + } + ], + "additionalProperties": false + }, + "tokenAmountParameters": { + "title": "Token Amount Formatting Parameters", + "type": "object", + "properties": { + "token": { + "title": "Token", + "type": [ + "string", + "object" + ], + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The token address, or a path to a constant in the ERC 7730 file." + }, + "tokenPath": { + "title": "Token Path", + "type": "string", + "description": "The path to the token address in the structured data." + }, + "nativeCurrencyAddress": { + "title": "Native Currency Address", + "oneOf": [ + { + "type": "string", + "description": "An address equal to this value is interpreted as an amount in native currency rather than a token." + }, + { + "type": "array", + "description": "An array of addresses, any of which are interpreted as an amount in native currency rather than a token.", + "items": { + "type": "string" + } + } + ] + }, + "threshold": { + "title": "Unlimited Threshold", + "type": "string", + "description": "The threshold above which the amount should be displayed using the message parameter rather than the real amount." + }, + "message": { + "title": "Unlimited Message", + "type": "string", + "description": "The message to display when the amount is above the threshold." + }, + "chainId": { + "title": "Chain ID", + "type": [ + "integer", + "object" + ], + "anyOf": [ + { + "type": "integer" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "Optional. The chain on which the token is deployed (constant, or a map reference). 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." + }, + "chainIdPath": { + "title": "Chain ID Path", + "type": "string", + "description": "Optional. Path to the chain ID in the structured data. When present, the wallet SHOULD resolve token metadata for the chain at this path. Useful for cross-chain swap clear signing." + } + }, + "allOf": [ + { + "not": { + "required": [ + "token", + "tokenPath" + ] + } + }, + { + "not": { + "required": [ + "chainId", + "chainIdPath" + ] + } + } + ], + "additionalProperties": false + }, + "tokenTickerParameters": { + "title": "Token Ticker Formatting Parameters", + "type": "object", + "properties": { + "chainId": { + "title": "Chain ID", + "type": [ + "integer", + "object" + ], + "anyOf": [ + { + "type": "integer" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "Optional. The chain on which the token is deployed (constant, or a map reference). When present, the wallet SHOULD resolve the token ticker for this chain. Useful for cross-chain swap clear signing." + }, + "chainIdPath": { + "title": "Chain ID Path", + "type": "string", + "description": "Optional. Path to the chain ID in the structured data. When present, the wallet SHOULD resolve the token ticker for the chain at this path. Useful for cross-chain swap clear signing." + } + }, + "allOf": [ + { + "not": { + "required": [ + "chainId", + "chainIdPath" + ] + } + } + ], + "additionalProperties": false + }, + "nftNameParameters": { + "title": "NFT Names Formatting Parameters", + "type": "object", + "properties": { + "collection": { + "title": "Collection Address", + "type": [ + "string", + "object" + ], + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The collection address, or a path to a constant in the ERC 7730 file." + }, + "collectionPath": { + "title": "Collection Path", + "type": "string", + "description": "The path to the collection in the structured data." + } + }, + "anyOf": [ + { + "required": [ + "collection" + ] + }, + { + "required": [ + "collectionPath" + ] + } + ], + "not": { + "required": [ + "collection", + "collectionPath" + ] + }, + "additionalProperties": false + }, + "dateParameters": { + "title": "Date Formatting Parameters", + "type": "object", + "properties": { + "encoding": { + "title": "Date Encoding", + "type": "string", + "description": "The encoding of the date.", + "enum": [ + "blockheight", + "timestamp" + ] + } + }, + "required": [ + "encoding" + ], + "additionalProperties": false + }, + "unitParameters": { + "title": "Unit Formatting Parameters", + "type": "object", + "properties": { + "base": { + "title": "Unit base symbol", + "type": "string", + "description": "The base symbol of the unit, displayed after the converted value. It can be an SI unit symbol or acceptable dimensionless symbols like % or bps." + }, + "decimals": { + "title": "Decimals", + "type": "integer", + "description": "The number of decimals of the value, used to convert to a float." + }, + "prefix": { + "title": "Prefix", + "type": "boolean", + "description": "Whether the value should be converted to a prefixed unit, like k, M, G, etc." + } + }, + "required": [ + "base" + ], + "additionalProperties": false + }, + "enumParameters": { + "title": "Enum Formatting Parameters", + "type": "object", + "properties": { + "$ref": { + "title": "Enum reference", + "type": "string", + "description": "The internal path to the enum definition used to convert this value." + } + }, + "required": [ + "$ref" + ], + "additionalProperties": false + }, + "interoperableAddressNameParameters": { + "title": "Interoperable Address Names Formatting Parameters", + "type": "object", + "properties": { + "types": { + "title": "Address Type", + "type": "array", + "description": "The types of address to display. Restrict allowable sources of names and MAY lead to additional checks from wallets.", + "items": { + "type": "string", + "enum": [ + "wallet", + "eoa", + "contract", + "token", + "collection" + ] + } + }, + "sources": { + "title": "Trusted Sources", + "description": "Trusted Sources for names, in order of preferences. Sources values are wallet manufacturer specific, example values are \"local\" or \"ens\". See specification for more details on sources values.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderAddress": { + "title": "Sender Address", + "oneOf": [ + { + "type": "string", + "description": "An address equal to this value is interpreted as the sender referenced by `@.from`." + }, + { + "type": "array", + "description": "An array of addresses, any of which are interpreted as the sender referenced by `@.from`.", + "items": { + "type": "string" + } + } + ] + } + }, + "additionalProperties": false + }, + "encryptionParameters": { + "title": "Encrypted Value Parameters", + "type": "object", + "properties": { + "scheme": { + "type": "string", + "description": "The encryption scheme used to produce the handle." + }, + "plaintextType": { + "type": "string", + "description": "Solidity type of the decrypted value (the handle does not encode this)." + }, + "fallbackLabel": { + "type": "string", + "description": "Optional label to display when decryption is not possible. Defaults to \"[Encrypted]\"." + } + }, + "required": [ + "scheme" + ], + "additionalProperties": false + } + }, + "$definitions": { + "id": { + "title": "ID", + "type": "string", + "description": "An internal identifier that can be used either for clarity specifying what the element is or as a reference in device specific sections." + } + } +} From eaeb875977223434e5260cfcfb7d4c51803a59c1 Mon Sep 17 00:00:00 2001 From: Frederic Samier Date: Thu, 12 Feb 2026 18:06:37 +0100 Subject: [PATCH 02/15] feat: implement v2 input and resolved models --- src/erc7730/model/input/v2/__init__.py | 5 + src/erc7730/model/input/v2/context.py | 174 ++++++ src/erc7730/model/input/v2/descriptor.py | 70 +++ src/erc7730/model/input/v2/display.py | 632 ++++++++++++++++++++ src/erc7730/model/input/v2/format.py | 69 +++ src/erc7730/model/input/v2/metadata.py | 150 +++++ src/erc7730/model/input/v2/unions.py | 84 +++ src/erc7730/model/resolved/v2/__init__.py | 5 + src/erc7730/model/resolved/v2/context.py | 162 +++++ src/erc7730/model/resolved/v2/descriptor.py | 64 ++ src/erc7730/model/resolved/v2/display.py | 604 +++++++++++++++++++ src/erc7730/model/resolved/v2/metadata.py | 155 +++++ 12 files changed, 2174 insertions(+) create mode 100644 src/erc7730/model/input/v2/__init__.py create mode 100644 src/erc7730/model/input/v2/context.py create mode 100644 src/erc7730/model/input/v2/descriptor.py create mode 100644 src/erc7730/model/input/v2/display.py create mode 100644 src/erc7730/model/input/v2/format.py create mode 100644 src/erc7730/model/input/v2/metadata.py create mode 100644 src/erc7730/model/input/v2/unions.py create mode 100644 src/erc7730/model/resolved/v2/__init__.py create mode 100644 src/erc7730/model/resolved/v2/context.py create mode 100644 src/erc7730/model/resolved/v2/descriptor.py create mode 100644 src/erc7730/model/resolved/v2/display.py create mode 100644 src/erc7730/model/resolved/v2/metadata.py diff --git a/src/erc7730/model/input/v2/__init__.py b/src/erc7730/model/input/v2/__init__.py new file mode 100644 index 0000000..96075fd --- /dev/null +++ b/src/erc7730/model/input/v2/__init__.py @@ -0,0 +1,5 @@ +""" +Package implementing an object model for ERC-7730 v2 input descriptors. + +This model is directly serializable back to the original JSON document. +""" diff --git a/src/erc7730/model/input/v2/context.py b/src/erc7730/model/input/v2/context.py new file mode 100644 index 0000000..3b0ec74 --- /dev/null +++ b/src/erc7730/model/input/v2/context.py @@ -0,0 +1,174 @@ +""" +Object model for ERC-7730 v2 descriptors `context` section. + +""" + +from typing import Any + +from pydantic import Field +from pydantic_string_url import HttpUrl + +from erc7730.model.base import Model +from erc7730.model.types import Id, MixedCaseAddress + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class InputDomain(Model): + """ + EIP 712 Domain Binding constraint. + + Each value of the domain constraint MUST match the corresponding eip 712 message domain value. + """ + + name: str | None = Field(default=None, title="Name", description="The EIP-712 domain name.") + + version: str | None = Field(default=None, title="Version", description="The EIP-712 version.") + + chainId: int | None = Field(default=None, title="Chain ID", description="The EIP-155 chain id.") + + verifyingContract: MixedCaseAddress | None = Field( + default=None, title="Verifying Contract", description="The EIP-712 verifying contract address." + ) + + +class InputDeployment(Model): + """ + A deployment describing where the contract is deployed. + + The target contract (Tx to or factory) MUST match one of those deployments. + """ + + chainId: int = Field(title="Chain ID", description="The deployment EIP-155 chain id.") + + address: MixedCaseAddress = Field(title="Contract Address", description="The deployment contract address.") + + +class InputFactory(Model): + """ + A factory constraint is used to check whether the target contract is deployed by a specified factory. + """ + + deployments: list[InputDeployment] = Field( + title="Deployments", + description="An array of deployments describing where the contract is deployed. The target contract (Tx to or" + "factory) MUST match one of those deployments.", + ) + + deployEvent: str = Field( + title="Deploy Event signature", + description="The event signature that the factory emits when deploying a new contract.", + ) + + +class InputBindingContext(Model): + deployments: list[InputDeployment] = Field( + title="Deployments", + description="An array of deployments describing where the contract is deployed. The target contract (Tx to or" + "factory) MUST match one of those deployments.", + min_length=1, + ) + + +class InputContract(InputBindingContext): + """ + The contract binding context is a set constraints that are used to bind the ERC7730 file to a specific smart + contract. + """ + + abi: Any | None = Field( + None, + title="ABI", + description=( + "[Deprecated] ABI definition bound to this file." + "Continue providing it for backward compatibility only; new specs should rely on display formats." + ), + ) + + addressMatcher: HttpUrl | None = Field( + None, + title="Address Matcher", + description="An URL of a contract address matcher that should be used to match the contract address.", + ) + + factory: InputFactory | None = Field( + None, + title="Factory Constraint", + description="A factory constraint is used to check whether the target contract is deployed by a specified" + "factory.", + ) + + +class InputEIP712(InputBindingContext): + """ + EIP 712 Binding. + + The EIP-712 binding context is a set of constraints that must be verified by the message being signed. + """ + + domain: InputDomain | None = Field( + default=None, + title="EIP 712 Domain Binding constraint", + description="Each value of the domain constraint MUST match the corresponding eip 712 message domain value.", + ) + + domainSeparator: str | None = Field( + default=None, + title="Domain Separator constraint", + description="The domain separator value that must be matched by the message. In hex string representation.", + ) + + schemas: Any | None = Field( + None, + title="EIP-712 messages schemas", + description=( + "[Deprecated] Schema definition bound to this file. " + "This is deprecated in favor of format driven validation. " + "The address book should be used to resolve EIP-712 schemas." + ), + ) + + +class InputContractContext(Model): + """ + Contract Binding Context. + + The contract binding context is a set constraints that are used to bind the ERC7730 file to a specific smart + contract. + """ + + id: Id | None = Field( + alias="$id", + default=None, + title="Id", + description="An internal identifier that can be used either for clarity specifying what the element is or as a" + "reference in device specific sections.", + ) + + contract: InputContract = Field( + title="Contract Binding Context", + description="The contract binding context is a set constraints that are used to bind the ERC7730 file to a" + "specific smart contract.", + ) + + +class InputEIP712Context(Model): + """ + EIP 712 Binding. + + The EIP-712 binding context is a set of constraints that must be verified by the message being signed. + """ + + id: Id | None = Field( + alias="$id", + default=None, + title="Id", + description="An internal identifier that can be used either for clarity specifying what the element is or as a" + "reference in device specific sections.", + ) + + eip712: InputEIP712 = Field( + title="EIP 712 Binding", + description="The EIP-712 binding context is a set of constraints that must be verified by the message being" + "signed.", + ) diff --git a/src/erc7730/model/input/v2/descriptor.py b/src/erc7730/model/input/v2/descriptor.py new file mode 100644 index 0000000..fc4323d --- /dev/null +++ b/src/erc7730/model/input/v2/descriptor.py @@ -0,0 +1,70 @@ +""" +Package implementing an object model for ERC-7730 v2 input descriptors. + +This model represents descriptors before resolution phase: + - URLs have been not been fetched yet + - Contract addresses have not been normalized to lowercase + - References have not been inlined + - Constants have not been inlined + - Field definitions have not been inlined + - Nested fields have been flattened where possible + - Selectors have been converted to 4 bytes form +""" + +from pydantic import Field + +from erc7730.model.base import Model +from erc7730.model.input.v2.context import InputContractContext, InputEIP712Context +from erc7730.model.input.v2.display import InputDisplay +from erc7730.model.input.v2.metadata import InputMetadata + + +class InputERC7730Descriptor(Model): + """ + An ERC-7730 v2 Clear Signing descriptor. + + This model is directly serializable back to the original JSON document. + + Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs + + JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v2.schema.json + """ + + schema_: str | None = Field( + None, + alias="$schema", + description="The schema that the document should conform to. This should be the URL of a version of the clear " + "signing JSON schemas available under " + "https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs", + ) + + comment: str | None = Field( + None, + alias="$comment", + description="An optional comment string that can be used to document the purpose of the file.", + ) + + includes: str | None = Field( + None, + description="An URL of another ERC 7730 file that should be merged into this one. Includes are merged into " + "this file before analysis. This can be used to manage interfaces definitions without redundancy.", + ) + + context: InputContractContext | InputEIP712Context = Field( + title="Binding Context Section", + description="The binding context is a set of constraints that are used to bind the ERC7730 file to a specific" + "structured data being displayed. Currently, supported contexts include contract-specific" + "constraints or EIP712 message specific constraints.", + ) + + metadata: InputMetadata = Field( + title="Metadata Section", + description="The metadata section contains information about constant values relevant in the scope of the" + "current contract / message (as matched by the `context` section)", + ) + + display: InputDisplay = Field( + title="Display Formatting Info Section", + description="The display section contains all the information needed to format the data in a human readable" + "way. It contains the constants and formatters used to display the data contained in the bound structure.", + ) diff --git a/src/erc7730/model/input/v2/display.py b/src/erc7730/model/input/v2/display.py new file mode 100644 index 0000000..b84e180 --- /dev/null +++ b/src/erc7730/model/input/v2/display.py @@ -0,0 +1,632 @@ +""" +Object model for ERC-7730 v2 descriptors `display` section. + +Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs +JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v2.schema.json +""" + +from typing import Annotated, Any, ForwardRef, Self + +from pydantic import Discriminator, Field, Tag, model_validator + +from erc7730.model.base import Model +from erc7730.model.display import ( + AddressNameType, + FormatBase, +) +from erc7730.model.input.path import ContainerPathStr, DataPathStr, DescriptorPathStr +from erc7730.model.input.v2.format import DateEncoding, FieldFormat +from erc7730.model.input.v2.unions import ( + field_discriminator, + field_parameters_discriminator, + visibility_rules_discriminator, +) +from erc7730.model.types import HexStr, Id, MixedCaseAddress, ScalarType + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class InputVisibilityConditions(Model): + """ + Complex visibility conditions for field display rules. + """ + + ifNotIn: list[str] | None = Field( + None, + title="If Not In", + description="Display this field only if its value is NOT in this list.", + ) + + mustBe: list[str] | None = Field( + None, + title="Must Be", + description="Skip displaying this field but its value MUST match one of these values.", + ) + + @model_validator(mode="after") + def _validate_at_least_one_condition(self) -> Self: + if self.ifNotIn is None and self.mustBe is None: + raise ValueError('At least one of "ifNotIn" or "mustBe" must be set.') + return self + + +InputVisibilityRules = Annotated[ + Annotated[str, Tag("simple")] | Annotated[InputVisibilityConditions, Tag("conditions")], + Discriminator(visibility_rules_discriminator), +] + + +class InputMapReference(Model): + """ + A reference to a map for dynamic value resolution. + """ + + map: DescriptorPathStr = Field( + title="Map Reference", + description="The path to the referenced map.", + ) + + keyPath: DescriptorPathStr | DataPathStr | ContainerPathStr = Field( + title="Key Path", + description="The path to the key used to resolve a value in the referenced map.", + ) + + +class InputFieldBase(Model): + """ + A field formatter, containing formatting information of a single field in a message. + """ + + path: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Path", + description="A path to the field in the structured data. The path is a JSON path expression that can be used " + """to extract the field value from the structured data. Exactly one of "path" or "value" must be set.""", + ) + + value: DescriptorPathStr | ScalarType | None = Field( + default=None, + title="Value", + description="A literal value on which the format should be applied instead of looking up a field in the " + """structured data. Exactly one of "path" or "value" must be set.""", + ) + + @model_validator(mode="after") + def _validate_one_of_path_or_value(self) -> Self: + if self.path is None and self.value is None: + raise ValueError('Either "path" or "value" must be set.') + if self.path is not None and self.value is not None: + raise ValueError('"path" and "value" are mutually exclusive.') + return self + + +class InputReference(InputFieldBase): + """ + A reference to a shared definition that should be used as the field formatting definition. + + The value is the key in the display definitions section, as a path expression $.display.definitions.DEFINITION_NAME. + It is used to share definitions between multiple messages / functions. + """ + + ref: DescriptorPathStr = Field( + alias="$ref", + title="Internal Definition", + description="An internal definition that should be used as the field formatting definition. The value is the " + "key in the display definitions section, as a path expression $.display.definitions.DEFINITION_NAME.", + ) + + params: dict[str, Any] | None = Field( + default=None, + title="Parameters", + description="Parameters override. These values takes precedence over the ones in the definition itself.", + ) + + +class InputTokenAmountParameters(Model): + """ + Token Amount Formatting Parameters. + """ + + tokenPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Token Path", + description="Path reference to the address of the token contract. Used to associate correct ticker. If ticker " + "is not found or tokenPath is not set, the wallet SHOULD display the raw value instead with an" + '"Unknown token" warning. Exactly one of "tokenPath" or "token" must be set.', + ) + + token: DescriptorPathStr | MixedCaseAddress | InputMapReference | None = Field( + default=None, + title="Token", + description=( + "The address of the token contract, as constant value or map reference. " + "Used to associate the correct ticker. If the ticker is not found or the value is not set, " + 'the wallet SHOULD display the raw value instead with an "Unknown token" warning. ' + 'Exactly one of "tokenPath" or "token" must be set.' + ), + ) + + nativeCurrencyAddress: list[DescriptorPathStr | MixedCaseAddress] | DescriptorPathStr | MixedCaseAddress | None = ( + Field( + default=None, + title="Native Currency Address", + description="An address or array of addresses, any of which are interpreted as an amount in native " + "currency rather than a token.", + ) + ) + + threshold: DescriptorPathStr | HexStr | int | None = Field( + default=None, + title="Unlimited Threshold", + description="The threshold above which the amount should be displayed using the message parameter rather than " + "the real amount (encoded as an int or byte array).", + ) + + message: DescriptorPathStr | str | None = Field( + default=None, + title="Unlimited Message", + description="The message to display when the amount is above the threshold.", + ) + + chainId: int | DescriptorPathStr | InputMapReference | None = Field( + default=None, + title="Chain ID", + description=( + "Optional. The chain on which the token is deployed (constant, or a map reference). " + "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." + ), + ) + + chainIdPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Chain ID Path", + description=( + "Optional. Path to the chain ID in the structured data. " + "When present, the wallet SHOULD resolve token metadata for the chain at this path. " + "Useful for cross-chain swap clear signing." + ), + ) + + @model_validator(mode="after") + def _validate_one_of_token_path_or_value(self) -> Self: + if self.tokenPath is not None and self.token is not None: + raise ValueError('"tokenPath" and "token" are mutually exclusive.') + if self.chainId is not None and self.chainIdPath is not None: + raise ValueError('"chainId" and "chainIdPath" are mutually exclusive.') + return self + + +class InputAddressNameParameters(Model): + """ + Address Names Formatting Parameters. + """ + + types: list[AddressNameType] | DescriptorPathStr | None = Field( + default=None, + title="Address Type", + description="An array of expected types of the address. If set, the wallet SHOULD check that the address " + "matches one of the types provided.", + min_length=1, + ) + + sources: list[str] | DescriptorPathStr | None = Field( + default=None, + title="Trusted Sources", + description="An array of acceptable sources for names. If set, the wallet SHOULD restrict name lookup to " + "relevant sources.", + min_length=1, + ) + + senderAddress: MixedCaseAddress | list[MixedCaseAddress] | DescriptorPathStr | InputMapReference | 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", + ) + + +class InputInteroperableAddressNameParameters(Model): + """ + Interoperable Address Names Formatting Parameters. + """ + + types: list[str] | DescriptorPathStr | None = Field( + default=None, + title="Address Type", + description="An array of expected types of the address (wallet, eoa, contract, token, collection).", + min_length=1, + ) + + sources: list[str] | DescriptorPathStr | None = Field( + default=None, + title="Trusted Sources", + description="An array of acceptable sources for names.", + min_length=1, + ) + + senderAddress: MixedCaseAddress | list[MixedCaseAddress] | DescriptorPathStr | InputMapReference | None = Field( + default=None, + title="Sender Address", + description="Either a string or an array of strings for sender address matching.", + ) + + +class InputCallDataParameters(Model): + """ + Embedded Calldata Formatting Parameters. + """ + + calleePath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Callee Path", + description="The path to the address of the contract being called by this embedded calldata. Exactly one of " + '"calleePath" or "callee" must be set.', + ) + + callee: DescriptorPathStr | MixedCaseAddress | InputMapReference | None = Field( + default=None, + title="Callee", + description=( + "The address of the contract being called by this embedded calldata, " + 'as a constant value or map reference. Exactly one of "calleePath" or "callee" must be set.' + ), + ) + + selectorPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Called Selector path", + description="The path to selector being called, if not contained in the calldata. Only " + 'one of "selectorPath" or "selector" must be set.', + ) + + selector: DescriptorPathStr | str | InputMapReference | None = Field( + default=None, + title="Called Selector", + description=( + "The selector being called, if not contained in the calldata or provided as a map reference. " + 'Hex string representation. Only one of "selectorPath" or "selector" must be set.' + ), + ) + + amountPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Amount path", + description="The path to the amount being transferred, if not contained in the calldata. Only " + 'one of "amountPath" or "amount" must be set.', + ) + + amount: int | DescriptorPathStr | InputMapReference | None = Field( + default=None, + title="Amount", + description="The amount being transferred, if not contained in the calldata or as map reference. Only " + 'one of "amountPath" or "amount" must be set.', + ) + + spenderPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Spender Path", + description="The path to the spender, if not contained in the calldata. Only " + 'one of "spenderPath" or "spender" must be set.', + ) + + spender: DescriptorPathStr | MixedCaseAddress | InputMapReference | None = Field( + default=None, + title="Spender", + description="the spender, if not contained in the calldata or as map reference. Only " + 'one of "spenderPath" or "spender" must be set.', + ) + + @model_validator(mode="after") + def _validate_mutually_exclusive_path_or_value(self) -> Self: + if self.calleePath is not None and self.callee is not None: + raise ValueError('"calleePath" and "callee" are mutually exclusive.') + if self.selectorPath is not None and self.selector is not None: + raise ValueError('"selectorPath" and "selector" are mutually exclusive.') + if self.amountPath is not None and self.amount is not None: + raise ValueError('"amountPath" and "amount" are mutually exclusive.') + if self.spenderPath is not None and self.spender is not None: + raise ValueError('"spenderPath" and "spender" are mutually exclusive.') + return self + + @model_validator(mode="after") + def _validate_one_of_callee_path_or_value(self) -> Self: + if self.calleePath is None and self.callee is None: + raise ValueError('Either "calleePath" or "callee" must be set.') + return self + + +class InputNftNameParameters(Model): + """ + NFT Names Formatting Parameters. + """ + + collectionPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Collection Path", + description="The path to the collection in the structured data. Exactly one of " + '"collectionPath" or "collection" must be set.', + ) + + collection: DescriptorPathStr | MixedCaseAddress | InputMapReference | None = Field( + default=None, + title="Collection", + description="The address of the collection contract, as a constant value or map reference. Exactly one of " + '"collectionPath" or "collection" must be set.', + ) + + @model_validator(mode="after") + def _validate_one_of_collection_path_or_value(self) -> Self: + if self.collectionPath is None and self.collection is None: + raise ValueError('Either "collectionPath" or "collection" must be set.') + if self.collectionPath is not None and self.collection is not None: + raise ValueError('"collectionPath" and "collection" are mutually exclusive.') + return self + + +class InputDateParameters(Model): + """ + Date Formatting Parameters + """ + + encoding: DateEncoding | DescriptorPathStr = Field(title="Date Encoding", description="The encoding of the date.") + + +class InputUnitParameters(Model): + """ + Unit Formatting Parameters. + """ + + base: DescriptorPathStr | str = Field( + title="Unit base symbol", + description="The base symbol of the unit, displayed after the converted value. It can be an SI unit symbol or " + "acceptable dimensionless symbols like % or bps.", + ) + + decimals: int | DescriptorPathStr | None = Field( + default=None, title="Decimals", description="The number of decimals of the value, used to convert to a float." + ) + + prefix: bool | DescriptorPathStr | None = Field( + default=None, + title="Prefix", + description="Whether the value should be converted to a prefixed unit, like k, M, G, etc.", + ) + + +class InputEnumParameters(Model): + """ + Enum Formatting Parameters. + """ + + ref: DescriptorPathStr = Field( + alias="$ref", + title="Enum reference", + description="The internal path to the enum definition used to convert this value.", + ) + + +class InputTokenTickerParameters(Model): + """ + Token Ticker Formatting Parameters. + """ + + chainId: int | DescriptorPathStr | InputMapReference | None = Field( + default=None, + title="Chain ID", + description=( + "Optional. The chain on which the token is deployed (constant, or a map reference). " + "When present, the wallet SHOULD resolve the token ticker for this chain. " + "Useful for cross-chain swap clear signing." + ), + ) + + chainIdPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Chain ID Path", + description=( + "Optional. Path to the chain ID in the structured data. " + "When present, the wallet SHOULD resolve the token ticker for the chain at this path. " + "Useful for cross-chain swap clear signing." + ), + ) + + @model_validator(mode="after") + def _validate_chainid_mutually_exclusive(self) -> Self: + if self.chainId is not None and self.chainIdPath is not None: + raise ValueError('"chainId" and "chainIdPath" are mutually exclusive.') + return self + + +class InputEncryptionParameters(Model): + """ + Encrypted Value Parameters. + """ + + scheme: str = Field( + title="Encryption Scheme", + description="The encryption scheme used to produce the handle.", + ) + + plaintextType: str | None = Field( + default=None, + title="Plaintext Type", + description="Solidity type of the decrypted value (the handle does not encode this).", + ) + + fallbackLabel: str | None = Field( + default=None, + title="Fallback Label", + description='Optional label to display when decryption is not possible. Defaults to "[Encrypted]".', + ) + + +# Extended field parameters for v2 - discriminator function needs to be updated +InputFieldParameters = Annotated[ + Annotated[InputAddressNameParameters, Tag("address_name")] + | Annotated[InputInteroperableAddressNameParameters, Tag("interoperable_address_name")] + | Annotated[InputCallDataParameters, Tag("call_data")] + | Annotated[InputTokenAmountParameters, Tag("token_amount")] + | Annotated[InputTokenTickerParameters, Tag("token_ticker")] + | Annotated[InputNftNameParameters, Tag("nft_name")] + | Annotated[InputDateParameters, Tag("date")] + | Annotated[InputUnitParameters, Tag("unit")] + | Annotated[InputEnumParameters, Tag("enum")], + Discriminator(field_parameters_discriminator), +] + + +class InputFieldDefinition(Model): + """ + A field formatter, containing formatting information of a single field in a message. + """ + + id: Id | None = Field( + alias="$id", + default=None, + title="Id", + description="An internal identifier that can be used either for clarity specifying what the element is or as a " + "reference in device specific sections.", + ) + + label: DescriptorPathStr | str = Field( + title="Field Label", + description="The label of the field, that will be displayed to the user in front of the formatted field value.", + ) + + format: FieldFormat | None = Field( + default=None, + title="Field Format", + description="The format of the field, that will be used to format the field value in a human readable way.", + ) + + params: InputFieldParameters | None = Field( + default=None, + title="Format Parameters", + description="Format specific parameters that are used to format the field value in a human readable way.", + ) + + +class InputFieldDescription(InputFieldBase, InputFieldDefinition): + """ + A field formatter, containing formatting information of a single field in a message. + """ + + visible: InputVisibilityRules | None = Field( + default=None, + title="Visibility Rules", + description=( + "Specifies when a field should be displayed based on its value or context. " + "Defaults to 'always' if not specified." + ), + ) + + separator: str | None = Field( + default=None, + title="Field Separator", + description="Optional separator for array values with {index} interpolation support.", + ) + + encryption: InputEncryptionParameters | None = Field( + default=None, + title="Encryption Parameters", + description=( + "If present, the field value is encrypted. The format specifies how to display the decrypted value." + ), + ) + + +class InputFieldGroup(Model): + """ + A group of field formats, allowing recursivity in the schema and control over grouping and iteration. + + Used to group whole definitions for structures for instance. This allows nesting definitions of formats, but note + that support for deep nesting will be device dependent. + + Unlike InputFieldBase, field groups do not require path or value (per v2 schema). The path is optional and there + is no value field — field groups scope their children under the given path, or act as logical groupings when no + path is provided. + """ + + id: Id | None = Field( + alias="$id", + default=None, + title="Id", + description="An internal identifier that can be used either for clarity specifying what the element is or as a " + "reference in device specific sections.", + ) + + path: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Path", + description="An optional path to scope the field group under. When set, child fields are resolved relative to " + "this path.", + ) + + label: str | None = Field( + default=None, + title="Group Label", + description="An optional label for the field group.", + ) + + iteration: str | None = Field( + default=None, + title="Iteration Strategy", + description="Specifies how iteration over arrays should be handled: 'sequential' or 'bundled'.", + ) + + fields: list[ForwardRef("InputField")] = Field( # type: ignore + title="Fields", description="Group of field formats." + ) + + +InputField = Annotated[ + Annotated[InputReference, Tag("reference")] + | Annotated[InputFieldDescription, Tag("field_description")] + | Annotated[InputFieldGroup, Tag("field_group")], + Discriminator(field_discriminator), +] + + +class InputFormat(FormatBase): + """ + A structured data format specification containing formatting information of fields + in a single type of message (v2). + """ + + interpolatedIntent: str | None = Field( + default=None, + title="Interpolated Intent Message", + description=( + "An optional intent string with embedded field values using {path} interpolation syntax. " + "This provides a dynamic, contextual description by embedding actual transaction or message " + "values directly in the intent string." + ), + ) + + fields: list[InputField] = Field( + title="Field Formats set", + description="An array containing the ordered definitions of fields formats.", + ) + + # Note: v2 removed required and excluded arrays + + +class InputDisplay(Model): + """ + Display Formatting Info Section (v2). + """ + + definitions: dict[str, InputFieldDefinition] | None = Field( + default=None, + title="Common Formatter Definitions", + description="A set of definitions that can be used to share formatting information between multiple messages / " + "functions. The definitions can be referenced by the key name in an internal path.", + ) + + formats: dict[str, InputFormat] = Field( + title="List of field formats", + description="The list includes formatting info for each field of a structure. This list is indexed by a key " + "identifying uniquely the message's type in the abi. For smartcontracts, it is the selector of the " + "function or its signature; and for EIP712 messages it is the primaryType of the message.", + ) diff --git a/src/erc7730/model/input/v2/format.py b/src/erc7730/model/input/v2/format.py new file mode 100644 index 0000000..67c863c --- /dev/null +++ b/src/erc7730/model/input/v2/format.py @@ -0,0 +1,69 @@ +""" +Object model for ERC-7730 v2 descriptors `display` section enums and format types. + +Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs +JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v2.schema.json +""" + +from enum import StrEnum + +from erc7730.model.display import DateEncoding as BaseDataEncoding + + +class FieldFormat(StrEnum): + """ + The format of the field (v2), that will be used to format the field value in a human readable way. + """ + + RAW = "raw" + """The field should be displayed as the natural representation of the underlying structured data type.""" + + ADDRESS_NAME = "addressName" + """The field should be displayed as a trusted name, or as a raw address if no names are found in trusted sources. + List of trusted sources can be optionally specified in parameters.""" + + INTEROPERABLE_ADDRESS_NAME = "interoperableAddressName" + """The field should be displayed as a trusted name using interoperable address name resolution.""" + + TOKEN_TICKER = "tokenTicker" # nosec B105 - constant string, not credentials + """The field should be displayed as an ERC 20 token ticker. + If no token definitions are found, fall back to the raw address.""" + + CALL_DATA = "calldata" + """The field is itself a calldata embedded in main call. Another ERC 7730 should be used to parse this field. If not + available or not supported, the wallet MAY display a hash of the embedded calldata instead.""" + + AMOUNT = "amount" + """The field should be displayed as an amount in underlying currency, converted using the best magnitude / ticker + available.""" + + TOKEN_AMOUNT = "tokenAmount" # nosec B105 - bandit false positive + """The field should be displayed as an amount, preceded by the ticker. The magnitude and ticker should be derived + from the tokenPath parameter corresponding metadata.""" + + NFT_NAME = "nftName" + """The field should be displayed as a single NFT names, or as a raw token Id if a specific name is not found. + Collection is specified by the collectionPath parameter.""" + + DATE = "date" + """The field should be displayed as a date. Suggested RFC3339 representation. Parameter specifies the encoding of + the date.""" + + DURATION = "duration" + """The field should be displayed as a duration in HH:MM:ss form. Value is interpreted as a number of seconds.""" + + UNIT = "unit" + """The field should be displayed as a percentage. Magnitude of the percentage encoding is specified as a parameter. + Example: a value of 3000 with magnitude 4 is displayed as 0.3%.""" + + ENUM = "enum" + """The field should be displayed as a human readable string by converting the value using the enum referenced in + parameters.""" + + CHAIN_ID = "chainId" + """The field should be displayed as a Blockchain explicit name, as defined in EIP-155. + The name is resolved based on the chain id value.""" + + +# Re-export base enums that haven't changed +DateEncoding = BaseDataEncoding diff --git a/src/erc7730/model/input/v2/metadata.py b/src/erc7730/model/input/v2/metadata.py new file mode 100644 index 0000000..784f32d --- /dev/null +++ b/src/erc7730/model/input/v2/metadata.py @@ -0,0 +1,150 @@ +""" +Object model for ERC-7730 v2 descriptors `metadata` section. + +Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs +JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v2.schema.json +""" + +from datetime import UTC, datetime +from typing import Any + +from pydantic import Field +from pydantic_string_url import HttpUrl + +from erc7730.model.base import Model +from erc7730.model.metadata import TokenInfo +from erc7730.model.resolved.metadata import EnumDefinition +from erc7730.model.types import Id, ScalarType + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class InputOwnerInfo(Model): + """ + Main contract's owner detailed information (v2). + + The owner info section contains detailed information about the owner or target of the contract / message to be + clear signed. + + Note: legalName and lastUpdate are v1 backward compatibility extensions not present in the v2 JSON schema. + The v2 schema only defines deploymentDate and url for info. These fields are retained for smooth migration. + """ + + legalName: str | None = Field( + None, + title="Owner Legal Name", + description="[v1 compat] The full legal name of the owner if different from the owner field. " + "Not present in v2 schema, retained for backward compatibility.", + min_length=1, + examples=["Tether Limited", "Lido DAO"], + ) + + lastUpdate: datetime | None = Field( + default=None, + title="[DEPRECATED] Last Update of the contract / message", + description="[v1 compat] The date of the last update of the contract / message. " + "Not present in v2 schema, retained for backward compatibility. Use `deploymentDate` instead.", + examples=[datetime.now(UTC)], + ) + + deploymentDate: datetime | None = Field( + default=None, + title="Deployment date of contract / message", + description="The date of deployment of the contract / message.", + examples=[datetime.now(UTC)], + ) + + url: HttpUrl = Field( + title="Owner URL", + description="URL with more info on the entity the user interacts with.", + examples=[HttpUrl("https://tether.to"), HttpUrl("https://lido.fi")], + ) + + +class InputMapDefinition(Model): + """ + A map definition for context-dependent constants. + + Maps are used to provide context-dependent values based on a key resolution. + """ + + keyType: str | None = Field( + None, + alias="$keyType", + title="Key Type", + description="The type of the key used for map resolution.", + ) + + values: dict[str, Any] = Field( + title="Map Values", + description="The mapping of keys to values that will be used for dynamic resolution.", + min_length=1, + ) + + +class InputMetadata(Model): + """ + Metadata Section (v2). + + The metadata section contains information about constant values relevant in the scope of the current contract / + message (as matched by the `context` section) + """ + + owner: str | None = Field( + default=None, + title="Owner display name.", + description="The display name of the owner or target of the contract / message to be clear signed.", + ) + + contractName: str | None = Field( + default=None, + title="Contract Name", + description="The name of the contract targeted by the transaction or message.", + ) + + info: InputOwnerInfo | None = Field( + default=None, + title="Main contract's owner detailed information.", + description="The owner info section contains detailed information about the owner or target of the contract / " + "message to be clear signed.", + ) + + token: TokenInfo | None = Field( + default=None, + title="Token Description", + description="A description of an ERC20 token exported by this format, that should be trusted. Not mandatory if " + "the corresponding metadata can be fetched from the contract itself.", + ) + + constants: dict[Id, ScalarType | None] | None = Field( + default=None, + title="Constant values", + description="A set of values that can be used in format parameters. Can be referenced with a path expression " + "like $.metadata.constants.CONSTANT_NAME", + examples=[ + { + "token_path": "#.params.witness.outputs[0].token", + "native_currency": "0x0000000000000000000000000000000000000001", + "max_threshold": "0xFFFFFFFF", + "max_message": "Max", + } + ], + ) + + enums: dict[Id, HttpUrl | EnumDefinition] | None = Field( + default=None, + title="Enums", + description="A set of enums that are used to format fields replacing values with human readable strings.", + examples=[{"interestRateMode": {"1": "stable", "2": "variable"}, "vaultIDs": "https://example.com/vaultIDs"}], + max_length=32, # TODO refine + ) + + maps: dict[Id, InputMapDefinition] | None = Field( + default=None, + title="Maps", + description="A set of maps that are used to manage context dependent constants. Maps can be used in place of " + "constants, and the corresponding constant is based on the map key resolved value.", + examples=[ + {"tokenMap": {"$keyType": "address", "values": {"0xA0b86a33E6441...": "USDT", "0x6B175474E89...": "DAI"}}} + ], + ) diff --git a/src/erc7730/model/input/v2/unions.py b/src/erc7730/model/input/v2/unions.py new file mode 100644 index 0000000..621210d --- /dev/null +++ b/src/erc7730/model/input/v2/unions.py @@ -0,0 +1,84 @@ +""" +Object model for ERC-7730 v2 discriminated unions and discriminator functions. + +Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs +JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v2.schema.json +""" + +from typing import Any + +from erc7730.common.properties import has_any_property + + +def field_discriminator(v: Any) -> str | None: + """ + Discriminator function for the Field union type (v2). + + :param v: deserialized raw data + :return: the discriminator tag + """ + if has_any_property(v, "$ref"): + return "reference" + if has_any_property(v, "fields"): + return "field_group" # was "nested_fields" in v1 + if has_any_property(v, "label", "format"): + return "field_description" + return None + + +def field_parameters_discriminator(v: Any) -> str | None: + """ + Discriminator function for the FieldParameters union type (v2). + + Note: addressName and interoperableAddressName parameters have identical schemas and cannot be + reliably distinguished by data shape alone. The correct type is determined by the parent field's + format property. For deserialization, ambiguous cases default to address_name. The converter + correctly routes parameters based on the format context. + + :param v: deserialized raw data + :return: the discriminator tag + """ + if has_any_property(v, "tokenPath", "token", "nativeCurrencyAddress", "threshold", "message"): + return "token_amount" + if has_any_property(v, "encoding"): + return "date" + if has_any_property(v, "collectionPath", "collection"): + return "nft_name" + if has_any_property(v, "base"): + return "unit" + if has_any_property(v, "$ref", "ref", "enumId"): + return "enum" + if has_any_property(v, "calleePath", "callee", "selector", "selectorPath"): + return "call_data" + # tokenTicker only has chainId/chainIdPath - distinguish from other params that also have these fields + if has_any_property(v, "chainId", "chainIdPath") and not has_any_property( + v, + "tokenPath", + "token", + "nativeCurrencyAddress", + "threshold", + "message", + "types", + "sources", + "senderAddress", + ): + return "token_ticker" + # addressName and interoperableAddressName have identical schemas (types, sources, senderAddress). + # Default to address_name; the converter determines the actual type from the format field. + if has_any_property(v, "types", "sources", "senderAddress"): + return "address_name" + return None + + +def visibility_rules_discriminator(v: Any) -> str | None: + """ + Discriminator function for the VisibilityRules union type (v2). + + :param v: deserialized raw data + :return: the discriminator tag + """ + if isinstance(v, str): + return "simple" + if isinstance(v, dict) and has_any_property(v, "ifNotIn", "mustBe"): + return "conditions" + return None diff --git a/src/erc7730/model/resolved/v2/__init__.py b/src/erc7730/model/resolved/v2/__init__.py new file mode 100644 index 0000000..8125841 --- /dev/null +++ b/src/erc7730/model/resolved/v2/__init__.py @@ -0,0 +1,5 @@ +""" +Package implementing an object model for ERC-7730 v2 resolved descriptors. + +This model represents descriptors after the resolution phase has been applied. +""" diff --git a/src/erc7730/model/resolved/v2/context.py b/src/erc7730/model/resolved/v2/context.py new file mode 100644 index 0000000..56f0e37 --- /dev/null +++ b/src/erc7730/model/resolved/v2/context.py @@ -0,0 +1,162 @@ +""" +Object model for ERC-7730 v2 resolved descriptors `context` section. + +Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs +JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v2.schema.json +""" + +from pydantic import Field + +from erc7730.model.base import Model +from erc7730.model.types import Address, Id + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class ResolvedDomain(Model): + """ + EIP 712 Domain Binding constraint (resolved). + + Each value of the domain constraint MUST match the corresponding eip 712 message domain value. + """ + + name: str | None = Field(default=None, title="Name", description="The EIP-712 domain name.") + + version: str | None = Field(default=None, title="Version", description="The EIP-712 version.") + + chainId: int | None = Field(default=None, title="Chain ID", description="The EIP-155 chain id.") + + verifyingContract: Address | None = Field( + default=None, + title="Verifying Contract", + description="The EIP-712 verifying contract address (normalized to lowercase).", + ) + + +class ResolvedDeployment(Model): + """ + A deployment describing where the contract is deployed (resolved). + + The target contract (Tx to or factory) MUST match one of those deployments. + """ + + chainId: int = Field(title="Chain ID", description="The deployment EIP-155 chain id.") + + address: Address = Field( + title="Contract Address", description="The deployment contract address (normalized to lowercase)." + ) + + +class ResolvedFactory(Model): + """ + A factory constraint is used to check whether the target contract is deployed by a specified factory (resolved). + """ + + deployments: list[ResolvedDeployment] = Field( + title="Deployments", + description="An array of deployments describing where the contract is deployed. The target contract (Tx to or" + "factory) MUST match one of those deployments.", + ) + + deployEvent: str = Field( + title="Deploy Event signature", + description="The event signature that the factory emits when deploying a new contract.", + ) + + +class ResolvedBindingContext(Model): + deployments: list[ResolvedDeployment] = Field( + title="Deployments", + description="An array of deployments describing where the contract is deployed. The target contract (Tx to or" + "factory) MUST match one of those deployments.", + min_length=1, + ) + + +class ResolvedContract(ResolvedBindingContext): + """ + The contract binding context is a set constraints that are used to bind the ERC7730 file to a specific smart + contract (resolved). + """ + + # ABI is deprecated, so dropped from resolved model. + + addressMatcher: str | None = Field( + None, + title="Address Matcher", + description="A resolved address matcher that should be used to match the contract address.", + ) + + factory: ResolvedFactory | None = Field( + None, + title="Factory Constraint", + description="A factory constraint is used to check whether the target contract is deployed by a specified" + "factory.", + ) + + +class ResolvedEIP712(ResolvedBindingContext): + """ + EIP 712 Binding (resolved). + + The EIP-712 binding context is a set of constraints that must be verified by the message being signed. + """ + + domain: ResolvedDomain | None = Field( + default=None, + title="EIP 712 Domain Binding constraint", + description="Each value of the domain constraint MUST match the corresponding eip 712 message domain value.", + ) + + domainSeparator: str | None = Field( + default=None, + title="Domain Separator constraint", + description="The domain separator value that must be matched by the message. In hex string representation.", + ) + + # Schemas are deprecated, so dropped from resolved model. + + +class ResolvedContractContext(Model): + """ + Contract Binding Context (resolved). + + The contract binding context is a set constraints that are used to bind the ERC7730 file to a specific smart + contract. + """ + + id: Id | None = Field( + alias="$id", + default=None, + title="Id", + description="An internal identifier that can be used either for clarity specifying what the element is or as a" + "reference in device specific sections.", + ) + + contract: ResolvedContract = Field( + title="Contract Binding Context", + description="The contract binding context is a set constraints that are used to bind the ERC7730 file to a" + "specific smart contract.", + ) + + +class ResolvedEIP712Context(Model): + """ + EIP 712 Binding (resolved). + + The EIP-712 binding context is a set of constraints that must be verified by the message being signed. + """ + + id: Id | None = Field( + alias="$id", + default=None, + title="Id", + description="An internal identifier that can be used either for clarity specifying what the element is or as a" + "reference in device specific sections.", + ) + + eip712: ResolvedEIP712 = Field( + title="EIP 712 Binding", + description="The EIP-712 binding context is a set of constraints that must be verified by the message being" + "signed.", + ) diff --git a/src/erc7730/model/resolved/v2/descriptor.py b/src/erc7730/model/resolved/v2/descriptor.py new file mode 100644 index 0000000..1fc49cf --- /dev/null +++ b/src/erc7730/model/resolved/v2/descriptor.py @@ -0,0 +1,64 @@ +""" +Package implementing an object model for ERC-7730 v2 resolved descriptors. + +This model represents descriptors after resolution phase: + - URLs have been fetched + - Contract addresses have been normalized to lowercase + - References have been inlined + - Constants have been inlined + - Field definitions have been inlined + - Nested fields have been flattened where possible + - Selectors have been converted to 4 bytes form +""" + +from pydantic import Field + +from erc7730.model.base import Model +from erc7730.model.resolved.v2.context import ResolvedContractContext, ResolvedEIP712Context +from erc7730.model.resolved.v2.display import ResolvedDisplay +from erc7730.model.resolved.v2.metadata import ResolvedMetadata + + +class ResolvedERC7730Descriptor(Model): + """ + An ERC-7730 v2 Clear Signing descriptor, after resolution phase. + + This model represents the descriptor after all references have been resolved and constants inlined. + + Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs + + JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v2.schema.json + """ + + schema_: str | None = Field( + None, + alias="$schema", + description="The schema that the document should conform to. This should be the URL of a version of the clear " + "signing JSON schemas available under " + "https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs", + ) + + comment: str | None = Field( + None, + alias="$comment", + description="An optional comment string that can be used to document the purpose of the file.", + ) + + context: ResolvedContractContext | ResolvedEIP712Context = Field( + title="Binding Context Section", + description="The binding context is a set of constraints that are used to bind the ERC7730 file to a specific" + "structured data being displayed. Currently, supported contexts include contract-specific" + "constraints or EIP712 message specific constraints.", + ) + + metadata: ResolvedMetadata = Field( + title="Metadata Section", + description="The metadata section contains information about constant values relevant in the scope of the" + "current contract / message (as matched by the `context` section)", + ) + + display: ResolvedDisplay = Field( + title="Display Formatting Info Section", + description="The display section contains all the information needed to format the data in a human readable" + "way. It contains the constants and formatters used to display the data contained in the bound structure.", + ) diff --git a/src/erc7730/model/resolved/v2/display.py b/src/erc7730/model/resolved/v2/display.py new file mode 100644 index 0000000..fc53755 --- /dev/null +++ b/src/erc7730/model/resolved/v2/display.py @@ -0,0 +1,604 @@ +""" +Object model for ERC-7730 v2 resolved descriptors `display` section. + +Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs +JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v2.schema.json +""" + +from typing import Annotated, ForwardRef, Self + +from pydantic import Discriminator, Field, Tag, model_validator + +from erc7730.model.base import Model +from erc7730.model.display import ( + AddressNameType, + FormatBase, +) +from erc7730.model.input.path import ContainerPathStr, DataPathStr, DescriptorPathStr +from erc7730.model.input.v2.format import DateEncoding, FieldFormat +from erc7730.model.input.v2.unions import ( + field_discriminator, + field_parameters_discriminator, + visibility_rules_discriminator, +) +from erc7730.model.types import Address, HexStr, Id, ScalarType + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class ResolvedVisibilityConditions(Model): + """ + Complex visibility conditions for field display rules (resolved). + """ + + ifNotIn: list[str] | None = Field( + None, + title="If Not In", + description="Display this field only if its value is NOT in this list.", + ) + + mustBe: list[str] | None = Field( + None, + title="Must Be", + description="Skip displaying this field but its value MUST match one of these values.", + ) + + @model_validator(mode="after") + def _validate_at_least_one_condition(self) -> Self: + if self.ifNotIn is None and self.mustBe is None: + raise ValueError('At least one of "ifNotIn" or "mustBe" must be set.') + return self + + +ResolvedVisibilityRules = Annotated[ + Annotated[str, Tag("simple")] | Annotated[ResolvedVisibilityConditions, Tag("conditions")], + Discriminator(visibility_rules_discriminator), +] + + +class ResolvedFieldBase(Model): + """ + A resolved field formatter, containing formatting information of a single field in a message. + """ + + path: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Path", + description="A path to the field in the structured data. The path is a JSON path expression that can be used " + """to extract the field value from the structured data. Exactly one of "path" or "value" must be set.""", + ) + + value: ScalarType | None = Field( + default=None, + title="Value", + description=( + "A resolved literal value on which the format should be applied instead of looking up a field in the " + 'structured data. Exactly one of "path" or "value" must be set.' + ), + ) + + @model_validator(mode="after") + def _validate_one_of_path_or_value(self) -> Self: + if self.path is None and self.value is None: + raise ValueError('Either "path" or "value" must be set.') + if self.path is not None and self.value is not None: + raise ValueError('"path" and "value" are mutually exclusive.') + return self + + +class ResolvedTokenAmountParameters(Model): + """ + Token Amount Formatting Parameters (resolved). + """ + + tokenPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Token Path", + description="Path reference to the address of the token contract. Used to associate correct ticker. If ticker " + "is not found or tokenPath is not set, the wallet SHOULD display the raw value instead with an" + '"Unknown token" warning. Exactly one of "tokenPath" or "token" must be set.', + ) + + token: Address | None = Field( + default=None, + title="Token", + description="The resolved address of the token contract. 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. Exactly one of "tokenPath" or "token" must be set.', + ) + + nativeCurrencyAddress: list[Address] | Address | None = Field( + default=None, + title="Native Currency Address", + description="An address or array of resolved addresses, any of which are interpreted as an amount in native " + "currency rather than a token.", + ) + + threshold: HexStr | int | None = Field( + default=None, + title="Unlimited Threshold", + description=( + "The resolved threshold above which the amount should be displayed using the message parameter " + "rather than the real amount (encoded as an int or byte array)." + ), + ) + + message: str | None = Field( + default=None, + title="Unlimited Message", + description="The resolved message to display when the amount is above the threshold.", + ) + + chainId: int | None = Field( + default=None, + title="Chain ID", + description=( + "Optional. The resolved chain on which the token is deployed. " + "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." + ), + ) + + chainIdPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Chain ID Path", + description=( + "Optional. Path to the chain ID in the structured data. " + "When present, the wallet SHOULD resolve token metadata for the chain at this path. " + "Useful for cross-chain swap clear signing." + ), + ) + + @model_validator(mode="after") + def _validate_one_of_token_path_or_value(self) -> Self: + if self.tokenPath is not None and self.token is not None: + raise ValueError('"tokenPath" and "token" are mutually exclusive.') + if self.chainId is not None and self.chainIdPath is not None: + raise ValueError('"chainId" and "chainIdPath" are mutually exclusive.') + return self + + +class ResolvedAddressNameParameters(Model): + """ + Address Names Formatting Parameters (resolved). + """ + + types: list[AddressNameType] | None = Field( + default=None, + title="Address Type", + description="An array of expected types of the address. If set, the wallet SHOULD check that the address " + "matches one of the types provided.", + min_length=1, + ) + + sources: list[str] | None = Field( + default=None, + title="Trusted Sources", + description="An array of acceptable sources for names. If set, the wallet SHOULD restrict name lookup to " + "relevant sources.", + min_length=1, + ) + + senderAddress: Address | list[Address] | 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", + ) + + +class ResolvedInteroperableAddressNameParameters(Model): + """ + Interoperable Address Names Formatting Parameters (resolved). + """ + + types: list[str] | None = Field( + default=None, + title="Address Type", + description="An array of expected types of the address (wallet, eoa, contract, token, collection).", + min_length=1, + ) + + sources: list[str] | None = Field( + default=None, + title="Trusted Sources", + description="An array of acceptable sources for names.", + min_length=1, + ) + + senderAddress: Address | list[Address] | None = Field( + default=None, + title="Sender Address", + description="Either a string or an array of strings for sender address matching.", + ) + + +class ResolvedCallDataParameters(Model): + """ + Embedded Calldata Formatting Parameters (resolved). + """ + + calleePath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Callee Path", + description="The path to the address of the contract being called by this embedded calldata. Exactly one of " + '"calleePath" or "callee" must be set.', + ) + + callee: Address | None = Field( + default=None, + title="Callee", + description="The resolved address of the contract being called by this embedded calldata. Exactly " + 'one of "calleePath" or "callee" must be set.', + ) + + selectorPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Called Selector path", + description="The path to selector being called, if not contained in the calldata. Only " + 'one of "selectorPath" or "selector" must be set.', + ) + + selector: str | None = Field( + default=None, + title="Called Selector", + description=( + "The resolved selector being called, if not contained in the calldata. " + 'Hex string representation. Only one of "selectorPath" or "selector" must be set.' + ), + ) + + amountPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Amount path", + description="The path to the amount being transferred, if not contained in the calldata. Only " + 'one of "amountPath" or "amount" must be set.', + ) + + amount: int | None = Field( + default=None, + title="Amount", + description="The resolved amount being transferred, if not contained in the calldata. Only " + 'one of "amountPath" or "amount" must be set.', + ) + + spenderPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Spender Path", + description="The path to the spender, if not contained in the calldata. Only " + 'one of "spenderPath" or "spender" must be set.', + ) + + spender: Address | None = Field( + default=None, + title="Spender", + description="the resolved spender, if not contained in the calldata. Only " + 'one of "spenderPath" or "spender" must be set.', + ) + + @model_validator(mode="after") + def _validate_mutually_exclusive_path_or_value(self) -> Self: + if self.calleePath is not None and self.callee is not None: + raise ValueError('"calleePath" and "callee" are mutually exclusive.') + if self.selectorPath is not None and self.selector is not None: + raise ValueError('"selectorPath" and "selector" are mutually exclusive.') + if self.amountPath is not None and self.amount is not None: + raise ValueError('"amountPath" and "amount" are mutually exclusive.') + if self.spenderPath is not None and self.spender is not None: + raise ValueError('"spenderPath" and "spender" are mutually exclusive.') + return self + + @model_validator(mode="after") + def _validate_one_of_callee_path_or_value(self) -> Self: + if self.calleePath is None and self.callee is None: + raise ValueError('Either "calleePath" or "callee" must be set.') + return self + + +class ResolvedNftNameParameters(Model): + """ + NFT Names Formatting Parameters (resolved). + """ + + collectionPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Collection Path", + description="The path to the collection in the structured data. Exactly one of " + '"collectionPath" or "collection" must be set.', + ) + + collection: Address | None = Field( + default=None, + title="Collection", + description="The resolved address of the collection contract. Exactly one of " + '"collectionPath" or "collection" must be set.', + ) + + @model_validator(mode="after") + def _validate_one_of_collection_path_or_value(self) -> Self: + if self.collectionPath is None and self.collection is None: + raise ValueError('Either "collectionPath" or "collection" must be set.') + if self.collectionPath is not None and self.collection is not None: + raise ValueError('"collectionPath" and "collection" are mutually exclusive.') + return self + + +class ResolvedDateParameters(Model): + """ + Date Formatting Parameters (resolved) + """ + + encoding: DateEncoding = Field(title="Date Encoding", description="The resolved encoding of the date.") + + +class ResolvedUnitParameters(Model): + """ + Unit Formatting Parameters (resolved). + """ + + base: str = Field( + title="Unit base symbol", + description=( + "The resolved base symbol of the unit, displayed after the converted value. " + "It can be an SI unit symbol or acceptable dimensionless symbols like % or bps." + ), + ) + + decimals: int | None = Field( + default=None, + title="Decimals", + description="The resolved number of decimals of the value, used to convert to a float.", + ) + + prefix: bool | None = Field( + default=None, + title="Prefix", + description=( + "The resolved value indicating whether the value should be converted to a prefixed unit, like k, M, G, etc." + ), + ) + + +class ResolvedEnumParameters(Model): + """ + Enum Formatting Parameters (resolved). + """ + + ref: DescriptorPathStr = Field( + alias="$ref", + title="Enum reference", + description="The resolved internal path to the enum definition used to convert this value.", + ) + + +class ResolvedTokenTickerParameters(Model): + """ + Token Ticker Formatting Parameters (resolved). + """ + + chainId: int | None = Field( + default=None, + title="Chain ID", + description=( + "Optional. The resolved chain on which the token is deployed. " + "When present, the wallet SHOULD resolve the token ticker for this chain. " + "Useful for cross-chain swap clear signing." + ), + ) + + chainIdPath: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Chain ID Path", + description=( + "Optional. Path to the chain ID in the structured data. " + "When present, the wallet SHOULD resolve the token ticker for the chain at this path. " + "Useful for cross-chain swap clear signing." + ), + ) + + @model_validator(mode="after") + def _validate_chainid_mutually_exclusive(self) -> Self: + if self.chainId is not None and self.chainIdPath is not None: + raise ValueError('"chainId" and "chainIdPath" are mutually exclusive.') + return self + + +class ResolvedEncryptionParameters(Model): + """ + Encrypted Value Parameters (resolved). + """ + + scheme: str = Field( + title="Encryption Scheme", + description="The encryption scheme used to produce the handle.", + ) + + plaintextType: str | None = Field( + default=None, + title="Plaintext Type", + description="Solidity type of the decrypted value (the handle does not encode this).", + ) + + fallbackLabel: str | None = Field( + default=None, + title="Fallback Label", + description='Optional label to display when decryption is not possible. Defaults to "[Encrypted]".', + ) + + +ResolvedFieldParameters = Annotated[ + Annotated[ResolvedAddressNameParameters, Tag("address_name")] + | Annotated[ResolvedInteroperableAddressNameParameters, Tag("interoperable_address_name")] + | Annotated[ResolvedCallDataParameters, Tag("call_data")] + | Annotated[ResolvedTokenAmountParameters, Tag("token_amount")] + | Annotated[ResolvedTokenTickerParameters, Tag("token_ticker")] + | Annotated[ResolvedNftNameParameters, Tag("nft_name")] + | Annotated[ResolvedDateParameters, Tag("date")] + | Annotated[ResolvedUnitParameters, Tag("unit")] + | Annotated[ResolvedEnumParameters, Tag("enum")], + Discriminator(field_parameters_discriminator), +] + + +class ResolvedFieldDefinition(Model): + """ + A resolved field formatter, containing formatting information of a single field in a message. + """ + + id: Id | None = Field( + alias="$id", + default=None, + title="Id", + description="An internal identifier that can be used either for clarity specifying what the element is or as a " + "reference in device specific sections.", + ) + + label: str = Field( + title="Field Label", + description=("The resolved label of the field, displayed to the user in front of the formatted field value."), + ) + + format: FieldFormat | None = Field( + default=None, + title="Field Format", + description="The format of the field, that will be used to format the field value in a human readable way.", + ) + + params: ResolvedFieldParameters | None = Field( + default=None, + title="Format Parameters", + description=( + "Resolved format specific parameters that are used to format the field value in a human readable way." + ), + ) + + +class ResolvedFieldDescription(ResolvedFieldBase, ResolvedFieldDefinition): + """ + A resolved field formatter, containing formatting information of a single field in a message. + """ + + visible: ResolvedVisibilityRules | None = Field( + default=None, + title="Visibility Rules", + description=( + "Specifies when a field should be displayed based on its value or context. " + "Defaults to 'always' if not specified." + ), + ) + + separator: str | None = Field( + default=None, + title="Field Separator", + description="Optional separator for array values with {index} interpolation support.", + ) + + encryption: ResolvedEncryptionParameters | None = Field( + default=None, + title="Encryption Parameters", + description=( + "If present, the field value is encrypted. The format specifies how to display the decrypted value." + ), + ) + + +class ResolvedFieldGroup(Model): + """ + A resolved group of field formats, allowing recursivity in the schema and control over grouping and iteration. + + Used to group whole definitions for structures for instance. This allows nesting definitions of formats, but note + that support for deep nesting will be device dependent. + + Unlike ResolvedFieldBase, field groups do not require path or value (per v2 schema). The path is optional and + there is no value field — field groups scope their children under the given path, or act as logical groupings + when no path is provided. + """ + + id: Id | None = Field( + alias="$id", + default=None, + title="Id", + description="An internal identifier that can be used either for clarity specifying what the element is or as a " + "reference in device specific sections.", + ) + + path: DescriptorPathStr | DataPathStr | ContainerPathStr | None = Field( + default=None, + title="Path", + description="An optional resolved path to scope the field group under.", + ) + + label: str | None = Field( + default=None, + title="Group Label", + description="An optional resolved label for the field group.", + ) + + iteration: str | None = Field( + default=None, + title="Iteration Strategy", + description="Specifies how iteration over arrays should be handled: 'sequential' or 'bundled'.", + ) + + fields: list[ForwardRef("ResolvedField")] = Field( # type: ignore + title="Fields", description="Resolved group of field formats." + ) + + +ResolvedField = Annotated[ + Annotated[ResolvedFieldDescription, Tag("field_description")] | Annotated[ResolvedFieldGroup, Tag("field_group")], + Discriminator(field_discriminator), +] + + +class ResolvedFormat(FormatBase): + """ + A resolved structured data format specification containing formatting information of fields + in a single type of message (v2). + """ + + interpolatedIntent: str | None = Field( + default=None, + title="Interpolated Intent Message", + description=( + "An optional resolved intent string with embedded field values using {path} interpolation syntax. " + "This provides a dynamic, contextual description by embedding actual transaction or message " + "values directly in the intent string." + ), + ) + + fields: list[ResolvedField] = Field( + title="Field Formats set", + description="An array containing the ordered resolved definitions of fields formats.", + ) + + +class ResolvedDisplay(Model): + """ + Display Formatting Info Section (v2, resolved). + """ + + definitions: dict[str, ResolvedFieldDefinition] | None = Field( + default=None, + title="Common Formatter Definitions", + description=( + "A set of resolved definitions that can be used to share formatting information " + "between multiple messages or functions. All references have been inlined." + ), + ) + + formats: dict[str, ResolvedFormat] = Field( + title="List of field formats", + description=( + "The resolved list includes formatting info for each field of a structure. " + "This list is indexed by a key uniquely identifying the message type in the ABI. " + "For smart contracts, it is the selector or function signature, and for EIP-712 messages " + "it is the primaryType of the message." + ), + ) + + @model_validator(mode="after") + def _validate_definitions_resolved(self) -> Self: + if self.definitions is not None: + raise ValueError("Definitions must be None after resolution: all references should have been inlined.") + return self diff --git a/src/erc7730/model/resolved/v2/metadata.py b/src/erc7730/model/resolved/v2/metadata.py new file mode 100644 index 0000000..bc44f53 --- /dev/null +++ b/src/erc7730/model/resolved/v2/metadata.py @@ -0,0 +1,155 @@ +""" +Object model for ERC-7730 v2 resolved descriptors `metadata` section. + +Specification: https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs +JSON schema: https://github.com/LedgerHQ/clear-signing-erc7730-registry/blob/master/specs/erc7730-v2.schema.json +""" + +from datetime import UTC, datetime +from typing import Any + +from pydantic import Field +from pydantic_string_url import HttpUrl + +from erc7730.model.base import Model +from erc7730.model.metadata import TokenInfo +from erc7730.model.resolved.metadata import EnumDefinition +from erc7730.model.types import Id, ScalarType + +# ruff: noqa: N815 - camel case field names are tolerated to match schema + + +class ResolvedOwnerInfo(Model): + """ + Main contract's owner detailed information (v2, resolved). + + The owner info section contains detailed information about the owner or target of the contract / message to be + clear signed. + + Note: legalName and lastUpdate are v1 backward compatibility extensions not present in the v2 JSON schema. + The v2 schema only defines deploymentDate and url for info. These fields are retained for smooth migration. + """ + + legalName: str | None = Field( + None, + title="Owner Legal Name", + description="[v1 compat] The full legal name of the owner if different from the owner field. " + "Not present in v2 schema, retained for backward compatibility.", + min_length=1, + examples=["Tether Limited", "Lido DAO"], + ) + + lastUpdate: datetime | None = Field( + default=None, + title="[DEPRECATED] Last Update of the contract / message", + description="[v1 compat] The date of the last update of the contract / message. " + "Not present in v2 schema, retained for backward compatibility. Use `deploymentDate` instead.", + examples=[datetime.now(UTC)], + ) + + deploymentDate: datetime | None = Field( + default=None, + title="Deployment date of contract / message", + description="The date of deployment of the contract / message.", + examples=[datetime.now(UTC)], + ) + + url: HttpUrl = Field( + title="Owner URL", + description="URL with more info on the entity the user interacts with.", + examples=[HttpUrl("https://tether.to"), HttpUrl("https://lido.fi")], + ) + + +class ResolvedMapDefinition(Model): + """ + A resolved map definition for context-dependent constants. + + Maps are used to provide context-dependent values based on a key resolution. + """ + + keyType: str | None = Field( + None, + alias="$keyType", + title="Key Type", + description="The type of the key used for map resolution.", + ) + + values: dict[str, Any] = Field( + title="Map Values", + description="The resolved mapping of keys to values that will be used for dynamic resolution.", + min_length=1, + ) + + +class ResolvedMetadata(Model): + """ + Metadata Section (v2, resolved). + + The metadata section contains information about constant values relevant in the scope of the current contract / + message (as matched by the `context` section). All external references have been resolved. + """ + + owner: str | None = Field( + default=None, + title="Owner display name.", + description="The display name of the owner or target of the contract / message to be clear signed.", + ) + + contractName: str | None = Field( + default=None, + title="Contract Name", + description="The name of the contract targeted by the transaction or message.", + ) + + info: ResolvedOwnerInfo | None = Field( + default=None, + title="Main contract's owner detailed information.", + description="The owner info section contains detailed information about the owner or target of the contract / " + "message to be clear signed.", + ) + + token: TokenInfo | None = Field( + default=None, + title="Token Description", + description="A description of an ERC20 token exported by this format, that should be trusted. Not mandatory if " + "the corresponding metadata can be fetched from the contract itself.", + ) + + constants: dict[Id, ScalarType | None] | None = Field( + default=None, + title="Constant values", + description=( + "A set of resolved values that can be used in format parameters. All references have been resolved." + ), + examples=[ + { + "token_path": "#.params.witness.outputs[0].token", + "native_currency": "0x0000000000000000000000000000000000000001", + "max_threshold": "0xFFFFFFFF", + "max_message": "Max", + } + ], + ) + + enums: dict[Id, EnumDefinition] | None = Field( + default=None, + title="Enums", + description=( + "A set of resolved enums that are used to format fields by replacing values with human readable strings." + ), + examples=[{"interestRateMode": {"1": "stable", "2": "variable"}}], + max_length=32, # TODO refine + ) + + maps: dict[Id, ResolvedMapDefinition] | None = Field( + default=None, + title="Maps", + description=( + "A set of resolved maps that are used to manage context dependent constants. " + "All external references have been resolved." + ), + examples=[ + {"tokenMap": {"$keyType": "address", "values": {"0xa0b86a33e6441...": "USDT", "0x6b175474e89...": "DAI"}}} + ], + ) From 3c61cf7a6c3baf6c7d19c10d6ca4a25aaa91d80f Mon Sep 17 00:00:00 2001 From: Frederic Samier Date: Thu, 12 Feb 2026 18:06:49 +0100 Subject: [PATCH 03/15] feat: add v2 input to resolved converter --- src/erc7730/convert/resolved/v2/__init__.py | 1 + src/erc7730/convert/resolved/v2/constants.py | 232 ++++++++ .../v2/convert_erc7730_input_to_resolved.py | 544 ++++++++++++++++++ src/erc7730/convert/resolved/v2/enums.py | 43 ++ src/erc7730/convert/resolved/v2/parameters.py | 411 +++++++++++++ src/erc7730/convert/resolved/v2/references.py | 121 ++++ src/erc7730/convert/resolved/v2/values.py | 174 ++++++ 7 files changed, 1526 insertions(+) create mode 100644 src/erc7730/convert/resolved/v2/__init__.py create mode 100644 src/erc7730/convert/resolved/v2/constants.py create mode 100644 src/erc7730/convert/resolved/v2/convert_erc7730_input_to_resolved.py create mode 100644 src/erc7730/convert/resolved/v2/enums.py create mode 100644 src/erc7730/convert/resolved/v2/parameters.py create mode 100644 src/erc7730/convert/resolved/v2/references.py create mode 100644 src/erc7730/convert/resolved/v2/values.py diff --git a/src/erc7730/convert/resolved/v2/__init__.py b/src/erc7730/convert/resolved/v2/__init__.py new file mode 100644 index 0000000..481767b --- /dev/null +++ b/src/erc7730/convert/resolved/v2/__init__.py @@ -0,0 +1 @@ +"""ERC-7730 v2 input to resolved conversion helpers.""" diff --git a/src/erc7730/convert/resolved/v2/constants.py b/src/erc7730/convert/resolved/v2/constants.py new file mode 100644 index 0000000..7fdcbd3 --- /dev/null +++ b/src/erc7730/convert/resolved/v2/constants.py @@ -0,0 +1,232 @@ +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import Any, assert_never, override + +from pydantic import TypeAdapter, ValidationError +from typing_extensions import TypeVar + +from erc7730.common.output import OutputAdder +from erc7730.common.properties import get_property +from erc7730.model.input.path import ContainerPathStr, DataPathStr +from erc7730.model.input.v2.descriptor import InputERC7730Descriptor +from erc7730.model.input.v2.display import InputMapReference +from erc7730.model.paths import ROOT_DESCRIPTOR_PATH, ArrayElement, ContainerPath, DataPath, DescriptorPath, Field +from erc7730.model.paths.path_ops import descriptor_path_append, to_absolute +from erc7730.model.types import MixedCaseAddress + +_T = TypeVar("_T", covariant=True) + + +class ConstantProvider(ABC): + """ + Resolver for constants values referenced by descriptor paths. + """ + + @abstractmethod + def get(self, path: DescriptorPath, out: OutputAdder) -> Any: + """ + Get the constant for the given path. + + :param path: descriptor path + :param out: error handler + :return: constant value, or None if not found + """ + raise NotImplementedError() + + @abstractmethod + def resolve_map_reference(self, prefix: DataPath, map_ref: InputMapReference, out: OutputAdder) -> Any: + """ + Resolve a map reference to its value. + + :param prefix: current path prefix + :param map_ref: map reference to resolve + :param out: error handler + :return: resolved value from map, or None if not found + """ + raise NotImplementedError() + + def resolve(self, value: _T | DescriptorPath, out: OutputAdder) -> _T: + """ + Resolve the value if it is a descriptor path. + + :param value: descriptor path or actual value + :param out: error handler + :return: constant value, or the value itself if not a descriptor path + """ + return self.get(value, out) if isinstance(value, DescriptorPath) else value + + def resolve_or_none(self, value: _T | DescriptorPath | None, out: OutputAdder) -> _T | None: + """ + Resolve the optional value if it is a descriptor path. + + :param value: descriptor path, actual value or None + :param out: error handler + :return: None, constant value, or the value itself if not a descriptor path + """ + return None if value is None else self.resolve(value, out) + + def resolve_path( + self, value: DataPath | ContainerPath | DescriptorPath, out: OutputAdder + ) -> DataPath | ContainerPath | None: + """ + Resolve the value as a data/container path. + + :param value: descriptor path or actual data/container path + :param out: error handler + :return: resolved data/container path + """ + + def assert_not_address(path: DataPath | ContainerPath) -> bool: + match path: + case ContainerPath(): + return True + case DataPath(): + if path.absolute: + return True + try: + TypeAdapter(MixedCaseAddress).validate_strings(str(path)) + out.error( + title="Invalid data path", + message=f""""{path}" is invalid, it must contain a data path to the address in the """ + "transaction data. It seems you are trying to use a constant address value instead, please " + "use the adequate parameter to provide a constant value.", + ) + return False + except ValidationError: + return True + case _: + assert_never(path) + + if isinstance(value, DataPath | ContainerPath): + if not assert_not_address(value): + return None + return value + + resolved_value: Any + if (resolved_value := self.resolve(value, out)) is None: + return None + + if not isinstance(resolved_value, str): + return out.error( + title="Invalid constant path", + message=f"Constant path defined at {value} must be a path string, got {type(resolved_value).__name__}.", + ) + + match TypeAdapter(DataPathStr | ContainerPathStr).validate_strings(resolved_value): + case ContainerPath() as path: + return path + case DataPath() as path: + if not assert_not_address(path): + return None + if not path.absolute: + return out.error( + title="Invalid data path constant", + message=f"Data path defined at {value} must be absolute, please change it to " + f"{to_absolute(path)}.", + ) + return path + case _: + assert_never(resolved_value) + + # noinspection PyUnreachableCode + return None + + def resolve_path_or_none( + self, value: DataPath | ContainerPath | DescriptorPath | None, out: OutputAdder + ) -> DataPath | ContainerPath | None: + """ + Resolve the value as a data/container path. + + :param value: descriptor path or actual data/container path + :param out: error handler + :return: resolved data/container path + """ + return None if value is None else self.resolve_path(value, out) + + +class DefaultConstantProvider(ConstantProvider): + """ + Resolver for constants values from a provided v2 descriptor. + """ + + def __init__(self, descriptor: InputERC7730Descriptor) -> None: + self.descriptor: InputERC7730Descriptor = descriptor + + @override + def get(self, path: DescriptorPath, out: OutputAdder) -> Any: + current_target = self.descriptor + parent_path = ROOT_DESCRIPTOR_PATH + current_path = ROOT_DESCRIPTOR_PATH + + for element in path.elements: + current_path = descriptor_path_append(current_path, element) + match element: + case Field(identifier=field): + if isinstance(current_target, Sequence): + return out.error( + title="Invalid constant path", + message=f"""Path {current_path} is invalid, {parent_path} is an array.""", + ) + else: + try: + current_target = get_property(current_target, field) + except (AttributeError, KeyError): + return out.error( + title="Invalid constant path", + message=f"""Path {current_path} is invalid, {parent_path} has no "{field}" field.""", + ) + case ArrayElement(index=i): + if not isinstance(current_target, Sequence): + return out.error( + title="Invalid constant path", + message=f"Path {current_path} is invalid, {parent_path} is not an array.", + ) + if i >= len(current_target): + return out.error( + title="Invalid constant path", + message=f"""Path {current_path} is invalid, index {i} is out of bounds.""", + ) + current_target = current_target[i] + case _: + assert_never(element) + parent_path = descriptor_path_append(parent_path, element) + + return current_target + + @override + def resolve_map_reference(self, prefix: DataPath, map_ref: InputMapReference, out: OutputAdder) -> Any: + """ + Resolve a map reference to its value by looking up the map and resolving the keyPath. + + :param prefix: current path prefix + :param map_ref: map reference with map descriptor path and keyPath + :param out: error handler + :return: resolved value from map, or None if not found + """ + # Get the map definition + if (map_def := self.get(map_ref.map, out)) is None: + return out.error( + title="Invalid map reference", + message=f"Map at {map_ref.map} does not exist.", + ) + + # Ensure map has the expected structure + if not hasattr(map_def, "values") or not isinstance(map_def.values, dict): + return out.error( + title="Invalid map reference", + message=f"Map at {map_ref.map} is not a valid map definition.", + ) + + # Resolve the key path to get the key value + if (self.resolve_path(map_ref.keyPath, out)) is None: + return None + + # For map references, the key path is either a DataPath or ContainerPath + # We can't actually resolve the runtime value here during conversion, + # so we store the path for runtime resolution. However, for constant validation + # we could check if it's a constant path. + # Since this is input-to-resolved conversion, we pass through the structure. + # The actual key lookup happens at display time, not conversion time. + + # Return the map reference as-is for the resolved model to handle at runtime + return map_ref diff --git a/src/erc7730/convert/resolved/v2/convert_erc7730_input_to_resolved.py b/src/erc7730/convert/resolved/v2/convert_erc7730_input_to_resolved.py new file mode 100644 index 0000000..5feb55e --- /dev/null +++ b/src/erc7730/convert/resolved/v2/convert_erc7730_input_to_resolved.py @@ -0,0 +1,544 @@ +""" +Converter for ERC-7730 v2 input descriptors to resolved form. + +This module provides conversion from input v2 descriptors to resolved v2 descriptors. +""" + +from typing import Any, assert_never, final, override + +from pydantic_string_url import HttpUrl + +from erc7730.common import client +from erc7730.common.abi import reduce_signature, signature_to_selector +from erc7730.common.output import ExceptionsToOutput, OutputAdder +from erc7730.convert import ERC7730Converter +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 resolve_reference +from erc7730.convert.resolved.v2.values import resolve_field_value +from erc7730.model.input.v2.context import ( + InputContract, + InputContractContext, + InputDeployment, + InputDomain, + InputEIP712, + InputEIP712Context, + InputFactory, +) +from erc7730.model.input.v2.descriptor import InputERC7730Descriptor +from erc7730.model.input.v2.display import ( + InputDisplay, + InputField, + InputFieldDefinition, + InputFieldDescription, + InputFieldGroup, + InputFormat, + InputReference, +) +from erc7730.model.input.v2.format import FieldFormat +from erc7730.model.input.v2.metadata import InputMetadata +from erc7730.model.paths import ROOT_DATA_PATH, Array, ArrayElement, ArraySlice, ContainerPath, DataPath, Field +from erc7730.model.paths.path_ops import data_path_concat +from erc7730.model.resolved.display import ResolvedValueConstant, ResolvedValuePath +from erc7730.model.resolved.metadata import EnumDefinition +from erc7730.model.resolved.v2.context import ( + ResolvedContract, + ResolvedContractContext, + ResolvedDeployment, + ResolvedDomain, + ResolvedEIP712, + ResolvedEIP712Context, + ResolvedFactory, +) +from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor +from erc7730.model.resolved.v2.display import ( + ResolvedDisplay, + ResolvedField, + ResolvedFieldDescription, + ResolvedFieldGroup, + ResolvedFormat, +) +from erc7730.model.resolved.v2.metadata import ResolvedMapDefinition, ResolvedMetadata, ResolvedOwnerInfo +from erc7730.model.types import Address, Id, Selector + + +@final +class ERC7730InputToResolved(ERC7730Converter[InputERC7730Descriptor, ResolvedERC7730Descriptor]): + """ + Converts ERC-7730 v2 descriptor input to resolved form. + + After conversion, the descriptor is in resolved form: + - URLs have been fetched (deprecated ABI and schemas fields are ignored) + - Contract addresses have been normalized to lowercase + - References have been inlined + - Constants have been inlined + - Field definitions have been inlined + - Field groups have been processed + - Selectors have been converted to 4 bytes form + - Maps have been resolved + """ + + @override + def convert(self, descriptor: InputERC7730Descriptor, out: OutputAdder) -> ResolvedERC7730Descriptor | None: + with ExceptionsToOutput(out): + constants = DefaultConstantProvider(descriptor) + + if (context := self._resolve_context(descriptor.context, out)) is None: + return None + if (metadata := self._resolve_metadata(descriptor.metadata, out)) is None: + return None + if (display := self._resolve_display(descriptor.display, context, metadata.enums, constants, out)) is None: + return None + + return ResolvedERC7730Descriptor.model_validate( + { + "$schema": descriptor.schema_, + "$comment": descriptor.comment, + "context": context, + "metadata": metadata, + "display": display, + } + ) + + # noinspection PyUnreachableCode + return None + + @classmethod + def _resolve_context( + cls, context: InputContractContext | InputEIP712Context, out: OutputAdder + ) -> ResolvedContractContext | ResolvedEIP712Context | None: + match context: + case InputContractContext(): + return cls._resolve_context_contract(context, out) + case InputEIP712Context(): + return cls._resolve_context_eip712(context, out) + case _: + assert_never(context) + + @classmethod + def _resolve_metadata(cls, metadata: InputMetadata, out: OutputAdder) -> ResolvedMetadata | None: + resolved_enums = {} + if metadata.enums is not None: + for enum_id, enum in metadata.enums.items(): + if (resolved_enum := cls._resolve_enum(enum, out)) is not None: + resolved_enums[enum_id] = resolved_enum + + resolved_maps = {} + if metadata.maps is not None: + for map_id, map_def in metadata.maps.items(): + resolved_maps[map_id] = ResolvedMapDefinition.model_validate( + {"$keyType": map_def.keyType, "values": map_def.values} + ) + + # Convert InputOwnerInfo to ResolvedOwnerInfo if present + resolved_info = None + if metadata.info is not None: + resolved_info = ResolvedOwnerInfo( + legalName=metadata.info.legalName, + lastUpdate=metadata.info.lastUpdate, + deploymentDate=metadata.info.deploymentDate, + url=metadata.info.url, + ) + + return ResolvedMetadata( + owner=metadata.owner, + contractName=metadata.contractName, + info=resolved_info, + token=metadata.token, + constants=metadata.constants, + enums=resolved_enums or None, + maps=resolved_maps or None, + ) + + @classmethod + def _resolve_enum(cls, enum: HttpUrl | EnumDefinition, out: OutputAdder) -> dict[str, str] | None: + match enum: + case HttpUrl() as url: + try: + return client.get(url=url, model=EnumDefinition) + except Exception as e: + return out.error( + title="Failed to fetch enum definition from URL", + message=f'Failed to fetch enum definition from URL "{url}": {e}', + ) + case dict(): + return enum + case _: + assert_never(enum) + + @classmethod + def _resolve_context_contract( + cls, context: InputContractContext, out: OutputAdder + ) -> ResolvedContractContext | None: + if (contract := cls._resolve_contract(context.contract, out)) is None: + return None + + return ResolvedContractContext.model_validate({"$id": context.id, "contract": contract}) + + @classmethod + def _resolve_contract(cls, contract: InputContract, out: OutputAdder) -> ResolvedContract | None: + # Note: In v2, ABI field is deprecated and ignored during resolution + if (deployments := cls._resolve_deployments(contract.deployments, out)) is None: + return None + + if contract.factory is None: + factory = None + elif (factory := cls._resolve_factory(contract.factory, out)) is None: + return None + + return ResolvedContract( + deployments=deployments, + addressMatcher=str(contract.addressMatcher) if contract.addressMatcher is not None else None, + factory=factory, + ) + + @classmethod + def _resolve_deployments( + cls, deployments: list[InputDeployment], out: OutputAdder + ) -> list[ResolvedDeployment] | None: + resolved_deployments = [] + for deployment in deployments: + if (resolved_deployment := cls._resolve_deployment(deployment, out)) is not None: + resolved_deployments.append(resolved_deployment) + return resolved_deployments + + @classmethod + def _resolve_deployment(cls, deployment: InputDeployment, out: OutputAdder) -> ResolvedDeployment | None: + return ResolvedDeployment(chainId=deployment.chainId, address=Address(deployment.address)) + + @classmethod + def _resolve_factory(cls, factory: InputFactory, out: OutputAdder) -> ResolvedFactory | None: + if (deployments := cls._resolve_deployments(factory.deployments, out)) is None: + return None + + return ResolvedFactory(deployments=deployments, deployEvent=factory.deployEvent) + + @classmethod + def _resolve_context_eip712(cls, context: InputEIP712Context, out: OutputAdder) -> ResolvedEIP712Context | None: + if (eip712 := cls._resolve_eip712(context.eip712, out)) is None: + return None + + return ResolvedEIP712Context.model_validate({"$id": context.id, "eip712": eip712}) + + @classmethod + def _resolve_eip712(cls, eip712: InputEIP712, out: OutputAdder) -> ResolvedEIP712 | None: + if eip712.domain is None: + domain = None + elif (domain := cls._resolve_domain(eip712.domain, out)) is None: + return None + + # Note: In v2, schemas field is deprecated and ignored during resolution + if (deployments := cls._resolve_deployments(eip712.deployments, out)) is None: + return None + + return ResolvedEIP712( + domain=domain, + domainSeparator=eip712.domainSeparator, + deployments=deployments, + ) + + @classmethod + def _resolve_domain(cls, domain: InputDomain, out: OutputAdder) -> ResolvedDomain | None: + return ResolvedDomain( + name=domain.name, + version=domain.version, + chainId=domain.chainId, + verifyingContract=None if domain.verifyingContract is None else Address(domain.verifyingContract), + ) + + @classmethod + def _resolve_display( + cls, + display: InputDisplay, + context: ResolvedContractContext | ResolvedEIP712Context, + enums: dict[Id, EnumDefinition] | None, + constants: ConstantProvider, + out: OutputAdder, + ) -> ResolvedDisplay | None: + definitions = display.definitions or {} + enums = enums or {} + formats = {} + for format_id, format in display.formats.items(): + if (resolved_format_id := cls._resolve_format_id(format_id, context, out)) is None: + return None + if (resolved_format := cls._resolve_format(format, definitions, enums, constants, out)) is None: + return None + if resolved_format_id in formats: + return out.error( + title="Duplicate format", + message=f"Descriptor contains 2 formats sections for {resolved_format_id}", + ) + formats[resolved_format_id] = resolved_format + + return ResolvedDisplay(definitions=None, formats=formats) + + @classmethod + def _resolve_field_description( + cls, + prefix: DataPath, + definition: InputFieldDescription, + enums: dict[Id, EnumDefinition], + constants: ConstantProvider, + out: OutputAdder, + ) -> ResolvedFieldDescription | None: + match definition.format: + case None | FieldFormat.RAW | FieldFormat.AMOUNT | FieldFormat.TOKEN_AMOUNT | FieldFormat.DURATION: + pass + case ( + FieldFormat.ADDRESS_NAME + | FieldFormat.INTEROPERABLE_ADDRESS_NAME + | FieldFormat.TOKEN_TICKER + | FieldFormat.CALL_DATA + | FieldFormat.NFT_NAME + | FieldFormat.DATE + | FieldFormat.UNIT + | FieldFormat.ENUM + ): + if definition.params is None: + return out.error( + title="Missing parameters", + message=f"""Field format "{definition.format.value}" requires parameters to be defined, """ + f"""they are missing for field "{definition.path}".""", + ) + case FieldFormat.CHAIN_ID: + pass + case _: + assert_never(definition.format) + + params = resolve_field_parameters(prefix, definition.params, enums, constants, out) + + if (value_or_path := resolve_field_value(prefix, definition, definition.format, constants, out)) is None: + return None + + # Convert InputEncryptionParameters to ResolvedEncryptionParameters if present + resolved_encryption = None + if definition.encryption is not None: + from erc7730.model.resolved.v2.display import ResolvedEncryptionParameters + + resolved_encryption = ResolvedEncryptionParameters( + scheme=definition.encryption.scheme, + plaintextType=definition.encryption.plaintextType, + fallbackLabel=definition.encryption.fallbackLabel, + ) + + # Convert InputVisibilityConditions to resolved dict for discriminator compatibility + resolved_visible: str | dict[str, Any] | None + if definition.visible is not None and not isinstance(definition.visible, str): + from erc7730.model.input.v2.display import InputVisibilityConditions + + if isinstance(definition.visible, InputVisibilityConditions): + visibility_dict: dict[str, Any] = {} + if definition.visible.ifNotIn is not None: + visibility_dict["ifNotIn"] = definition.visible.ifNotIn + if definition.visible.mustBe is not None: + visibility_dict["mustBe"] = definition.visible.mustBe + resolved_visible = visibility_dict + else: + resolved_visible = None + else: + resolved_visible = definition.visible + + # In v2, value_or_path is a ResolvedValue (ResolvedValuePath | ResolvedValueConstant) + # Convert to v2's simpler path/value model + # Convert params/encryption to dicts so discriminated unions work properly + params_dict = params.model_dump(by_alias=True, exclude_none=True) if params is not None else None + encryption_dict = ( + resolved_encryption.model_dump(by_alias=True, exclude_none=True) + if resolved_encryption is not None + else None + ) + + field_dict: dict[str, Any] = { + "$id": definition.id, + "visible": resolved_visible, + "label": constants.resolve(definition.label, out), + "format": FieldFormat(definition.format) if definition.format is not None else None, + "params": params_dict, + "separator": definition.separator, + "encryption": encryption_dict, + } + + # Set either path or value based on the ResolvedValue type + if isinstance(value_or_path, ResolvedValuePath): + field_dict["path"] = str(value_or_path.path) + field_dict["value"] = None + elif isinstance(value_or_path, ResolvedValueConstant): + field_dict["path"] = None + field_dict["value"] = value_or_path.value + else: + return out.error( + title="Invalid value type", + message=f"Unexpected value type: {type(value_or_path)}", + ) + + return ResolvedFieldDescription.model_validate(field_dict) + + @classmethod + def _resolve_format_id( + cls, + format_id: str, + context: ResolvedContractContext | ResolvedEIP712Context, + out: OutputAdder, + ) -> str | Selector | None: + match context: + case ResolvedContractContext(): + if format_id.startswith("0x"): + return Selector(format_id) + + if (reduced_signature := reduce_signature(format_id)) is not None: + return Selector(signature_to_selector(reduced_signature)) + + return out.error( + title="Invalid selector", + message=f""""{format_id}" is not a valid function signature or selector.""", + ) + case ResolvedEIP712Context(): + return format_id + case _: + assert_never(context) + + @classmethod + def _resolve_format( + cls, + format: InputFormat, + definitions: dict[Id, InputFieldDefinition], + enums: dict[Id, EnumDefinition], + constants: ConstantProvider, + out: OutputAdder, + ) -> ResolvedFormat | None: + if (fields := cls._resolve_fields(ROOT_DATA_PATH, format.fields, definitions, enums, constants, out)) is None: + return None + + return ResolvedFormat.model_validate( + { + "$id": format.id, + "intent": format.intent, + "interpolatedIntent": format.interpolatedIntent, + "fields": [f.model_dump(by_alias=True, exclude_none=True) for f in fields], + } + ) + + @classmethod + def _resolve_fields( + cls, + prefix: DataPath, + fields: list[InputField], + definitions: dict[Id, InputFieldDefinition], + enums: dict[Id, EnumDefinition], + constants: ConstantProvider, + out: OutputAdder, + ) -> list[ResolvedField] | None: + resolved_fields = [] + for input_format in fields: + if (resolved_field := cls._resolve_field(prefix, input_format, definitions, enums, constants, out)) is None: + return None + resolved_fields.extend(resolved_field) + return resolved_fields + + @classmethod + def _resolve_field( + cls, + prefix: DataPath, + field: InputField, + definitions: dict[Id, InputFieldDefinition], + enums: dict[Id, EnumDefinition], + constants: ConstantProvider, + out: OutputAdder, + ) -> list[ResolvedField] | None: + resolved_fields: list[ResolvedField] = [] + match field: + case InputReference(): + if (resolved_field := resolve_reference(prefix, field, definitions, enums, constants, out)) is None: + return None + resolved_fields.append(resolved_field) + case InputFieldDescription(): + if (resolved_field := cls._resolve_field_description(prefix, field, enums, constants, out)) is None: + return None + resolved_fields.append(resolved_field) + case InputFieldGroup(): + if ( + resolved_field_group := cls._resolve_field_group(prefix, field, definitions, enums, constants, out) + ) is None: + return None + resolved_fields.extend(resolved_field_group) + case _: + assert_never(field) + return resolved_fields + + @classmethod + def _resolve_field_group( + cls, + prefix: DataPath, + group: InputFieldGroup, + definitions: dict[Id, InputFieldDefinition], + enums: dict[Id, EnumDefinition], + constants: ConstantProvider, + out: OutputAdder, + ) -> list[ResolvedFieldGroup | ResolvedFieldDescription] | None: + if group.path is None: + # No path = logical grouping only, resolve fields with current prefix + if ( + resolved_fields := cls._resolve_fields( + prefix=prefix, + fields=group.fields, + definitions=definitions, + enums=enums, + constants=constants, + out=out, + ) + ) is None: + return None + return [ + ResolvedFieldGroup.model_validate( + { + "$id": group.id, + "label": group.label, + "iteration": group.iteration, + "fields": [f.model_dump(by_alias=True, exclude_none=True) for f in resolved_fields], + } + ) + ] + + path: DataPath + match constants.resolve_path(group.path, out): + case None: + return None + case DataPath() as data_path: + path = data_path_concat(prefix, data_path) + case ContainerPath() as container_path: + return out.error( + title="Invalid path type", + message=f"Container path {container_path} cannot be used with field groups.", + ) + case _: + assert_never(group.path) + + if ( + resolved_fields := cls._resolve_fields( + prefix=path, fields=group.fields, definitions=definitions, enums=enums, constants=constants, out=out + ) + ) is None: + return None + + match path.elements[-1]: + case Field() | ArrayElement(): + return resolved_fields + case ArraySlice(): + return out.error( + title="Invalid field group", + message="Using field groups on an array slice is not allowed.", + ) + case Array(): + return [ + ResolvedFieldGroup.model_validate( + { + "$id": group.id, + "path": str(path), + "label": group.label, + "iteration": group.iteration, + "fields": [f.model_dump(by_alias=True, exclude_none=True) for f in resolved_fields], + } + ) + ] + case _: + assert_never(path.elements[-1]) diff --git a/src/erc7730/convert/resolved/v2/enums.py b/src/erc7730/convert/resolved/v2/enums.py new file mode 100644 index 0000000..f81da13 --- /dev/null +++ b/src/erc7730/convert/resolved/v2/enums.py @@ -0,0 +1,43 @@ +from erc7730.common.output import OutputAdder +from erc7730.model.paths import DescriptorPath, Field +from erc7730.model.paths.path_ops import descriptor_path_strip_prefix +from erc7730.model.resolved.metadata import EnumDefinition +from erc7730.model.types import Id + +ENUMS_PATH = DescriptorPath(elements=[Field(identifier="metadata"), Field(identifier="enums")]) + + +def get_enum(ref: DescriptorPath, enums: dict[Id, EnumDefinition], out: OutputAdder) -> dict[str, str] | None: + if (enum_id := get_enum_id(ref, out)) is None: + return None + + if (enum := enums.get(enum_id)) is None: + return out.error( + title="Invalid enum reference", + message=f"""Enum "{enum_id}" does not exist, valid ones are: """ f"{', '.join(enums.keys())}.", + ) + return enum + + +def get_enum_id(path: DescriptorPath, out: OutputAdder) -> str | None: + try: + tail = descriptor_path_strip_prefix(path, ENUMS_PATH) + except ValueError: + return out.error( + title="Invalid enum reference path", + message=f"Enums must be defined at {ENUMS_PATH}, {path} is not a valid enum reference.", + ) + if len(tail.elements) != 1: + return out.error( + title="Invalid enum reference path", + message=f"Enums must be defined directly under {ENUMS_PATH}, deep nesting is not allowed, {path} is not a " + f"valid enum reference.", + ) + if not isinstance(element := tail.elements[0], Field): + return out.error( + title="Invalid enum reference path", + message=f"Enums must be defined at {ENUMS_PATH}, array operators are not allowed, {path} is not a valid " + f"enum reference.", + ) + + return element.identifier diff --git a/src/erc7730/convert/resolved/v2/parameters.py b/src/erc7730/convert/resolved/v2/parameters.py new file mode 100644 index 0000000..8989bf5 --- /dev/null +++ b/src/erc7730/convert/resolved/v2/parameters.py @@ -0,0 +1,411 @@ +from typing import Any, assert_never, cast + +from erc7730.common.abi import ABIDataType +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.model.input.path import ContainerPathStr, DataPathStr, DescriptorPathStr +from erc7730.model.input.v2.display import ( + InputAddressNameParameters, + InputCallDataParameters, + InputDateParameters, + InputEncryptionParameters, + InputEnumParameters, + InputFieldParameters, + InputInteroperableAddressNameParameters, + InputMapReference, + InputNftNameParameters, + InputTokenAmountParameters, + InputTokenTickerParameters, + InputUnitParameters, +) +from erc7730.model.paths import DataPath +from erc7730.model.resolved.display import ResolvedValueConstant, ResolvedValuePath +from erc7730.model.resolved.metadata import EnumDefinition +from erc7730.model.resolved.v2.display import ( + ResolvedAddressNameParameters, + ResolvedCallDataParameters, + ResolvedDateParameters, + ResolvedEncryptionParameters, + ResolvedEnumParameters, + ResolvedFieldParameters, + ResolvedInteroperableAddressNameParameters, + ResolvedNftNameParameters, + ResolvedTokenAmountParameters, + ResolvedTokenTickerParameters, + ResolvedUnitParameters, +) +from erc7730.model.types import Address, HexStr, Id, MixedCaseAddress + + +def resolve_field_parameters( + prefix: DataPath, + params: InputFieldParameters | None, + enums: dict[Id, EnumDefinition], + constants: ConstantProvider, + out: OutputAdder, +) -> ResolvedFieldParameters | None: + match params: + case None: + return None + case InputAddressNameParameters(): + return resolve_address_name_parameters(prefix, params, constants, out) + case InputInteroperableAddressNameParameters(): + return resolve_interoperable_address_name_parameters(prefix, params, constants, out) + case InputCallDataParameters(): + return resolve_calldata_parameters(prefix, params, constants, out) + case InputTokenAmountParameters(): + return resolve_token_amount_parameters(prefix, params, constants, out) + case InputTokenTickerParameters(): + return resolve_token_ticker_parameters(prefix, params, constants, out) + case InputNftNameParameters(): + return resolve_nft_parameters(prefix, params, constants, out) + case InputDateParameters(): + return resolve_date_parameters(prefix, params, constants, out) + case InputUnitParameters(): + return resolve_unit_parameters(prefix, params, constants, out) + case InputEnumParameters(): + return resolve_enum_parameters(prefix, params, enums, constants, out) + case _: + assert_never(params) + + +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") + + return ResolvedAddressNameParameters( + types=constants.resolve_or_none(params.types, out), + sources=constants.resolve_or_none(params.sources, out), + senderAddress=sender_address, + ) + + +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") + + return ResolvedInteroperableAddressNameParameters( + types=constants.resolve_or_none(params.types, out), + sources=constants.resolve_or_none(params.sources, out), + senderAddress=sender_address, + ) + + +def resolve_calldata_parameters( + prefix: DataPath, params: InputCallDataParameters, constants: ConstantProvider, out: OutputAdder +) -> ResolvedCallDataParameters | None: + # Helper to split a ResolvedValue into (path, value) for the calldata model + def _split_resolved( + resolved: ResolvedValuePath | ResolvedValueConstant | None, + ) -> tuple[str | None, Any]: + if resolved is None: + return None, None + if isinstance(resolved, ResolvedValuePath): + return str(resolved.path), None + if isinstance(resolved, ResolvedValueConstant): + return None, resolved.value + return None, None + + # Resolve callee - can be path, constant, or map reference + callee_path: DescriptorPathStr | DataPathStr | ContainerPathStr | None = params.calleePath + callee_value: Address | None = 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.", + ) + elif params.callee is not None or params.calleePath is not None: + 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 resolved is None: + return None + callee_path, callee_value = _split_resolved(resolved) # type: ignore[assignment] + + # Resolve selector + selector_path: DescriptorPathStr | DataPathStr | ContainerPathStr | None = params.selectorPath + selector_value: str | None = 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: + resolved = resolve_path_or_constant_value( + prefix=prefix, + input_path=params.selectorPath, + input_value=params.selector, + abi_type=ABIDataType.STRING, + constants=constants, + out=out, + ) + if resolved is not None: + selector_path, selector_value = _split_resolved(resolved) # type: ignore[assignment] + + # Resolve amount + amount_path: DescriptorPathStr | DataPathStr | ContainerPathStr | None = params.amountPath + amount_value: int | None = 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: + resolved = resolve_path_or_constant_value( + prefix=prefix, + input_path=params.amountPath, + input_value=params.amount, + abi_type=ABIDataType.UINT, + constants=constants, + out=out, + ) + if resolved is not None: + amount_path, amount_value = _split_resolved(resolved) # type: ignore[assignment] + + # Resolve spender + spender_path: DescriptorPathStr | DataPathStr | ContainerPathStr | None = params.spenderPath + spender_value: Address | None = 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: + resolved = resolve_path_or_constant_value( + prefix=prefix, + input_path=params.spenderPath, + input_value=params.spender, + abi_type=ABIDataType.ADDRESS, + constants=constants, + out=out, + ) + if resolved is not None: + spender_path, spender_value = _split_resolved(resolved) # type: ignore[assignment] + + return ResolvedCallDataParameters( + calleePath=callee_path, + callee=callee_value, + selectorPath=selector_path, + selector=selector_value, + amountPath=amount_path, + amount=amount_value, + spenderPath=spender_path, + spender=spender_value, + ) + + +def resolve_token_amount_parameters( + prefix: DataPath, params: InputTokenAmountParameters, constants: ConstantProvider, out: OutputAdder +) -> ResolvedTokenAmountParameters | None: + # Resolve token - can be path, constant, or map reference + token_value = params.token + resolved_token: Address | None = None + if token_value is not None and isinstance(token_value, InputMapReference): + # Map reference - store as-is for runtime resolution + # For now, we set to None since resolved model expects Address + 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, + ) + if isinstance(token_resolved, ResolvedValueConstant): + resolved_token = Address(str(token_resolved.value)) + + input_addresses = cast( + list[DescriptorPathStr | MixedCaseAddress] | MixedCaseAddress | None, + constants.resolve_or_none(params.nativeCurrencyAddress, out), + ) + resolved_addresses: list[Address] | None + if input_addresses is None: + resolved_addresses = None + elif isinstance(input_addresses, list): + resolved_addresses = [] + for input_address in input_addresses: + if (resolved_address := constants.resolve(input_address, out)) is None: + return None + resolved_addresses.append(Address(resolved_address)) + elif isinstance(input_addresses, str): + resolved_addresses = [Address(input_addresses)] + else: + raise Exception("Invalid nativeCurrencyAddress type") + + input_threshold = cast(HexStr | int | None, constants.resolve_or_none(params.threshold, out)) + resolved_threshold: HexStr | None + if input_threshold is not None: + if isinstance(input_threshold, int): + resolved_threshold = "0x" + input_threshold.to_bytes(byteorder="big", signed=False).hex() + else: + resolved_threshold = input_threshold + 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 + + return ResolvedTokenAmountParameters( + tokenPath=params.tokenPath, + token=resolved_token, + nativeCurrencyAddress=resolved_addresses, + threshold=resolved_threshold, + message=constants.resolve_or_none(params.message, out), + chainId=resolved_chain_id, + chainIdPath=params.chainIdPath, + ) + + +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 + + return ResolvedTokenTickerParameters( + chainId=resolved_chain_id, + chainIdPath=params.chainIdPath, + ) + + +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 + resolved_collection: Address | None = None + 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.", + ) + else: + collection_resolved = resolve_path_or_constant_value( + prefix=prefix, + input_path=params.collectionPath, + input_value=collection_value, + abi_type=ABIDataType.ADDRESS, + constants=constants, + out=out, + ) + if collection_resolved is None: + return None + if isinstance(collection_resolved, ResolvedValueConstant): + resolved_collection = Address(str(collection_resolved.value)) + + return ResolvedNftNameParameters( + collectionPath=params.collectionPath, + collection=resolved_collection, + ) + + +def resolve_date_parameters( + prefix: DataPath, params: InputDateParameters, constants: ConstantProvider, out: OutputAdder +) -> ResolvedDateParameters | None: + return ResolvedDateParameters(encoding=constants.resolve(params.encoding, out)) + + +def resolve_unit_parameters( + prefix: DataPath, params: InputUnitParameters, constants: ConstantProvider, out: OutputAdder +) -> ResolvedUnitParameters | None: + return ResolvedUnitParameters( + base=constants.resolve(params.base, out), + decimals=constants.resolve_or_none(params.decimals, out), + prefix=constants.resolve_or_none(params.prefix, out), + ) + + +def resolve_enum_parameters( + prefix: DataPath, + params: InputEnumParameters, + enums: dict[Id, EnumDefinition], + constants: ConstantProvider, + out: OutputAdder, +) -> ResolvedEnumParameters | None: + if get_enum_id(params.ref, out) is None: + return None + if get_enum(params.ref, enums, out) is None: + return None + + return ResolvedEnumParameters.model_validate({"$ref": str(params.ref)}) + + +def resolve_encryption_parameters( + prefix: DataPath, params: InputEncryptionParameters, constants: ConstantProvider, out: OutputAdder +) -> ResolvedEncryptionParameters | None: + # Encryption parameters are passed through as-is + return ResolvedEncryptionParameters( + scheme=params.scheme, + plaintextType=params.plaintextType, + fallbackLabel=params.fallbackLabel, + ) diff --git a/src/erc7730/convert/resolved/v2/references.py b/src/erc7730/convert/resolved/v2/references.py new file mode 100644 index 0000000..43d5364 --- /dev/null +++ b/src/erc7730/convert/resolved/v2/references.py @@ -0,0 +1,121 @@ +import json +from typing import Any + +from pydantic import TypeAdapter + +from erc7730.common.output import OutputAdder +from erc7730.common.pydantic import model_to_json_str +from erc7730.convert.resolved.v2.constants import ConstantProvider +from erc7730.convert.resolved.v2.parameters import resolve_field_parameters +from erc7730.convert.resolved.v2.values import resolve_field_value +from erc7730.model.input.v2.display import ( + InputFieldDefinition, + InputFieldParameters, + InputReference, +) +from erc7730.model.input.v2.format import FieldFormat +from erc7730.model.paths import DataPath, DescriptorPath, Field +from erc7730.model.paths.path_ops import descriptor_path_strip_prefix +from erc7730.model.resolved.display import ResolvedValueConstant, ResolvedValuePath +from erc7730.model.resolved.metadata import EnumDefinition +from erc7730.model.resolved.v2.display import ( + ResolvedField, + ResolvedFieldDescription, + ResolvedFieldParameters, +) +from erc7730.model.types import Id + +DEFINITIONS_PATH = DescriptorPath(elements=[Field(identifier="display"), Field(identifier="definitions")]) + + +def resolve_reference( + prefix: DataPath, + reference: InputReference, + definitions: dict[Id, InputFieldDefinition], + enums: dict[Id, EnumDefinition], + constants: ConstantProvider, + out: OutputAdder, +) -> ResolvedField | None: + if (definition := _get_definition(reference.ref, definitions, out)) is None: + return None + + if (label := definition.label) is None: + return out.error( + title="Missing display field label", + message=f"Label must be defined on the referenced display field definition {reference.ref}.", + ) + + params: dict[str, Any] = {} + if (definition_params := definition.params) is not None: + params.update(json.loads(model_to_json_str(definition_params))) + if (reference_params := reference.params) is not None: + params.update(reference_params) + + resolved_params: ResolvedFieldParameters | None = None + + if params: + input_params: InputFieldParameters = TypeAdapter(InputFieldParameters).validate_json(json.dumps(params)) + if (resolved_params := resolve_field_parameters(prefix, input_params, enums, constants, out)) is None: + return None + + if (value_or_path := resolve_field_value(prefix, reference, definition.format, constants, out)) is None: + return None + + # Build field dict for model_validate to handle aliases and discriminated unions + field_dict: dict[str, Any] = { + "label": str(constants.resolve(label, out)), + "format": FieldFormat(definition.format) if definition.format is not None else None, + } + + if resolved_params is not None: + field_dict["params"] = resolved_params.model_dump(by_alias=True, exclude_none=True) + + # Set either path or value based on the ResolvedValue type + if isinstance(value_or_path, ResolvedValuePath): + field_dict["path"] = str(value_or_path.path) + elif isinstance(value_or_path, ResolvedValueConstant): + field_dict["value"] = value_or_path.value + + return ResolvedFieldDescription.model_validate(field_dict) + + +def _get_definition( + ref: DescriptorPath, definitions: dict[Id, InputFieldDefinition], out: OutputAdder +) -> InputFieldDefinition | None: + if (definition_id := _get_definition_id(ref, out)) is None: + return None + + if (definition := definitions.get(definition_id)) is None: + return out.error( + title="Invalid display definition reference", + message=f"""Display definition "{definition_id}" does not exist, valid ones are: """ + f"{', '.join(definitions.keys())}.", + ) + return definition + + +def _get_definition_id(ref: DescriptorPath, out: OutputAdder) -> Id | None: + try: + tail = descriptor_path_strip_prefix(ref, DEFINITIONS_PATH) + except ValueError: + return out.error( + title="Invalid definition reference path", + message=f"References to display field definitions are restricted to {DEFINITIONS_PATH}, {ref} " + f"cannot be used as a field definition reference.", + ) + if len(tail.elements) != 1: + return out.error( + title="Invalid definition reference path", + message=f"References to display field definitions are restricted to fields immediately under " + f"{DEFINITIONS_PATH}, deep nesting is not allowed, {ref} cannot be used as a field " + f"definition reference.", + ) + if not isinstance(element := tail.elements[0], Field): + return out.error( + title="Invalid definition reference path", + message=f"References to display field definitions are restricted to fields immediately under " + f"{DEFINITIONS_PATH}, array operators are not allowed, {ref} cannot be used as a field " + f"definition reference.", + ) + + return element.identifier diff --git a/src/erc7730/convert/resolved/v2/values.py b/src/erc7730/convert/resolved/v2/values.py new file mode 100644 index 0000000..4877a7f --- /dev/null +++ b/src/erc7730/convert/resolved/v2/values.py @@ -0,0 +1,174 @@ +from typing import 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.format import FieldFormat +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, ResolvedValueConstant, ResolvedValuePath +from erc7730.model.types import HexStr, ScalarType + + +def resolve_field_value( + prefix: DataPath, + input_field: InputFieldBase, + input_field_format: FieldFormat | None, + constants: ConstantProvider, + out: OutputAdder, +) -> ResolvedValue | None: + """ + Resolve value, as a data path or constant value, for a field or reference. + + :param prefix: current path prefix + :param input_field: field description or definition + :param input_field_format: input field format + :param constants: descriptor paths constants resolver + :param out: error handler + :return: resolved value or None if error + """ + match input_field_format: + case None | FieldFormat.RAW: + abi_type = ABIDataType.STRING + case ( + FieldFormat.AMOUNT + | FieldFormat.TOKEN_AMOUNT + | FieldFormat.DURATION + | FieldFormat.DATE + | FieldFormat.UNIT + | FieldFormat.NFT_NAME + | FieldFormat.ENUM + ): + abi_type = ABIDataType.UINT + case FieldFormat.ADDRESS_NAME | FieldFormat.INTEROPERABLE_ADDRESS_NAME: + abi_type = ABIDataType.ADDRESS + case FieldFormat.CALL_DATA: + abi_type = ABIDataType.BYTES + case FieldFormat.TOKEN_TICKER: + abi_type = ABIDataType.ADDRESS + case FieldFormat.CHAIN_ID: + abi_type = ABIDataType.UINT + case _: + assert_never(input_field_format) + + if ( + value := resolve_path_or_constant_value( + prefix=prefix, + input_path=input_field.path, + input_value=input_field.value, + abi_type=abi_type, + constants=constants, + out=out, + ) + ) is None: + return out.error(title="Invalid field", message="Field must have either a path or a value.") + return value + + +def resolve_path_or_constant_value( + prefix: DataPath, + input_path: DescriptorPath | DataPath | ContainerPath | None, + input_value: DescriptorPath | ScalarType | None, + abi_type: ABIDataType, + constants: ConstantProvider, + out: OutputAdder, +) -> ResolvedValue | None: + """ + Resolve value, as a data path or constant value. + + :param prefix: current path prefix + :param input_path: input data path, if provided + :param input_value: input constant value, 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 input_path is not None: + if input_value is not None: + return out.error( + title="Invalid field", + message="Field cannot have both a path and a value.", + ) + + if (path := constants.resolve_path(input_path, out)) is None: + return None + + return ResolvedValuePath(path=data_or_container_path_concat(prefix, path)) + + if input_value is not None: + if (value := constants.resolve(input_value, out)) is None: + return None + + if not isinstance(value, str | bool | int | float): + return out.error( + title="Invalid constant value", + message="Constant value 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) + + 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) + except ValidationError: + return out.error( + title="Invalid hex string", + message=f""""{value}" is not a valid hexadecimal string.""", + ) + + # this uses a custom specific encoding because this is what the Ledger app expects + try: + match abi_type: + case ABIDataType.UFIXED | ABIDataType.FIXED: + return out.error(title="Invalid constant", message="""Fixed precision numbers are not supported""") + + case ABIDataType.UINT: + if not isinstance(value, int) or value < 0: + return out.error(title="Invalid constant", message=f"""Value "{value}" is not an unsigned int""") + encoded = value.to_bytes(length=(max(value.bit_length(), 1) + 7) // 8, signed=False) + + case ABIDataType.INT: + if not isinstance(value, int): + return out.error(title="Invalid constant", message=f"""Value "{value}" is not an integer""") + encoded = value.to_bytes(length=(max(value.bit_length(), 1) + 7) // 8, signed=True) + + case ABIDataType.BOOL: + if not isinstance(value, bool): + return out.error(title="Invalid constant", message=f"""Value "{value}" is not a boolean""") + encoded = value.to_bytes() + + case ABIDataType.STRING: + if not isinstance(value, str): + return out.error(title="Invalid constant", message=f"""Value "{value}" is not a string""") + encoded = value.encode(encoding="ascii", errors="replace") + + case ABIDataType.ADDRESS: + return out.error( + title="Invalid constant", message=f"""Value "{value}" is not a valid address hexadecimal string.""" + ) + + case ABIDataType.BYTES: + return out.error( + title="Invalid constant", message=f"""Value "{value}" is not a valid hexadecimal string.""" + ) + + case _: + assert_never(abi_type) + except OverflowError: + return out.error( + title="Invalid constant", + message=f"""Value "{value}" is too large for the specified type.""", + ) + + return HexStr("0x" + encoded.hex()) From 0d34a3acaabd3df706f2c6dd04c52c49ddd7ede8 Mon Sep 17 00:00:00 2001 From: Frederic Samier Date: Thu, 12 Feb 2026 18:07:00 +0100 Subject: [PATCH 04/15] feat: implement v2 linter --- src/erc7730/lint/v2/__init__.py | 31 +++++ src/erc7730/lint/v2/lint.py | 90 ++++++++++++ .../v2/lint_transaction_type_classifier.py | 120 ++++++++++++++++ .../lint/v2/lint_validate_display_fields.py | 127 +++++++++++++++++ .../lint/v2/lint_validate_max_length.py | 131 ++++++++++++++++++ src/erc7730/lint/v2/path_schemas.py | 92 ++++++++++++ 6 files changed, 591 insertions(+) create mode 100644 src/erc7730/lint/v2/__init__.py create mode 100644 src/erc7730/lint/v2/lint.py create mode 100644 src/erc7730/lint/v2/lint_transaction_type_classifier.py create mode 100644 src/erc7730/lint/v2/lint_validate_display_fields.py create mode 100644 src/erc7730/lint/v2/lint_validate_max_length.py create mode 100644 src/erc7730/lint/v2/path_schemas.py diff --git a/src/erc7730/lint/v2/__init__.py b/src/erc7730/lint/v2/__init__.py new file mode 100644 index 0000000..af80328 --- /dev/null +++ b/src/erc7730/lint/v2/__init__.py @@ -0,0 +1,31 @@ +from abc import ABC, abstractmethod +from typing import final, override + +from erc7730.common.output import OutputAdder +from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor + + +class ERC7730Linter(ABC): + """ + Linter for ERC-7730 v2 descriptors, inspects a (structurally valid) resolved v2 descriptor and emits notes, + warnings, or errors. + + A linter may emit false positives or false negatives. It is up to the user to interpret the output. + """ + + @abstractmethod + def lint(self, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: + raise NotImplementedError() + + +@final +class MultiLinter(ERC7730Linter): + """A linter that runs multiple v2 linters in sequence.""" + + def __init__(self, linters: list[ERC7730Linter]): + self.linters = linters + + @override + def lint(self, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: + for linter in self.linters: + linter.lint(descriptor, out) diff --git a/src/erc7730/lint/v2/lint.py b/src/erc7730/lint/v2/lint.py new file mode 100644 index 0000000..5f0cd63 --- /dev/null +++ b/src/erc7730/lint/v2/lint.py @@ -0,0 +1,90 @@ +import os +from concurrent.futures.thread import ThreadPoolExecutor +from pathlib import Path + +from rich import print + +from erc7730.common.output import ( + AddFileOutputAdder, + BufferAdder, + ConsoleOutputAdder, + DropFileOutputAdder, + ExceptionsToOutput, + GithubAnnotationsAdder, + OutputAdder, +) +from erc7730.convert.resolved.v2.convert_erc7730_input_to_resolved import ERC7730InputToResolved +from erc7730.lint.v2 import ERC7730Linter, MultiLinter +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_max_length import ValidateMaxLengthLinter +from erc7730.list.list import get_erc7730_files +from erc7730.model.input.v2.descriptor import InputERC7730Descriptor + + +def lint_all_and_print_errors(paths: list[Path], gha: bool = False) -> bool: + """Lint all ERC-7730 v2 descriptor files at given paths and print results.""" + out = GithubAnnotationsAdder() if gha else DropFileOutputAdder(delegate=ConsoleOutputAdder()) + + count = lint_all(paths, out) + + if out.has_errors: + print(f"[bold][red]checked {count} v2 descriptor files, some errors found ❌[/red][/bold]") + return False + + if out.has_warnings: + print(f"[bold][yellow]checked {count} v2 descriptor files, some warnings found ⚠️[/yellow][/bold]") + return True + + print(f"[bold][green]checked {count} v2 descriptor files, no errors found ✅[/green][/bold]") + return True + + +def lint_all(paths: list[Path], out: OutputAdder) -> int: + """ + Lint all ERC-7730 v2 descriptor files at given paths. + + Paths can be files or directories, in which case all JSON files in the directory are recursively linted. + + :param paths: paths to apply linter on + :param out: output adder + :return: number of files checked + """ + linter = MultiLinter([ValidateDisplayFieldsLinter(), ClassifyTransactionTypeLinter(), ValidateMaxLengthLinter()]) + + files = list(get_erc7730_files(*paths, out=out)) + + if len(files) <= 1 or not (root_path := os.path.commonpath(files)): + root_path = None + + def label(f: Path) -> Path | None: + return f.relative_to(root_path) if root_path is not None else None + + if len(files) > 1: + print(f"🔍 checking {len(files)} v2 descriptor files…\n") + + with ThreadPoolExecutor() as executor: + for future in (executor.submit(lint_file, file, linter, out, label(file)) for file in files): + future.result() + + return len(files) + + +def lint_file(path: Path, linter: ERC7730Linter, out: OutputAdder, show_as: Path | None = None) -> None: + """ + Lint a single ERC-7730 v2 descriptor file. + + :param path: ERC-7730 v2 descriptor file path + :param show_as: if provided, print this label instead of the file path + :param linter: v2 linter instance + :param out: error handler + """ + + label = path if show_as is None else show_as + file_out = AddFileOutputAdder(delegate=out, file=path) + + with BufferAdder(file_out, prolog=f"➡️ checking [bold]{label}[/bold]…", epilog="") as out, ExceptionsToOutput(out): + input_descriptor = InputERC7730Descriptor.load(path) + resolved_descriptor = ERC7730InputToResolved().convert(input_descriptor, out) + if resolved_descriptor is not None: + linter.lint(resolved_descriptor, out) diff --git a/src/erc7730/lint/v2/lint_transaction_type_classifier.py b/src/erc7730/lint/v2/lint_transaction_type_classifier.py new file mode 100644 index 0000000..06d1191 --- /dev/null +++ b/src/erc7730/lint/v2/lint_transaction_type_classifier.py @@ -0,0 +1,120 @@ +""" +V2 linter that classifies transaction types and validates expected display fields. + +In v2, classification relies on: + - For EIP-712 context: the format key (primaryType) — e.g., "Permit*" → PERMIT + - For contract context: the fetched Etherscan ABI (via ABIClassifier, currently unimplemented) +""" + +from typing import final, override + +from erc7730.common import client +from erc7730.common.output import OutputAdder +from erc7730.lint.classifier import TxClass +from erc7730.lint.classifier.abi_classifier import ABIClassifier +from erc7730.lint.v2 import ERC7730Linter +from erc7730.model.resolved.v2.context import ResolvedContractContext, ResolvedEIP712Context +from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor +from erc7730.model.resolved.v2.display import ResolvedDisplay, ResolvedField, ResolvedFieldDescription, ResolvedFormat + + +@final +class ClassifyTransactionTypeLinter(ERC7730Linter): + """ + Classifies transaction type from context/format and validates expected display fields. + + For EIP-712: classifies by format key (primaryType). If "permit" found in format key, classifies as PERMIT. + For contract: classifies from fetched Etherscan ABI using ABIClassifier. + """ + + @override + def lint(self, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: + if (tx_class := self._determine_tx_class(descriptor)) is None: + return None + DisplayFormatChecker(tx_class, descriptor.display).check(out) + + @classmethod + def _determine_tx_class(cls, descriptor: ResolvedERC7730Descriptor) -> TxClass | None: + match descriptor.context: + case ResolvedEIP712Context(): + # In v2, no schemas — classify from format keys (primaryType) + for format_key in descriptor.display.formats: + if "permit" in format_key.lower(): + return TxClass.PERMIT + return None + case ResolvedContractContext(): + # Try to classify from fetched ABI + return cls._classify_from_fetched_abi(descriptor.context) + + @classmethod + def _classify_from_fetched_abi(cls, context: ResolvedContractContext) -> TxClass | None: + if (deployments := context.contract.deployments) is None: + return None + for deployment in deployments: + try: + if (abis := client.get_contract_abis(deployment.chainId, deployment.address)) is not None: + return ABIClassifier().classify(list(abis)) + except Exception: # nosec B112 - intentional: try next deployment on failure + continue + return None + + +class DisplayFormatChecker: + """Given a transaction class and v2 display formats, check if all the required fields of a given + transaction class are being displayed. + """ + + def __init__(self, tx_class: TxClass, display: ResolvedDisplay): + self.tx_class = tx_class + self.display = display + + def check(self, out: OutputAdder) -> None: + match self.tx_class: + case TxClass.PERMIT: + fields = self._get_all_displayed_fields(self.display.formats) + if not self._fields_contain("spender", fields): + out.warning( + title="Expected display field missing", + message="Contract detected as Permit but no spender field displayed", + ) + if not self._fields_contain("amount", fields): + out.warning( + title="Expected display field missing", + message="Contract detected as Permit but no amount field displayed", + ) + if ( + not self._fields_contain("valid until", fields) + and not self._fields_contain("expiry", fields) + and not self._fields_contain("expiration", fields) + and not self._fields_contain("deadline", fields) + ): + out.warning( + title="Expected display field missing", + message="Contract detected as Permit but no expiration field displayed", + ) + case _: + pass + + @classmethod + def _get_all_displayed_fields(cls, formats: dict[str, ResolvedFormat]) -> set[str]: + fields: set[str] = set() + for fmt in formats.values(): + for field in fmt.fields: + cls._collect_field_labels(field, fields) + return fields + + @classmethod + def _collect_field_labels(cls, field: ResolvedField, labels: set[str]) -> None: + match field: + case ResolvedFieldDescription(): + labels.add(field.label) + case _: + # ResolvedFieldGroup — recurse into sub-fields + if hasattr(field, "fields"): + for sub_field in field.fields: + cls._collect_field_labels(sub_field, labels) + + @classmethod + def _fields_contain(cls, word: str, fields: set[str]) -> bool: + """Check if the provided keyword is contained in one of the fields (case insensitive).""" + return any(word.lower() in field.lower() for field in fields) diff --git a/src/erc7730/lint/v2/lint_validate_display_fields.py b/src/erc7730/lint/v2/lint_validate_display_fields.py new file mode 100644 index 0000000..7518d7e --- /dev/null +++ b/src/erc7730/lint/v2/lint_validate_display_fields.py @@ -0,0 +1,127 @@ +""" +V2 linter that validates display fields against reference ABIs fetched from Etherscan. + +In v2, ABI and EIP-712 schemas are NOT embedded in the descriptor. Instead: + - For contract context: fetch ABI from Etherscan, validate display field paths match ABI params, + and check selector exhaustiveness. + - For EIP-712 context: no schema to validate against (no-op). +""" + +from typing import final, override + +from erc7730.common import client +from erc7730.common.abi import compute_signature, get_functions +from erc7730.common.output import OutputAdder +from erc7730.lint.v2 import ERC7730Linter +from erc7730.lint.v2.path_schemas import compute_format_schema_paths +from erc7730.model.paths import DataPath +from erc7730.model.paths.path_schemas import compute_abi_schema_paths +from erc7730.model.resolved.v2.context import ResolvedContractContext, ResolvedEIP712Context +from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor + + +@final +class ValidateDisplayFieldsLinter(ERC7730Linter): + """ + Validates display fields against reference ABIs fetched from Etherscan. + + For contract context: + - Fetches ABI from Etherscan for each deployment + - Validates that display field paths exist in the ABI + - Validates that all ABI function params have display fields + - Checks that all selectors in the ABI have corresponding display formats + + For EIP-712 context: + - No schema available in v2 resolved model, so no validation is performed + """ + + @override + def lint(self, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: + match descriptor.context: + case ResolvedEIP712Context(): + pass # no schema to validate against in v2 + case ResolvedContractContext(): + self._validate_contract_display_fields(descriptor, out) + + @classmethod + def _validate_contract_display_fields(cls, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: + context = descriptor.context + if not isinstance(context, ResolvedContractContext): + return + + if (deployments := context.contract.deployments) is None: + return + + # Try to fetch ABI from Etherscan for the first deployment that succeeds + reference_abis = None + explorer_url = None + for deployment in deployments: + try: + if (abis := client.get_contract_abis(deployment.chainId, deployment.address)) is None: + continue + except Exception as e: + out.warning( + title="Could not fetch ABI", + message=f"Fetching reference ABI for chain id {deployment.chainId} failed, display fields will " + f"not be validated against ABI: {e}", + ) + continue + + reference_abis = get_functions(abis) + try: + explorer_url = client.get_contract_explorer_url(deployment.chainId, deployment.address) + except NotImplementedError: + explorer_url = f"" + break + + if reference_abis is None: + return + + if reference_abis.proxy: + return out.info( + title="Proxy contract", + message=f"Contract {explorer_url} is likely to be a proxy, validation of display fields skipped", + ) + + # Build ABI paths by selector + abi_paths_by_selector: dict[str, set[DataPath]] = {} + for selector, abi in reference_abis.functions.items(): + abi_paths_by_selector[selector] = compute_abi_schema_paths(abi) + + # Validate display field paths against ABI paths + for selector, fmt in descriptor.display.formats.items(): + if selector not in abi_paths_by_selector: + out.warning( + title="Unknown selector", + message=f"Selector {selector} in display formats not found in reference ABI (see {explorer_url}). " + f"This could indicate a custom function or a stale descriptor.", + ) + continue + + format_paths = compute_format_schema_paths(fmt) + abi_paths = abi_paths_by_selector[selector] + + # Check for display fields referencing non-existent ABI paths + for path in format_paths.data_paths - abi_paths: + out.error( + title="Invalid display field", + message=f"A display field is defined for `{path}`, but it does not exist in function " + f"{selector} ABI (see {explorer_url}). Please check the field path is valid.", + ) + + # Check for ABI paths without corresponding display fields + for path in abi_paths - format_paths.data_paths: + out.warning( + title="Missing display field", + message=f"No display field is defined for path `{path}` in function {selector} " + f"(see {explorer_url}).", + ) + + # Check selector exhaustiveness: all ABI functions should have display formats + for selector, abi in reference_abis.functions.items(): + if selector not in descriptor.display.formats: + out.warning( + title="Missing display format", + message=f"Function {compute_signature(abi)} (selector: {selector}) exists in reference ABI " + f"(see {explorer_url}) but has no display format defined in the descriptor.", + ) diff --git a/src/erc7730/lint/v2/lint_validate_max_length.py b/src/erc7730/lint/v2/lint_validate_max_length.py new file mode 100644 index 0000000..4335828 --- /dev/null +++ b/src/erc7730/lint/v2/lint_validate_max_length.py @@ -0,0 +1,131 @@ +""" +V2 linter that validates string lengths against Ledger device display limits. + +Adapted from v1 ValidateMaxLengthLinter to use v2 model types. +""" + +from typing import final, override + +from erc7730.common.ledger import ( + CONTRACT_NAME_MAX_LENGTH, + CREATOR_LEGAL_NAME_MAX_LENGTH, + CREATOR_NAME_MAX_LENGTH, + CREATOR_URL_MAX_LENGTH, + ENUM_MAX_LENGTH, + FIELD_NAME_MAX_LENGTH, + OPERATION_TYPE_MAX_LENGTH, +) +from erc7730.common.output import OutputAdder +from erc7730.lint.v2 import ERC7730Linter +from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor +from erc7730.model.resolved.v2.display import ResolvedField, ResolvedFieldDescription, ResolvedFieldGroup + + +@final +class ValidateMaxLengthLinter(ERC7730Linter): + """ + Validates max length of metadata fields, display fields, and enums for Ledger device display limits. + """ + + @override + def lint(self, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: + self._validate_metadata_lengths(descriptor, out) + self._validate_display_lengths(descriptor, out) + self._validate_enum_lengths(descriptor, out) + + @classmethod + def _validate_metadata_lengths(cls, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: + if descriptor.metadata.owner is not None and len(descriptor.metadata.owner) > CREATOR_NAME_MAX_LENGTH: + out.warning( + title="Owner too long", + message=f"Owner `{descriptor.metadata.owner}` exceeds {CREATOR_NAME_MAX_LENGTH}" + " characters and may be truncated on Ledger devices.", + ) + + if descriptor.metadata.info is not None: + if ( + descriptor.metadata.info.legalName is not None + and len(descriptor.metadata.info.legalName) > CREATOR_LEGAL_NAME_MAX_LENGTH + ): + out.warning( + title="Legal name too long", + message=f"Legal name `{descriptor.metadata.info.legalName}` exceeds " + f"{CREATOR_LEGAL_NAME_MAX_LENGTH} characters and may be truncated on Ledger devices.", + ) + if descriptor.metadata.info.url is not None and len(descriptor.metadata.info.url) > CREATOR_URL_MAX_LENGTH: + out.warning( + title="URL too long", + message=f"URL `{descriptor.metadata.info.url}` exceeds " + f"{CREATOR_URL_MAX_LENGTH} characters and may be truncated on Ledger devices.", + ) + + if descriptor.context.id is not None and len(descriptor.context.id) > CONTRACT_NAME_MAX_LENGTH: + out.warning( + title="Contract id too long", + message=f"Contract id `{descriptor.context.id}` exceeds " + f"{CONTRACT_NAME_MAX_LENGTH} characters and may be truncated on Ledger devices.", + ) + + @classmethod + def _validate_display_lengths(cls, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: + too_long_intents: set[str] = set() + too_long_ids: set[str] = set() + too_long_labels: set[str] = set() + + for fmt in descriptor.display.formats.values(): + if fmt.intent is not None and isinstance(fmt.intent, str) and len(fmt.intent) > OPERATION_TYPE_MAX_LENGTH: + too_long_intents.add(fmt.intent) + if fmt.interpolatedIntent is not None and len(fmt.interpolatedIntent) > OPERATION_TYPE_MAX_LENGTH: + too_long_intents.add(fmt.interpolatedIntent) + if fmt.id is not None and len(fmt.id) > OPERATION_TYPE_MAX_LENGTH: + too_long_ids.add(fmt.id) + + for field in fmt.fields: + cls._collect_long_labels(field, too_long_labels) + + if too_long_intents: + out.warning( + title="Display intent too long", + message=f"Display intent(s) `{', '.join(too_long_intents)}` exceed " + f"{OPERATION_TYPE_MAX_LENGTH} characters and may be truncated on Ledger devices.", + ) + if too_long_ids: + out.warning( + title="Display id too long", + message=f"Display id(s) `{', '.join(too_long_ids)}` exceed " + f"{OPERATION_TYPE_MAX_LENGTH} characters and may be truncated on Ledger devices.", + ) + if too_long_labels: + out.warning( + title="Display label too long", + message=f"Display label(s) `{', '.join(too_long_labels)}` exceed " + f"{FIELD_NAME_MAX_LENGTH} characters and may be truncated on Ledger devices.", + ) + + @classmethod + def _collect_long_labels(cls, field: ResolvedField, too_long: set[str]) -> None: + match field: + case ResolvedFieldDescription(): + if len(field.label) > FIELD_NAME_MAX_LENGTH: + too_long.add(field.label) + case ResolvedFieldGroup(): + if field.label is not None and len(field.label) > FIELD_NAME_MAX_LENGTH: + too_long.add(field.label) + for sub_field in field.fields: + cls._collect_long_labels(sub_field, too_long) + + @classmethod + def _validate_enum_lengths(cls, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: + too_long_enums: set[str] = set() + if descriptor.metadata.enums is not None: + for enum in descriptor.metadata.enums.values(): + for enum_entry in enum.values(): + if len(enum_entry) > ENUM_MAX_LENGTH: + too_long_enums.add(enum_entry) + + if too_long_enums: + out.warning( + title="Enum entry too long", + message=f"Enum entry(s) `{', '.join(too_long_enums)}` exceed " + f"{ENUM_MAX_LENGTH} characters and may be truncated on Ledger devices.", + ) diff --git a/src/erc7730/lint/v2/path_schemas.py b/src/erc7730/lint/v2/path_schemas.py new file mode 100644 index 0000000..ff3ea3b --- /dev/null +++ b/src/erc7730/lint/v2/path_schemas.py @@ -0,0 +1,92 @@ +""" +Utilities for computing schema paths from v2 resolved display formats. + +These functions extract the set of data paths referenced by a v2 resolved format, which can then be compared against +ABI-derived paths for validation. +""" + +from typing import assert_never + +from erc7730.model.paths import ContainerPath, DataPath, DescriptorPath +from erc7730.model.paths.path_schemas import FormatPaths, data_path_to_schema_path +from erc7730.model.resolved.v2.display import ( + ResolvedAddressNameParameters, + ResolvedCallDataParameters, + ResolvedDateParameters, + ResolvedEnumParameters, + ResolvedField, + ResolvedFieldDescription, + ResolvedFieldGroup, + ResolvedFormat, + ResolvedInteroperableAddressNameParameters, + ResolvedNftNameParameters, + ResolvedTokenAmountParameters, + ResolvedTokenTickerParameters, + ResolvedUnitParameters, +) + + +def compute_format_schema_paths(fmt: ResolvedFormat) -> FormatPaths: + """ + Compute the sets of schema paths referred in a v2 ERC7730 Format section. + + :param fmt: resolved v2 $.display.format section + :return: schema paths used by field formats + """ + data_paths: set[DataPath] = set() + container_paths: set[ContainerPath] = set() + + def add_path(path: DescriptorPath | DataPath | ContainerPath | None) -> None: + if path is None: + return + match path: + case ContainerPath(): + container_paths.add(path) + case DataPath(): + data_paths.add(data_path_to_schema_path(path)) + case DescriptorPath(): + pass # descriptor paths are not schema paths + + def append_field(field: ResolvedField) -> None: + match field: + case ResolvedFieldDescription(): + # Add the field's own path + if field.path is not None: + add_path(field.path) + + # Add paths from parameters + match field.params: + case None: + pass + case ResolvedAddressNameParameters(): + pass + case ResolvedInteroperableAddressNameParameters(): + pass + case ResolvedCallDataParameters(): + pass + case ResolvedTokenAmountParameters(): + if field.params.tokenPath is not None: + add_path(field.params.tokenPath) + case ResolvedTokenTickerParameters(): + pass + case ResolvedNftNameParameters(): + if field.params.collectionPath is not None: + add_path(field.params.collectionPath) + case ResolvedDateParameters(): + pass + case ResolvedUnitParameters(): + pass + case ResolvedEnumParameters(): + pass + case _: + assert_never(field.params) + case ResolvedFieldGroup(): + for sub_field in field.fields: + append_field(sub_field) + case _: + assert_never(field) + + for field in fmt.fields: + append_field(field) + + return FormatPaths(data_paths=data_paths, container_paths=container_paths) From ab37c5b55a8e98685e46ffa7b1d91e54cf26b92b Mon Sep 17 00:00:00 2001 From: Frederic Samier Date: Thu, 12 Feb 2026 18:07:10 +0100 Subject: [PATCH 05/15] test: add v2 converter tests --- tests/v2/__init__.py | 0 tests/v2/convert/__init__.py | 0 tests/v2/convert/resolved/__init__.py | 0 .../calldata_with_extended_params_input.json | 36 +++++ ...alldata_with_extended_params_resolved.json | 36 +++++ .../data/deprecated_abi_ignored_input.json | 46 ++++++ .../data/deprecated_abi_ignored_resolved.json | 30 ++++ .../deprecated_schemas_ignored_input.json | 38 +++++ .../deprecated_schemas_ignored_resolved.json | 30 ++++ .../data/field_group_basic_input.json | 39 +++++ .../data/field_group_basic_resolved.json | 39 +++++ .../field_group_with_iteration_input.json | 36 +++++ .../field_group_with_iteration_resolved.json | 36 +++++ .../data/field_group_with_label_input.json | 36 +++++ .../data/field_group_with_label_resolved.json | 36 +++++ .../data/field_with_encryption_input.json | 35 +++++ .../data/field_with_encryption_resolved.json | 35 +++++ .../data/field_with_separator_input.json | 31 ++++ .../data/field_with_separator_resolved.json | 31 ++++ ...ield_with_visibility_conditions_input.json | 35 +++++ ...d_with_visibility_conditions_resolved.json | 35 +++++ .../field_with_visibility_simple_input.json | 31 ++++ ...field_with_visibility_simple_resolved.json | 31 ++++ .../resolved/data/format_chain_id_input.json | 31 ++++ .../data/format_chain_id_resolved.json | 31 ++++ ...rmat_interoperable_address_name_input.json | 41 +++++ ...t_interoperable_address_name_resolved.json | 41 +++++ .../data/format_token_ticker_input.json | 34 +++++ .../data/format_token_ticker_resolved.json | 34 +++++ ...format_with_interpolated_intent_input.json | 35 +++++ ...mat_with_interpolated_intent_resolved.json | 35 +++++ .../metadata_with_contract_name_input.json | 26 ++++ .../metadata_with_contract_name_resolved.json | 26 ++++ .../data/metadata_with_maps_input.json | 39 +++++ .../data/metadata_with_maps_resolved.json | 39 +++++ .../data/minimal_contract_v2_input.json | 35 +++++ .../data/minimal_contract_v2_resolved.json | 35 +++++ .../data/minimal_eip712_v2_input.json | 35 +++++ .../data/minimal_eip712_v2_resolved.json | 35 +++++ .../data/token_amount_with_chainid_input.json | 35 +++++ .../token_amount_with_chainid_resolved.json | 35 +++++ .../test_convert_input_to_resolved.py | 142 ++++++++++++++++++ 42 files changed, 1466 insertions(+) create mode 100644 tests/v2/__init__.py create mode 100644 tests/v2/convert/__init__.py create mode 100644 tests/v2/convert/resolved/__init__.py create mode 100644 tests/v2/convert/resolved/data/calldata_with_extended_params_input.json create mode 100644 tests/v2/convert/resolved/data/calldata_with_extended_params_resolved.json create mode 100644 tests/v2/convert/resolved/data/deprecated_abi_ignored_input.json create mode 100644 tests/v2/convert/resolved/data/deprecated_abi_ignored_resolved.json create mode 100644 tests/v2/convert/resolved/data/deprecated_schemas_ignored_input.json create mode 100644 tests/v2/convert/resolved/data/deprecated_schemas_ignored_resolved.json create mode 100644 tests/v2/convert/resolved/data/field_group_basic_input.json create mode 100644 tests/v2/convert/resolved/data/field_group_basic_resolved.json create mode 100644 tests/v2/convert/resolved/data/field_group_with_iteration_input.json create mode 100644 tests/v2/convert/resolved/data/field_group_with_iteration_resolved.json create mode 100644 tests/v2/convert/resolved/data/field_group_with_label_input.json create mode 100644 tests/v2/convert/resolved/data/field_group_with_label_resolved.json create mode 100644 tests/v2/convert/resolved/data/field_with_encryption_input.json create mode 100644 tests/v2/convert/resolved/data/field_with_encryption_resolved.json create mode 100644 tests/v2/convert/resolved/data/field_with_separator_input.json create mode 100644 tests/v2/convert/resolved/data/field_with_separator_resolved.json create mode 100644 tests/v2/convert/resolved/data/field_with_visibility_conditions_input.json create mode 100644 tests/v2/convert/resolved/data/field_with_visibility_conditions_resolved.json create mode 100644 tests/v2/convert/resolved/data/field_with_visibility_simple_input.json create mode 100644 tests/v2/convert/resolved/data/field_with_visibility_simple_resolved.json create mode 100644 tests/v2/convert/resolved/data/format_chain_id_input.json create mode 100644 tests/v2/convert/resolved/data/format_chain_id_resolved.json create mode 100644 tests/v2/convert/resolved/data/format_interoperable_address_name_input.json create mode 100644 tests/v2/convert/resolved/data/format_interoperable_address_name_resolved.json create mode 100644 tests/v2/convert/resolved/data/format_token_ticker_input.json create mode 100644 tests/v2/convert/resolved/data/format_token_ticker_resolved.json create mode 100644 tests/v2/convert/resolved/data/format_with_interpolated_intent_input.json create mode 100644 tests/v2/convert/resolved/data/format_with_interpolated_intent_resolved.json create mode 100644 tests/v2/convert/resolved/data/metadata_with_contract_name_input.json create mode 100644 tests/v2/convert/resolved/data/metadata_with_contract_name_resolved.json create mode 100644 tests/v2/convert/resolved/data/metadata_with_maps_input.json create mode 100644 tests/v2/convert/resolved/data/metadata_with_maps_resolved.json create mode 100644 tests/v2/convert/resolved/data/minimal_contract_v2_input.json create mode 100644 tests/v2/convert/resolved/data/minimal_contract_v2_resolved.json create mode 100644 tests/v2/convert/resolved/data/minimal_eip712_v2_input.json create mode 100644 tests/v2/convert/resolved/data/minimal_eip712_v2_resolved.json create mode 100644 tests/v2/convert/resolved/data/token_amount_with_chainid_input.json create mode 100644 tests/v2/convert/resolved/data/token_amount_with_chainid_resolved.json create mode 100644 tests/v2/convert/resolved/test_convert_input_to_resolved.py diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/v2/convert/__init__.py b/tests/v2/convert/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/v2/convert/resolved/__init__.py b/tests/v2/convert/resolved/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/v2/convert/resolved/data/calldata_with_extended_params_input.json b/tests/v2/convert/resolved/data/calldata_with_extended_params_input.json new file mode 100644 index 0000000..9a825e2 --- /dev/null +++ b/tests/v2/convert/resolved/data/calldata_with_extended_params_input.json @@ -0,0 +1,36 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "calldata-extended-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "execute(bytes)": { + "intent": "Execute calldata", + "fields": [ + { + "path": "data", + "label": "Calldata", + "format": "calldata", + "params": { + "callee": "0x0000000000000000000000000000000000000002", + "amount": 1000000, + "spender": "0x0000000000000000000000000000000000000003" + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/calldata_with_extended_params_resolved.json b/tests/v2/convert/resolved/data/calldata_with_extended_params_resolved.json new file mode 100644 index 0000000..4503b62 --- /dev/null +++ b/tests/v2/convert/resolved/data/calldata_with_extended_params_resolved.json @@ -0,0 +1,36 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "calldata-extended-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "0x09c5eabe": { + "intent": "Execute calldata", + "fields": [ + { + "label": "Calldata", + "format": "calldata", + "params": { + "callee": "0x0000000000000000000000000000000000000002", + "amount": 1000000, + "spender": "0x0000000000000000000000000000000000000003" + }, + "path": "#.data" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/deprecated_abi_ignored_input.json b/tests/v2/convert/resolved/data/deprecated_abi_ignored_input.json new file mode 100644 index 0000000..a729526 --- /dev/null +++ b/tests/v2/convert/resolved/data/deprecated_abi_ignored_input.json @@ -0,0 +1,46 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "contract-deprecated-abi", + "contract": { + "abi": [ + { + "type": "function", + "name": "transfer", + "inputs": [ + { + "name": "to", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ] + } + ], + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "transfer(address,uint256)": { + "intent": "Transfer tokens", + "fields": [ + { + "path": "to", + "label": "To" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/deprecated_abi_ignored_resolved.json b/tests/v2/convert/resolved/data/deprecated_abi_ignored_resolved.json new file mode 100644 index 0000000..f5594fe --- /dev/null +++ b/tests/v2/convert/resolved/data/deprecated_abi_ignored_resolved.json @@ -0,0 +1,30 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "contract-deprecated-abi", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "0xa9059cbb": { + "intent": "Transfer tokens", + "fields": [ + { + "label": "To", + "path": "#.to" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/deprecated_schemas_ignored_input.json b/tests/v2/convert/resolved/data/deprecated_schemas_ignored_input.json new file mode 100644 index 0000000..f7adf30 --- /dev/null +++ b/tests/v2/convert/resolved/data/deprecated_schemas_ignored_input.json @@ -0,0 +1,38 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "eip712-deprecated-schemas", + "eip712": { + "schemas": [ + { + "TestMessage": { + "name": "value", + "type": "uint256" + } + } + ], + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "TestMessage": { + "intent": "Test Intent", + "fields": [ + { + "path": "value", + "label": "Value" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/deprecated_schemas_ignored_resolved.json b/tests/v2/convert/resolved/data/deprecated_schemas_ignored_resolved.json new file mode 100644 index 0000000..093606c --- /dev/null +++ b/tests/v2/convert/resolved/data/deprecated_schemas_ignored_resolved.json @@ -0,0 +1,30 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "eip712-deprecated-schemas", + "eip712": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "TestMessage": { + "intent": "Test Intent", + "fields": [ + { + "label": "Value", + "path": "#.value" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_group_basic_input.json b/tests/v2/convert/resolved/data/field_group_basic_input.json new file mode 100644 index 0000000..6401c56 --- /dev/null +++ b/tests/v2/convert/resolved/data/field_group_basic_input.json @@ -0,0 +1,39 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "field-group-test", + "eip712": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "MultiTransfer": { + "intent": "Transfer to multiple recipients", + "fields": [ + { + "path": "#.transfers.[]", + "fields": [ + { + "path": "to", + "label": "To" + }, + { + "path": "amount", + "label": "Amount" + } + ] + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_group_basic_resolved.json b/tests/v2/convert/resolved/data/field_group_basic_resolved.json new file mode 100644 index 0000000..be0b104 --- /dev/null +++ b/tests/v2/convert/resolved/data/field_group_basic_resolved.json @@ -0,0 +1,39 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "field-group-test", + "eip712": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "MultiTransfer": { + "intent": "Transfer to multiple recipients", + "fields": [ + { + "path": "#.transfers.[]", + "fields": [ + { + "path": "#.transfers.[].to", + "label": "To" + }, + { + "path": "#.transfers.[].amount", + "label": "Amount" + } + ] + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_group_with_iteration_input.json b/tests/v2/convert/resolved/data/field_group_with_iteration_input.json new file mode 100644 index 0000000..ab9d4a2 --- /dev/null +++ b/tests/v2/convert/resolved/data/field_group_with_iteration_input.json @@ -0,0 +1,36 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "field-group-iteration-test", + "eip712": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "BatchTransfer": { + "intent": "Batch transfer", + "fields": [ + { + "path": "#.transfers.[]", + "iteration": "sequential", + "fields": [ + { + "path": "to", + "label": "To" + } + ] + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_group_with_iteration_resolved.json b/tests/v2/convert/resolved/data/field_group_with_iteration_resolved.json new file mode 100644 index 0000000..872eebc --- /dev/null +++ b/tests/v2/convert/resolved/data/field_group_with_iteration_resolved.json @@ -0,0 +1,36 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "field-group-iteration-test", + "eip712": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "BatchTransfer": { + "intent": "Batch transfer", + "fields": [ + { + "path": "#.transfers.[]", + "iteration": "sequential", + "fields": [ + { + "path": "#.transfers.[].to", + "label": "To" + } + ] + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_group_with_label_input.json b/tests/v2/convert/resolved/data/field_group_with_label_input.json new file mode 100644 index 0000000..8232e32 --- /dev/null +++ b/tests/v2/convert/resolved/data/field_group_with_label_input.json @@ -0,0 +1,36 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "field-group-label-test", + "eip712": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "Orders": { + "intent": "Submit orders", + "fields": [ + { + "path": "#.orders.[]", + "label": "Order", + "fields": [ + { + "path": "amount", + "label": "Amount" + } + ] + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_group_with_label_resolved.json b/tests/v2/convert/resolved/data/field_group_with_label_resolved.json new file mode 100644 index 0000000..cf014de --- /dev/null +++ b/tests/v2/convert/resolved/data/field_group_with_label_resolved.json @@ -0,0 +1,36 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "field-group-label-test", + "eip712": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "Orders": { + "intent": "Submit orders", + "fields": [ + { + "path": "#.orders.[]", + "label": "Order", + "fields": [ + { + "path": "#.orders.[].amount", + "label": "Amount" + } + ] + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_with_encryption_input.json b/tests/v2/convert/resolved/data/field_with_encryption_input.json new file mode 100644 index 0000000..45b2030 --- /dev/null +++ b/tests/v2/convert/resolved/data/field_with_encryption_input.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "encryption-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "withdraw(bytes32)": { + "intent": "Withdraw encrypted amount", + "fields": [ + { + "path": "encryptedAmount", + "label": "Amount", + "encryption": { + "scheme": "fhevm", + "plaintextType": "uint256", + "fallbackLabel": "[Encrypted Value]" + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_with_encryption_resolved.json b/tests/v2/convert/resolved/data/field_with_encryption_resolved.json new file mode 100644 index 0000000..234aeb9 --- /dev/null +++ b/tests/v2/convert/resolved/data/field_with_encryption_resolved.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "encryption-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "0x8e19899e": { + "intent": "Withdraw encrypted amount", + "fields": [ + { + "label": "Amount", + "path": "#.encryptedAmount", + "encryption": { + "scheme": "fhevm", + "plaintextType": "uint256", + "fallbackLabel": "[Encrypted Value]" + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_with_separator_input.json b/tests/v2/convert/resolved/data/field_with_separator_input.json new file mode 100644 index 0000000..925764d --- /dev/null +++ b/tests/v2/convert/resolved/data/field_with_separator_input.json @@ -0,0 +1,31 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "separator-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "multiTransfer(address[],uint256[])": { + "intent": "Transfer to multiple recipients", + "fields": [ + { + "path": "#.recipients.[]", + "label": "Recipient {index}", + "separator": ", " + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_with_separator_resolved.json b/tests/v2/convert/resolved/data/field_with_separator_resolved.json new file mode 100644 index 0000000..52aa9bd --- /dev/null +++ b/tests/v2/convert/resolved/data/field_with_separator_resolved.json @@ -0,0 +1,31 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "separator-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "0x1e89d545": { + "intent": "Transfer to multiple recipients", + "fields": [ + { + "label": "Recipient {index}", + "path": "#.recipients.[]", + "separator": ", " + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_with_visibility_conditions_input.json b/tests/v2/convert/resolved/data/field_with_visibility_conditions_input.json new file mode 100644 index 0000000..9f24090 --- /dev/null +++ b/tests/v2/convert/resolved/data/field_with_visibility_conditions_input.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "visibility-conditions-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "swap(address,address,uint256)": { + "intent": "Swap tokens", + "fields": [ + { + "path": "from", + "label": "From Token", + "visible": { + "ifNotIn": [ + "0x0000000000000000000000000000000000000000" + ] + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_with_visibility_conditions_resolved.json b/tests/v2/convert/resolved/data/field_with_visibility_conditions_resolved.json new file mode 100644 index 0000000..e14ff39 --- /dev/null +++ b/tests/v2/convert/resolved/data/field_with_visibility_conditions_resolved.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "visibility-conditions-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "0xdf791e50": { + "intent": "Swap tokens", + "fields": [ + { + "label": "From Token", + "path": "#.from", + "visible": { + "ifNotIn": [ + "0x0000000000000000000000000000000000000000" + ] + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_with_visibility_simple_input.json b/tests/v2/convert/resolved/data/field_with_visibility_simple_input.json new file mode 100644 index 0000000..601351d --- /dev/null +++ b/tests/v2/convert/resolved/data/field_with_visibility_simple_input.json @@ -0,0 +1,31 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "visibility-simple-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "transfer(address,uint256)": { + "intent": "Transfer", + "fields": [ + { + "path": "to", + "label": "To", + "visible": "always" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/field_with_visibility_simple_resolved.json b/tests/v2/convert/resolved/data/field_with_visibility_simple_resolved.json new file mode 100644 index 0000000..7eeb0a5 --- /dev/null +++ b/tests/v2/convert/resolved/data/field_with_visibility_simple_resolved.json @@ -0,0 +1,31 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "visibility-simple-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "0xa9059cbb": { + "intent": "Transfer", + "fields": [ + { + "label": "To", + "path": "#.to", + "visible": "always" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/format_chain_id_input.json b/tests/v2/convert/resolved/data/format_chain_id_input.json new file mode 100644 index 0000000..6552d44 --- /dev/null +++ b/tests/v2/convert/resolved/data/format_chain_id_input.json @@ -0,0 +1,31 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "chain-id-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "bridgeToChain(uint256,address,uint256)": { + "intent": "Bridge to chain", + "fields": [ + { + "path": "chainId", + "label": "Target Chain", + "format": "chainId" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/format_chain_id_resolved.json b/tests/v2/convert/resolved/data/format_chain_id_resolved.json new file mode 100644 index 0000000..413aa73 --- /dev/null +++ b/tests/v2/convert/resolved/data/format_chain_id_resolved.json @@ -0,0 +1,31 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "chain-id-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "0x3ac54ed6": { + "intent": "Bridge to chain", + "fields": [ + { + "label": "Target Chain", + "format": "chainId", + "path": "#.chainId" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/format_interoperable_address_name_input.json b/tests/v2/convert/resolved/data/format_interoperable_address_name_input.json new file mode 100644 index 0000000..d6937f5 --- /dev/null +++ b/tests/v2/convert/resolved/data/format_interoperable_address_name_input.json @@ -0,0 +1,41 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "interoperable-address-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "transfer(address,uint256)": { + "intent": "Transfer", + "fields": [ + { + "path": "to", + "label": "To", + "format": "interoperableAddressName", + "params": { + "types": [ + "wallet", + "eoa" + ], + "sources": [ + "ens", + "lens" + ] + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/format_interoperable_address_name_resolved.json b/tests/v2/convert/resolved/data/format_interoperable_address_name_resolved.json new file mode 100644 index 0000000..fc94f9d --- /dev/null +++ b/tests/v2/convert/resolved/data/format_interoperable_address_name_resolved.json @@ -0,0 +1,41 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "interoperable-address-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "0xa9059cbb": { + "intent": "Transfer", + "fields": [ + { + "label": "To", + "format": "interoperableAddressName", + "params": { + "types": [ + "wallet", + "eoa" + ], + "sources": [ + "ens", + "lens" + ] + }, + "path": "#.to" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/format_token_ticker_input.json b/tests/v2/convert/resolved/data/format_token_ticker_input.json new file mode 100644 index 0000000..a07da6a --- /dev/null +++ b/tests/v2/convert/resolved/data/format_token_ticker_input.json @@ -0,0 +1,34 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "token-ticker-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "getTokenTicker(address)": { + "intent": "Get token ticker", + "fields": [ + { + "path": "token", + "label": "Token", + "format": "tokenTicker", + "params": { + "chainId": 1 + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/format_token_ticker_resolved.json b/tests/v2/convert/resolved/data/format_token_ticker_resolved.json new file mode 100644 index 0000000..3387c29 --- /dev/null +++ b/tests/v2/convert/resolved/data/format_token_ticker_resolved.json @@ -0,0 +1,34 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "token-ticker-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "0xc53cdef6": { + "intent": "Get token ticker", + "fields": [ + { + "label": "Token", + "format": "tokenTicker", + "params": { + "chainId": 1 + }, + "path": "#.token" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/format_with_interpolated_intent_input.json b/tests/v2/convert/resolved/data/format_with_interpolated_intent_input.json new file mode 100644 index 0000000..d34c612 --- /dev/null +++ b/tests/v2/convert/resolved/data/format_with_interpolated_intent_input.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "interpolated-intent-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "swap(address,uint256)": { + "intent": "Swap tokens", + "interpolatedIntent": "Swap {amount} of token {token}", + "fields": [ + { + "path": "token", + "label": "Token" + }, + { + "path": "amount", + "label": "Amount" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/format_with_interpolated_intent_resolved.json b/tests/v2/convert/resolved/data/format_with_interpolated_intent_resolved.json new file mode 100644 index 0000000..8e11126 --- /dev/null +++ b/tests/v2/convert/resolved/data/format_with_interpolated_intent_resolved.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "interpolated-intent-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "0xd004f0f7": { + "intent": "Swap tokens", + "interpolatedIntent": "Swap {amount} of token {token}", + "fields": [ + { + "label": "Token", + "path": "#.token" + }, + { + "label": "Amount", + "path": "#.amount" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/metadata_with_contract_name_input.json b/tests/v2/convert/resolved/data/metadata_with_contract_name_input.json new file mode 100644 index 0000000..68ba54d --- /dev/null +++ b/tests/v2/convert/resolved/data/metadata_with_contract_name_input.json @@ -0,0 +1,26 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "contract-name-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "contractName": "TestContract" + }, + "display": { + "formats": { + "execute()": { + "intent": "Execute", + "fields": [] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/metadata_with_contract_name_resolved.json b/tests/v2/convert/resolved/data/metadata_with_contract_name_resolved.json new file mode 100644 index 0000000..20a3b8e --- /dev/null +++ b/tests/v2/convert/resolved/data/metadata_with_contract_name_resolved.json @@ -0,0 +1,26 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "contract-name-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "contractName": "TestContract" + }, + "display": { + "formats": { + "0x61461954": { + "intent": "Execute", + "fields": [] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/metadata_with_maps_input.json b/tests/v2/convert/resolved/data/metadata_with_maps_input.json new file mode 100644 index 0000000..a572eed --- /dev/null +++ b/tests/v2/convert/resolved/data/metadata_with_maps_input.json @@ -0,0 +1,39 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "maps-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "chainNames": { + "$keyType": "uint", + "values": { + "1": "Ethereum", + "137": "Polygon" + } + } + } + }, + "display": { + "formats": { + "setChain(uint256)": { + "intent": "Set chain", + "fields": [ + { + "path": "chainId", + "label": "Chain" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/metadata_with_maps_resolved.json b/tests/v2/convert/resolved/data/metadata_with_maps_resolved.json new file mode 100644 index 0000000..08bdf98 --- /dev/null +++ b/tests/v2/convert/resolved/data/metadata_with_maps_resolved.json @@ -0,0 +1,39 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "maps-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "maps": { + "chainNames": { + "$keyType": "uint", + "values": { + "1": "Ethereum", + "137": "Polygon" + } + } + } + }, + "display": { + "formats": { + "0x1b44fd15": { + "intent": "Set chain", + "fields": [ + { + "label": "Chain", + "path": "#.chainId" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/minimal_contract_v2_input.json b/tests/v2/convert/resolved/data/minimal_contract_v2_input.json new file mode 100644 index 0000000..2ea5b00 --- /dev/null +++ b/tests/v2/convert/resolved/data/minimal_contract_v2_input.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "contract-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "info": { + "url": "https://example.com", + "legalName": "Test Legal Name", + "lastUpdate": "2025-01-01" + } + }, + "display": { + "formats": { + "transfer(address,uint256)": { + "intent": "Transfer tokens", + "fields": [ + { + "path": "to", + "label": "To" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/minimal_contract_v2_resolved.json b/tests/v2/convert/resolved/data/minimal_contract_v2_resolved.json new file mode 100644 index 0000000..a25cf06 --- /dev/null +++ b/tests/v2/convert/resolved/data/minimal_contract_v2_resolved.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "contract-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "info": { + "legalName": "Test Legal Name", + "lastUpdate": "2025-01-01T00:00:00", + "url": "https://example.com" + } + }, + "display": { + "formats": { + "0xa9059cbb": { + "intent": "Transfer tokens", + "fields": [ + { + "label": "To", + "path": "#.to" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/minimal_eip712_v2_input.json b/tests/v2/convert/resolved/data/minimal_eip712_v2_input.json new file mode 100644 index 0000000..1a54b16 --- /dev/null +++ b/tests/v2/convert/resolved/data/minimal_eip712_v2_input.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "eip712-test", + "eip712": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "info": { + "url": "https://example.com", + "legalName": "Test Legal Name", + "lastUpdate": "2025-01-01" + } + }, + "display": { + "formats": { + "TestMessage": { + "intent": "Test Intent", + "fields": [ + { + "path": "value", + "label": "Value" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/minimal_eip712_v2_resolved.json b/tests/v2/convert/resolved/data/minimal_eip712_v2_resolved.json new file mode 100644 index 0000000..1fe6e62 --- /dev/null +++ b/tests/v2/convert/resolved/data/minimal_eip712_v2_resolved.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "eip712-test", + "eip712": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner", + "info": { + "legalName": "Test Legal Name", + "lastUpdate": "2025-01-01T00:00:00", + "url": "https://example.com" + } + }, + "display": { + "formats": { + "TestMessage": { + "intent": "Test Intent", + "fields": [ + { + "label": "Value", + "path": "#.value" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/token_amount_with_chainid_input.json b/tests/v2/convert/resolved/data/token_amount_with_chainid_input.json new file mode 100644 index 0000000..d537c60 --- /dev/null +++ b/tests/v2/convert/resolved/data/token_amount_with_chainid_input.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "token-amount-chainid-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "swapCrossChain(address,uint256,uint256)": { + "intent": "Cross-chain swap", + "fields": [ + { + "path": "amount", + "label": "Amount", + "format": "tokenAmount", + "params": { + "tokenPath": "token", + "chainIdPath": "targetChainId" + } + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/data/token_amount_with_chainid_resolved.json b/tests/v2/convert/resolved/data/token_amount_with_chainid_resolved.json new file mode 100644 index 0000000..2f233fe --- /dev/null +++ b/tests/v2/convert/resolved/data/token_amount_with_chainid_resolved.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../../../specs/erc7730-v2.schema.json", + "context": { + "$id": "token-amount-chainid-test", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x0000000000000000000000000000000000000001" + } + ] + } + }, + "metadata": { + "owner": "Test Owner" + }, + "display": { + "formats": { + "0x0ef55e91": { + "intent": "Cross-chain swap", + "fields": [ + { + "label": "Amount", + "format": "tokenAmount", + "params": { + "tokenPath": "token", + "chainIdPath": "targetChainId" + }, + "path": "#.amount" + } + ] + } + } + } +} diff --git a/tests/v2/convert/resolved/test_convert_input_to_resolved.py b/tests/v2/convert/resolved/test_convert_input_to_resolved.py new file mode 100644 index 0000000..1a13442 --- /dev/null +++ b/tests/v2/convert/resolved/test_convert_input_to_resolved.py @@ -0,0 +1,142 @@ +from pathlib import Path + +import pytest + +from erc7730.convert.convert import convert_and_raise_errors +from erc7730.convert.resolved.v2.convert_erc7730_input_to_resolved import ERC7730InputToResolved +from erc7730.model.input.v2.descriptor import InputERC7730Descriptor +from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor +from tests.assertions import assert_model_json_equals +from tests.cases import TestCase, case_id +from tests.skip import single_or_skip + +DATA = Path(__file__).resolve().parent / "data" +UPDATE_REFERENCES = False + + +@pytest.mark.parametrize( + "testcase", + [ + TestCase( + id="minimal_eip712_v2", + label="minimal EIP-712 v2 descriptor", + description="most minimal possible EIP-712 v2 file: all optional fields unset, resolved form is identical " + "to input form", + ), + TestCase( + id="minimal_contract_v2", + label="minimal contract v2 descriptor", + description="most minimal possible contract v2 file: all optional fields unset, resolved form is identical " + "to input form", + ), + TestCase( + id="deprecated_abi_ignored", + label="deprecated ABI field ignored", + description="contract descriptor with deprecated ABI field is ignored during conversion to resolved form", + ), + TestCase( + id="deprecated_schemas_ignored", + label="deprecated schemas field ignored", + description="EIP-712 descriptor with deprecated schemas field is ignored during conversion to resolved " + "form", + ), + TestCase( + id="format_token_ticker", + label="field format - using token ticker format", + description="using token ticker format with chainId parameter variants", + ), + TestCase( + id="format_interoperable_address_name", + label="field format - using interoperable address name format", + description="using interoperable address name format with parameter variants", + ), + TestCase( + id="format_chain_id", + label="field format - using chain ID format", + description="using chain ID format to display chain name", + ), + TestCase( + id="field_with_encryption", + label="field with encryption parameters", + description="using encryption parameters for encrypted value display", + ), + TestCase( + id="field_with_visibility_simple", + label="field with simple visibility rule", + description="using simple string visibility rule", + ), + TestCase( + id="field_with_visibility_conditions", + label="field with visibility conditions", + description="using visibility conditions with ifNotIn/mustBe", + ), + TestCase( + id="field_with_separator", + label="field with separator", + description="using separator with {index} interpolation", + ), + TestCase( + id="field_group_basic", + label="field group - basic usage", + description="using field group on array with basic configuration", + ), + TestCase( + id="field_group_with_iteration", + label="field group - with iteration strategy", + description="using field group with sequential/bundled iteration", + ), + TestCase( + id="field_group_with_label", + label="field group - with label", + description="using field group with label for array display", + ), + TestCase( + id="format_with_interpolated_intent", + label="format with interpolated intent", + description="using interpolatedIntent with {path} syntax", + ), + TestCase( + id="metadata_with_maps", + label="metadata with maps", + description="using maps in metadata section", + ), + TestCase( + id="metadata_with_contract_name", + label="metadata with contract name", + description="using contractName field in metadata", + ), + TestCase( + id="token_amount_with_chainid", + label="token amount with chainId", + description="using token amount format with chainId parameter for cross-chain tokens", + ), + TestCase( + id="calldata_with_extended_params", + label="calldata with extended parameters", + description="using calldata format with chainId, amount, and spender parameters", + ), + ], + ids=case_id, +) +def test_by_reference(testcase: TestCase) -> None: + """ + Test converting ERC-7730 v2 descriptor files from input to resolved form, and compare against reference files. + """ + input_descriptor_path = DATA / f"{testcase.id}_input.json" + resolved_descriptor_path = DATA / f"{testcase.id}_resolved.json" + if (expected_error := testcase.error) is not None: + with pytest.raises(Exception) as exc_info: + input_descriptor = InputERC7730Descriptor.load(input_descriptor_path) + convert_and_raise_errors(input_descriptor, ERC7730InputToResolved()) + assert expected_error in str(exc_info.value) + else: + input_descriptor = InputERC7730Descriptor.load(input_descriptor_path) + actual_descriptor: ResolvedERC7730Descriptor = single_or_skip( + convert_and_raise_errors(input_descriptor, ERC7730InputToResolved()) + ) + if UPDATE_REFERENCES: + actual_descriptor.save(resolved_descriptor_path) + pytest.fail(f"Reference {resolved_descriptor_path} updated, please set UPDATE_REFERENCES back to False") + else: + expected_descriptor = ResolvedERC7730Descriptor.load(resolved_descriptor_path) + assert_model_json_equals(expected_descriptor, actual_descriptor) From 84e8b68dc3913794b86f3db04913caa075bb4c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Samier?= Date: Thu, 12 Feb 2026 18:22:50 +0100 Subject: [PATCH 06/15] Update src/erc7730/convert/resolved/v2/parameters.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/erc7730/convert/resolved/v2/parameters.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/erc7730/convert/resolved/v2/parameters.py b/src/erc7730/convert/resolved/v2/parameters.py index 8989bf5..0184686 100644 --- a/src/erc7730/convert/resolved/v2/parameters.py +++ b/src/erc7730/convert/resolved/v2/parameters.py @@ -331,9 +331,16 @@ def resolve_token_ticker_parameters( if isinstance(resolved_value, int): resolved_chain_id = resolved_value + # Resolve and normalize chainIdPath using constants and the current prefix + resolved_chain_id_path: DataPath | None = None + if params.chainIdPath is not None: + relative_chain_id_path = constants.resolve_path(params.chainIdPath, out) + if relative_chain_id_path is not None: + resolved_chain_id_path = prefix + relative_chain_id_path + return ResolvedTokenTickerParameters( chainId=resolved_chain_id, - chainIdPath=params.chainIdPath, + chainIdPath=resolved_chain_id_path, ) From c861ca836a88ff79be409c43931a89c27a3a4837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Samier?= Date: Thu, 12 Feb 2026 18:23:00 +0100 Subject: [PATCH 07/15] Update src/erc7730/convert/resolved/v2/values.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/erc7730/convert/resolved/v2/values.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/erc7730/convert/resolved/v2/values.py b/src/erc7730/convert/resolved/v2/values.py index 4877a7f..55ef073 100644 --- a/src/erc7730/convert/resolved/v2/values.py +++ b/src/erc7730/convert/resolved/v2/values.py @@ -136,17 +136,25 @@ def encode_value(value: ScalarType, abi_type: ABIDataType, out: OutputAdder) -> case ABIDataType.UINT: if not isinstance(value, int) or value < 0: return out.error(title="Invalid constant", message=f"""Value "{value}" is not an unsigned int""") - encoded = value.to_bytes(length=(max(value.bit_length(), 1) + 7) // 8, signed=False) + encoded = value.to_bytes( + length=(max(value.bit_length(), 1) + 7) // 8, + byteorder="big", + signed=False, + ) case ABIDataType.INT: if not isinstance(value, int): return out.error(title="Invalid constant", message=f"""Value "{value}" is not an integer""") - encoded = value.to_bytes(length=(max(value.bit_length(), 1) + 7) // 8, signed=True) + encoded = value.to_bytes( + length=(max(value.bit_length(), 1) + 7) // 8, + byteorder="big", + signed=True, + ) case ABIDataType.BOOL: if not isinstance(value, bool): return out.error(title="Invalid constant", message=f"""Value "{value}" is not a boolean""") - encoded = value.to_bytes() + encoded = int(value).to_bytes(length=1, byteorder="big", signed=False) case ABIDataType.STRING: if not isinstance(value, str): From 96eb0a4f63c2446bf5d9ebc5bbffc7658974fbe9 Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Fri, 13 Feb 2026 10:00:52 +0100 Subject: [PATCH 08/15] fix: auto-detect v2 descriptors in lint command The lint CLI command was hardcoded to use the v1 model, causing false errors when linting v2 descriptors. This adds auto-detection based on the $schema field and routes to the v2 linter accordingly. A --v2 flag is also available for explicit override. --- src/erc7730/main.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/erc7730/main.py b/src/erc7730/main.py index 2e9b286..b559291 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -19,7 +19,8 @@ from erc7730.convert.resolved.convert_erc7730_input_to_resolved import ERC7730InputToResolved from erc7730.format.format import format_all_and_print_errors from erc7730.generate.generate import generate_descriptor -from erc7730.lint.lint import lint_all_and_print_errors +from erc7730.lint.lint import lint_all_and_print_errors as lint_all_and_print_errors_v1 +from erc7730.lint.v2.lint import lint_all_and_print_errors as lint_all_and_print_errors_v2 from erc7730.list.list import list_all from erc7730.model import ERC7730ModelType from erc7730.model.base import Model @@ -34,6 +35,21 @@ ) +def _any_v2_descriptor(paths: list[Path]) -> bool: + """Check if any descriptor file in paths references the v2 schema.""" + for path in paths: + files = [path] if path.is_file() else path.rglob("*.json") + for file in files: + try: + with open(file) as f: + content = json.load(f) + if isinstance(content, dict) and "v2" in content.get("$schema", ""): + return True + except Exception: + continue + return False + + app = Typer( name="erc7730", no_args_is_help=True, @@ -84,9 +100,14 @@ def command_schema( def command_lint( paths: Annotated[list[Path], Argument(help="The files or directory paths to lint")], gha: Annotated[bool, Option(help="Enable Github annotations output")] = False, + v2: Annotated[bool, Option("--v2", help="Use v2 model for validation (auto-detected from $schema if not set)")] = False, ) -> None: - if not lint_all_and_print_errors(paths, gha): - raise Exit(1) + if v2 or _any_v2_descriptor(paths): + if not lint_all_and_print_errors_v2(paths, gha): + raise Exit(1) + else: + if not lint_all_and_print_errors_v1(paths, gha): + raise Exit(1) @app.command( From 63adace61fa26ff37c3bee52c4d0731a8ee6bbe6 Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Fri, 13 Feb 2026 16:48:02 +0100 Subject: [PATCH 09/15] fix: filter out read-only functions from ABI lint warnings Pure/view functions cannot produce transactions and don't need clear signing descriptors. Skip them by default in get_functions() to reduce noise in lint output. The generator still includes all functions via include_read_only=True. --- src/erc7730/common/abi.py | 16 +++++++++++++--- src/erc7730/generate/generate.py | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/erc7730/common/abi.py b/src/erc7730/common/abi.py index 7c37aa2..8067dc9 100644 --- a/src/erc7730/common/abi.py +++ b/src/erc7730/common/abi.py @@ -7,7 +7,7 @@ from lark import Lark, UnexpectedInput from lark.visitors import Transformer_InPlaceRecursive -from erc7730.model.abi import ABI, Component, Function, InputOutput +from erc7730.model.abi import ABI, Component, Function, InputOutput, StateMutability _SIGNATURE_PARSER = parser = Lark( grammar=r""" @@ -128,11 +128,21 @@ class Functions: proxy: bool -def get_functions(abis: list[ABI]) -> Functions: - """Get the functions from a list of ABIs.""" +_READ_ONLY_MUTABILITIES = frozenset({StateMutability.pure, StateMutability.view}) + + +def get_functions(abis: list[ABI], *, include_read_only: bool = False) -> Functions: + """Get the functions from a list of ABIs. + + :param abis: list of ABI entries + :param include_read_only: if False (default), filter out pure/view functions that cannot produce transactions + :return: Functions dataclass with selector->Function mapping + """ functions = Functions(functions={}, proxy=False) for abi in abis: if abi.type == "function": + if not include_read_only and abi.stateMutability in _READ_ONLY_MUTABILITIES: + continue functions.functions[function_to_selector(abi)] = abi if abi.name in ("proxyType", "getImplementation", "implementation", "proxy__getImplementation"): functions.proxy = True diff --git a/src/erc7730/generate/generate.py b/src/erc7730/generate/generate.py index 22e0dda..3e733c1 100644 --- a/src/erc7730/generate/generate.py +++ b/src/erc7730/generate/generate.py @@ -111,7 +111,7 @@ def _generate_context_calldata( 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()) + functions = list(get_functions(abis, include_read_only=True).functions.values()) context = InputContractContext( contract=InputContract(abi=functions, deployments=[InputDeployment(chainId=chain_id, address=contract_address)]) From 3b1807bfd648f6b6bf8bd795ec9ceec9d40f98c4 Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Fri, 13 Feb 2026 17:16:55 +0100 Subject: [PATCH 10/15] fix: use builtins.print for JSON output commands rich.print wraps long strings at terminal width, inserting literal newlines into JSON string values (e.g. hex descriptor fields). This corrupts the output when piped or captured. Use builtins.print for schema, resolve, and calldata commands that output raw JSON. --- src/erc7730/main.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/erc7730/main.py b/src/erc7730/main.py index b559291..eb82840 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -1,3 +1,4 @@ +import builtins import json import logging import os @@ -87,7 +88,7 @@ def command_schema( case _: assert_never(model_type) - print(json.dumps(descriptor_type.model_json_schema(by_alias=True), indent=4)) + builtins.print(json.dumps(descriptor_type.model_json_schema(by_alias=True), indent=4)) @app.command( @@ -160,7 +161,7 @@ def command_resolve( input_descriptor = InputERC7730Descriptor.load(input_path) if (resolved_descriptor := ERC7730InputToResolved().convert(input_descriptor, ConsoleOutputAdder())) is None: raise Exit(1) - print(resolved_descriptor.to_json_string()) + builtins.print(resolved_descriptor.to_json_string()) @app.command( @@ -223,7 +224,7 @@ def command_calldata( input_descriptor, source=HttpUrl(source) if source is not None else None, chain_id=chain_id ) ) - print(model.model_dump_json(indent=2, exclude_none=True)) + builtins.print(model.model_dump_json(indent=2, exclude_none=True)) if __name__ == "__main__": From 7420bef80c52dfd92848352ba9b8a212a8d25d06 Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Fri, 13 Feb 2026 18:02:29 +0100 Subject: [PATCH 11/15] feat: add v2 support to `erc7730 calldata` command The calldata command now auto-detects v2 descriptors (via $schema field) and builds ABI Function objects from human-readable format keys in display.formats, instead of requiring an embedded ABI in the contract context. Fields with visible="never" are skipped to match v1 behavior. --- .../convert_erc7730_v2_input_to_calldata.py | 608 ++++++++++++++++++ src/erc7730/main.py | 21 +- 2 files changed, 624 insertions(+), 5 deletions(-) create mode 100644 src/erc7730/convert/calldata/convert_erc7730_v2_input_to_calldata.py diff --git a/src/erc7730/convert/calldata/convert_erc7730_v2_input_to_calldata.py b/src/erc7730/convert/calldata/convert_erc7730_v2_input_to_calldata.py new file mode 100644 index 0000000..d43ade3 --- /dev/null +++ b/src/erc7730/convert/calldata/convert_erc7730_v2_input_to_calldata.py @@ -0,0 +1,608 @@ +""" +Conversion of v2 ERC-7730 input descriptors to calldata descriptors. + +In v2, the ABI is no longer embedded in the contract context. Instead, the display.formats keys are +human-readable ABI signatures (e.g., "cooldownShares(uint256 shares)") from which Function objects +can be parsed and selectors computed. This module provides the v2-specific entry point and conversion +logic. +""" + +import hashlib +from typing import cast + +from pydantic_string_url import HttpUrl + +from erc7730.common.abi import ( + ABIDataType, + function_to_selector, + parse_signature, + reduce_signature, + signature_to_selector, +) +from erc7730.common.binary import from_hex +from erc7730.common.ledger import ledger_network_id +from erc7730.common.options import first_not_none +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.path import ( + convert_container_path, + convert_data_path, +) +from erc7730.convert.resolved.v2.convert_erc7730_input_to_resolved import ( + ERC7730InputToResolved, +) +from erc7730.convert.resolved.v2.values import encode_value +from erc7730.model.abi import Function +from erc7730.model.calldata.descriptor import ( + CalldataDescriptor, + CalldataDescriptorV1, +) +from erc7730.model.calldata.types import TrustedNameSource, TrustedNameType +from erc7730.model.calldata.v1.instruction import ( + CalldataDescriptorInstructionFieldV1, + CalldataDescriptorInstructionTransactionInfoV1, +) +from erc7730.model.calldata.v1.param import ( + CalldataDescriptorDateType, + CalldataDescriptorParamAmountV1, + CalldataDescriptorParamCalldataV1, + CalldataDescriptorParamDatetimeV1, + CalldataDescriptorParamDurationV1, + CalldataDescriptorParamEnumV1, + CalldataDescriptorParamNFTV1, + CalldataDescriptorParamRawV1, + CalldataDescriptorParamTokenAmountV1, + CalldataDescriptorParamTrustedNameV1, + CalldataDescriptorParamUnitV1, + CalldataDescriptorParamV1, +) +from erc7730.model.calldata.v1.value import ( + CalldataDescriptorValueConstantV1, + CalldataDescriptorValueV1, + CalldataDescriptorTypeFamily, +) +from erc7730.model.display import AddressNameType +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.path_parser import to_path +from erc7730.model.resolved.display import ResolvedValueConstant +from erc7730.model.resolved.v2.context import ( + ResolvedContractContext, + ResolvedDeployment, +) +from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor +from erc7730.model.resolved.v2.display import ( + ResolvedFieldDescription, + ResolvedFieldGroup, + ResolvedFormat, +) +from erc7730.model.types import Address, HexStr, Selector + + +def erc7730_v2_descriptor_to_calldata_descriptors( + input_descriptor: InputERC7730Descriptor, + source: HttpUrl | None = None, + chain_id: int | 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. + + :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 + :return: output calldata descriptors (1 per chain + selector) + """ + out = ConsoleOutputAdder() + + try: + if not isinstance(input_descriptor.context, InputContractContext): + return [] + + # Parse format keys (human-readable ABI signatures) into Function objects + abis: dict[Selector, Function] = {} + for format_key in input_descriptor.display.formats: + if format_key.startswith("0x"): + out.warning(f"Format key '{format_key}' is already a selector, cannot reconstruct ABI - skipping") + continue + try: + func = parse_signature(format_key) + reduced = reduce_signature(format_key) + selector = Selector(signature_to_selector(reduced)) + abis[selector] = func + except ValueError as e: + out.warning(f"Failed to parse format key '{format_key}': {e}") + continue + + if not abis: + out.warning("No valid function signatures found in display.formats keys") + return [] + + # Check chain_id filter against deployments + if chain_id is not None: + deployment_chain_ids = {d.chainId for d in input_descriptor.context.contract.deployments} + if chain_id not in deployment_chain_ids: + return [] + + # Resolve the v2 descriptor + if (resolved_descriptor := ERC7730InputToResolved().convert(input_descriptor, out)) is None: + return [] + + context = cast(ResolvedContractContext, resolved_descriptor.context) + + output_descriptors: list[CalldataDescriptor] = [] + + for deployment in context.contract.deployments: + if chain_id is not None and chain_id != deployment.chainId: + continue + + if ledger_network_id(deployment.chainId) is None: + out.warning(f"Chain id {deployment.chainId} is not known, skipping it") + continue + + for selector, format in resolved_descriptor.display.formats.items(): + if (abi := abis.get(selector)) is None: + out.error( + title="Invalid selector", + message=f"Selector {selector} not found in parsed ABI signatures.", + ) + continue + + descriptor = _convert_v2_selector( + descriptor=resolved_descriptor, + deployment=deployment, + selector=selector, + format=format, + abi=abi, + source=source, + out=out, + ) + + if descriptor is not None: + output_descriptors.append(descriptor) + + return output_descriptors + + except Exception as e: + out.warning(f"Error processing v2 ERC-7730 file {source}, skipping it") + exception_to_output(e, out) + + return [] + + +def _convert_v2_selector( + descriptor: ResolvedERC7730Descriptor, + deployment: ResolvedDeployment, + selector: Selector, + format: ResolvedFormat, + abi: Function, + source: HttpUrl | None, + out: OutputAdder, +) -> CalldataDescriptor | None: + """ + Generate output calldata descriptor for a single v2 selector. + + :param descriptor: resolved v2 source ERC-7730 descriptor + :param deployment: chain id / contract address for which the descriptor is generated + :param selector: function selector + :param format: v2 resolved format for the selector + :param abi: parsed ABI Function from format key signature + :param source: source of the descriptor file + :param out: error handler + :return: output calldata descriptor or None if invalid + """ + abi_tree = function_to_abi_tree(abi) + + creator_legal_name: str | None = None + creator_url: str | None = None + deploy_date: str | None = None + if descriptor.metadata.info is not None: + creator_legal_name = descriptor.metadata.owner + creator_url = str(descriptor.metadata.info.url) if descriptor.metadata.info.url else None + deploy_date = ( + descriptor.metadata.info.deploymentDate.strftime("%Y-%m-%dT%H:%M:%SZ") + if descriptor.metadata.info.deploymentDate + else None + ) + + # 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} + + 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: + return None + fields.extend(output_fields) + + hash = hashlib.sha3_256() + for field in fields: + hash.update(from_hex(field.descriptor)) + + transaction_info = CalldataDescriptorInstructionTransactionInfoV1( + chain_id=deployment.chainId, + address=deployment.address, + selector=selector, + hash=hash.digest().hex(), + operation_type=first_not_none(format.intent, format.id, selector), # type:ignore + creator_name=descriptor.metadata.owner, + creator_legal_name=creator_legal_name, + creator_url=creator_url, + contract_name=descriptor.context.id, + deploy_date=deploy_date, + ) + + return CalldataDescriptorV1( + source=source, + network=cast(str, ledger_network_id(deployment.chainId)), + chain_id=deployment.chainId, + address=deployment.address, + selector=selector, + transaction_info=transaction_info, + enums=enums, + fields=fields, + ) + + +# --- V2 field conversion --- + + +def _convert_v2_field( + abi: ABITree, + field: ResolvedFieldDescription | ResolvedFieldGroup, + enums: dict[str, int], + out: OutputAdder, +) -> list[CalldataDescriptorInstructionFieldV1] | None: + """ + Convert a v2 resolved field to calldata descriptor field instructions. + + Fields with ``visible == "never"`` are skipped — they correspond to v1 ``excluded`` fields + that were never included in calldata output. + + :param abi: function ABI tree + :param field: v2 resolved field + :param enums: mapping of source descriptor enum ids to calldata descriptor enum ids + :param out: error handler + :return: 1 or more calldata field instructions, or None on error + """ + if isinstance(field, ResolvedFieldDescription): + # Skip hidden fields (v2 equivalent of v1 "excluded" fields) + if field.visible == "never": + return [] + if (param := _convert_v2_param(abi=abi, field=field, enums=enums, out=out)) is None: + return None + return [CalldataDescriptorInstructionFieldV1(name=field.label, param=param)] + elif isinstance(field, ResolvedFieldGroup): + # 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: + return None + instructions.extend(nested_instructions) + return instructions + else: + return out.error( + title="Unknown field type", + message=f"Unexpected field type: {type(field)}", + ) + + +def _convert_v2_value( + path_str: str | None, + value: object | None, + format_type: FieldFormat | None, + abi: ABITree, + out: OutputAdder, +) -> CalldataDescriptorValueV1 | None: + """ + Convert a v2 resolved path/value to a calldata protocol value. + + In v2, the resolved model stores path as a string and value as a scalar. + We parse the string path back into DataPath/ContainerPath objects and reuse the v1 binary encoding. + + :param path_str: v2 resolved path string (e.g. "#.amount", "@.from") + :param value: v2 resolved constant value + :param format_type: field format type + :param abi: function ABI tree + :param out: error handler + :return: calldata protocol value or None on error + """ + if path_str is not None: + try: + parsed_path = to_path(str(path_str)) + except (ValueError, Exception) as e: + return out.error( + title="Invalid path", + message=f'Failed to parse path "{path_str}": {e}', + ) + + if isinstance(parsed_path, ContainerPath): + return convert_container_path(parsed_path, out) + elif isinstance(parsed_path, DataPath): + return convert_data_path(parsed_path, abi, out) + else: + return out.error( + title="Unsupported path type", + message=f'Descriptor paths are not supported in calldata conversion: "{path_str}"', + ) + + elif value is not None: + # Reconstruct a v1-compatible constant value + abi_type = _format_to_abi_type(format_type) + raw = encode_value(value, abi_type, out) + if raw is None: + return None + return CalldataDescriptorValueConstantV1( + type_family=CalldataDescriptorTypeFamily[abi_type.name], + type_size=len(raw) // 2 - 1, + value=value, + raw=raw, + ) + + return out.error( + title="Invalid field", + message="Field must have either a path or a value.", + ) + + +def _format_to_abi_type(format_type: FieldFormat | None) -> ABIDataType: + """Map a field format to the expected ABI data type (for constant value encoding).""" + match format_type: + case None | FieldFormat.RAW: + return ABIDataType.STRING + case ( + FieldFormat.AMOUNT + | FieldFormat.TOKEN_AMOUNT + | FieldFormat.DURATION + | FieldFormat.DATE + | FieldFormat.UNIT + | FieldFormat.NFT_NAME + | FieldFormat.ENUM + ): + return ABIDataType.UINT + case FieldFormat.ADDRESS_NAME | FieldFormat.INTEROPERABLE_ADDRESS_NAME: + return ABIDataType.ADDRESS + case FieldFormat.CALL_DATA: + return ABIDataType.BYTES + case FieldFormat.TOKEN_TICKER: + return ABIDataType.ADDRESS + case FieldFormat.CHAIN_ID: + return ABIDataType.UINT + case _: + return ABIDataType.STRING + + +def _convert_v2_param( + abi: ABITree, + field: ResolvedFieldDescription, + enums: dict[str, int], + out: OutputAdder, +) -> CalldataDescriptorParamV1 | None: + """ + Convert v2 resolved field parameters to a calldata descriptor field parameter. + + This mirrors the v1 convert_param logic but works with v2 resolved types. + + :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 out: error handler + :return: calldata protocol field parameter or None on error + """ + if (value := _convert_v2_value(field.path, field.value, field.format, abi, out)) is None: + return None + + match field.format: + case None | FieldFormat.RAW: + return CalldataDescriptorParamRawV1(value=value) + + case FieldFormat.ADDRESS_NAME: + types: list[TrustedNameType] = [] + sources: list[TrustedNameSource] = [] + sender_addresses: list[Address] | None = None + + if field.params is not None: + address_params = field.params + if (input_types := getattr(address_params, "types", None)) is not None: + for input_type in input_types: + if input_type == AddressNameType.CONTRACT: + types.append(TrustedNameType.SMART_CONTRACT) + else: + types.append(TrustedNameType(input_type)) + + for type_ in types: + match type_: + case TrustedNameType.EOA | TrustedNameType.WALLET | TrustedNameType.COLLECTION: + sources.append(TrustedNameSource.ENS) + sources.append(TrustedNameSource.UNSTOPPABLE_DOMAIN) + sources.append(TrustedNameSource.FREENAME) + case TrustedNameType.SMART_CONTRACT | TrustedNameType.TOKEN: + sources.append(TrustedNameSource.CRYPTO_ASSET_LIST) + case TrustedNameType.CONTEXT_ADDRESS: + sources.append(TrustedNameSource.DYNAMIC_RESOLVER) + case _: + pass + + if (input_sources := getattr(address_params, "sources", None)) is not None: + for input_source in input_sources: + if input_source.lower() == "local": + sources.append(TrustedNameSource.LOCAL_ADDRESS_BOOK) + if input_source.lower() in set(TrustedNameSource): + sources.append(TrustedNameSource(input_source.lower())) + + sender_addresses = getattr(address_params, "senderAddress", None) + + types = list(TrustedNameType) if not types else list(dict.fromkeys(types)) + sources = list(TrustedNameSource) if not sources else list(dict.fromkeys(sources)) + + return CalldataDescriptorParamTrustedNameV1( + value=value, types=types, sources=sources, sender_addresses=sender_addresses + ) + + case FieldFormat.ENUM: + if field.params is None: + return out.error( + title="Missing enum parameters", + message="Enum format requires parameters.", + ) + # V2 enum params have ref (e.g., "$.metadata.enums.myEnum") + # Extract the enum ID from the ref path + ref = getattr(field.params, "ref", None) + if ref is None: + return out.error( + title="Missing enum reference", + message="Enum parameters must include a $ref.", + ) + enum_id_str = str(ref).split(".")[-1] + if (enum_id := enums.get(enum_id_str)) is None: + return out.error( + title="Invalid enum id", + message=f"Failed finding descriptor id for enum {enum_id_str}, please report this bug", + ) + return CalldataDescriptorParamEnumV1(value=value, id=enum_id) + + case FieldFormat.UNIT: + if field.params is None: + return out.error( + title="Missing unit parameters", + message="Unit format requires parameters.", + ) + return CalldataDescriptorParamUnitV1( + value=value, + base=getattr(field.params, "base", ""), + decimals=getattr(field.params, "decimals", None), + prefix=getattr(field.params, "prefix", None), + ) + + case FieldFormat.DURATION: + return CalldataDescriptorParamDurationV1(value=value) + + case FieldFormat.NFT_NAME: + if field.params is None: + return out.error( + title="Missing NFT parameters", + message="NFT name format requires parameters.", + ) + collection_path_str = getattr(field.params, "collection", None) or getattr( + field.params, "collectionPath", None + ) + if collection_path_str is None: + return out.error( + title="Missing collection", + message="NFT name parameters must include a collection or collectionPath.", + ) + if (collection_value := _convert_v2_value(str(collection_path_str), None, None, abi, out)) is None: + return None + return CalldataDescriptorParamNFTV1(value=value, collection=collection_value) + + case FieldFormat.CALL_DATA: + if field.params is None: + return out.error( + title="Missing calldata parameters", + message="Calldata format requires parameters.", + ) + callee_str = getattr(field.params, "callee", None) or getattr(field.params, "calleePath", None) + if callee_str is None: + return out.error( + title="Missing callee", + message="Calldata parameters must include a callee or calleePath.", + ) + if (callee := _convert_v2_value(str(callee_str), None, None, abi, out)) is None: + return None + + selector_str = getattr(field.params, "selector", None) or getattr(field.params, "selectorPath", None) + selector_val = _convert_v2_value(str(selector_str), None, None, abi, out) if selector_str else None + + chain_id_val = None + chain_id_attr = getattr(field.params, "chainId", None) or getattr(field.params, "chainIdPath", None) + if chain_id_attr: + chain_id_val = _convert_v2_value(str(chain_id_attr), None, None, abi, out) + + amount_str = getattr(field.params, "amount", None) or getattr(field.params, "amountPath", None) + amount_val = _convert_v2_value(str(amount_str), None, None, abi, out) if amount_str else None + + spender_str = getattr(field.params, "spender", None) or getattr(field.params, "spenderPath", None) + spender_val = _convert_v2_value(str(spender_str), None, None, abi, out) if spender_str else None + + return CalldataDescriptorParamCalldataV1( + value=value, + callee=callee, + selector=selector_val, + chain_id=chain_id_val, + amount=amount_val, + spender=spender_val, + ) + + case FieldFormat.DATE: + if field.params is None: + return out.error( + title="Missing date parameters", + message="Date format requires parameters.", + ) + encoding = getattr(field.params, "encoding", None) + if encoding == DateEncoding.TIMESTAMP: + date_type = CalldataDescriptorDateType.UNIX + elif encoding == DateEncoding.BLOCKHEIGHT: + date_type = CalldataDescriptorDateType.BLOCK_HEIGHT + else: + return out.error( + title="Unsupported date encoding", + message=f"Date encoding '{encoding}' is not supported.", + ) + return CalldataDescriptorParamDatetimeV1(value=value, date_type=date_type) + + case FieldFormat.AMOUNT: + return CalldataDescriptorParamAmountV1(value=value) + + case FieldFormat.TOKEN_AMOUNT: + token_path: CalldataDescriptorValueV1 | None = None + native_currencies: list[Address] | None = None + threshold: HexStr | None = None + above_threshold_message: str | None = None + + if field.params is not None: + token = getattr(field.params, "token", None) + token_path_str = getattr(field.params, "tokenPath", None) + + if token is not None: + token_value_str = str(token) + # Token is an address, convert as path if it's a path, otherwise as constant + if token_value_str.startswith("#.") or token_value_str.startswith("@."): + token_path = _convert_v2_value(token_value_str, None, None, abi, out) + else: + # It's a resolved address constant — encode as constant value + raw = encode_value(token_value_str, ABIDataType.ADDRESS, out) + if raw is not None: + token_path = CalldataDescriptorValueConstantV1( + type_family=CalldataDescriptorTypeFamily.ADDRESS, + type_size=20, + value=token_value_str, + raw=raw, + ) + elif token_path_str is not None: + token_path = _convert_v2_value(str(token_path_str), None, None, abi, out) + + threshold = getattr(field.params, "threshold", None) + native_currencies = getattr(field.params, "nativeCurrencyAddress", None) + above_threshold_message = getattr(field.params, "message", None) + + return CalldataDescriptorParamTokenAmountV1( + value=value, + token=token_path, + native_currencies=native_currencies, + threshold=threshold, + above_threshold_message=above_threshold_message, + ) + + case _: + return out.error( + title="Unsupported format", + message=f"Field format '{field.format}' is not supported for calldata conversion.", + ) diff --git a/src/erc7730/main.py b/src/erc7730/main.py index eb82840..01edbcf 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -14,6 +14,7 @@ from erc7730.common.output import ConsoleOutputAdder from erc7730.convert.calldata.convert_erc7730_input_to_calldata import erc7730_descriptor_to_calldata_descriptors +from erc7730.convert.calldata.convert_erc7730_v2_input_to_calldata import erc7730_v2_descriptor_to_calldata_descriptors from erc7730.convert.convert import convert_to_file_and_print_errors from erc7730.convert.ledger.eip712.convert_eip712_to_erc7730 import EIP712toERC7730Converter from erc7730.convert.ledger.eip712.convert_erc7730_to_eip712 import ERC7730toEIP712Converter @@ -216,14 +217,24 @@ def command_calldata( input_erc7730_path: Annotated[Path, Argument(help="The input ERC-7730 file path")], source: Annotated[str | None, Option(help="Source URL of the descriptor file")] = None, chain_id: Annotated[int | None, Option(help="Only emit calldata descriptors for given chain ID")] = None, + v2: Annotated[bool, Option("--v2", help="Force v2 mode")] = False, ) -> None: - input_descriptor = InputERC7730Descriptor.load(input_erc7730_path) + source_url = HttpUrl(source) if source is not None else None + + if v2 or _any_v2_descriptor([input_erc7730_path]): + from erc7730.model.input.v2.descriptor import InputERC7730Descriptor as InputERC7730DescriptorV2 - model = RootModel[list[CalldataDescriptor]]( - erc7730_descriptor_to_calldata_descriptors( - input_descriptor, source=HttpUrl(source) if source is not None else None, chain_id=chain_id + 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 ) - ) + else: + input_descriptor = InputERC7730Descriptor.load(input_erc7730_path) + calldata_descriptors = erc7730_descriptor_to_calldata_descriptors( + input_descriptor, source=source_url, chain_id=chain_id + ) + + model = RootModel[list[CalldataDescriptor]](calldata_descriptors) builtins.print(model.model_dump_json(indent=2, exclude_none=True)) From f46bc4496b5ffc9ad5a1623d1d2d5a2ef02cb3c4 Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Fri, 13 Feb 2026 19:00:40 +0100 Subject: [PATCH 12/15] feat: add v2 support to `resolve` command, guard `erc7730-to-eip712` - `erc7730 resolve` now auto-detects v2 descriptors and uses the v2 resolver (or accepts --v2 flag). - `erc7730 convert erc7730-to-eip712` now detects v2 descriptors early and exits with a clear error: v2 drops embedded EIP-712 schemas, so conversion to legacy format is not supported. --- src/erc7730/main.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/erc7730/main.py b/src/erc7730/main.py index 01edbcf..e0a367f 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -158,11 +158,23 @@ def command_format( ) def command_resolve( input_path: Annotated[Path, Argument(help="The input ERC-7730 file path")], + v2: Annotated[bool, Option("--v2", help="Force v2 mode")] = False, ) -> None: - input_descriptor = InputERC7730Descriptor.load(input_path) - if (resolved_descriptor := ERC7730InputToResolved().convert(input_descriptor, ConsoleOutputAdder())) is None: - raise Exit(1) - builtins.print(resolved_descriptor.to_json_string()) + if v2 or _any_v2_descriptor([input_path]): + from erc7730.convert.resolved.v2.convert_erc7730_input_to_resolved import ( + ERC7730InputToResolved as ERC7730InputToResolvedV2, + ) + from erc7730.model.input.v2.descriptor import InputERC7730Descriptor as InputERC7730DescriptorV2 + + input_descriptor_v2 = InputERC7730DescriptorV2.load(input_path) + if (resolved := ERC7730InputToResolvedV2().convert(input_descriptor_v2, ConsoleOutputAdder())) is None: + raise Exit(1) + builtins.print(resolved.to_json_string()) + else: + input_descriptor = InputERC7730Descriptor.load(input_path) + if (resolved := ERC7730InputToResolved().convert(input_descriptor, ConsoleOutputAdder())) is None: + raise Exit(1) + builtins.print(resolved.to_json_string()) @app.command( @@ -274,6 +286,11 @@ def command_convert_erc7730_to_eip712( input_erc7730_path: Annotated[Path, Argument(help="The input ERC-7730 file path")], output_eip712_path: Annotated[Path, Argument(help="The output EIP-712 file path")], ) -> None: + if _any_v2_descriptor([input_erc7730_path]): + print("[red]v2 descriptors do not embed EIP-712 schemas; conversion to legacy EIP-712 format is not " + "supported. Please use a v1 descriptor.[/red]") + raise Exit(1) + input_descriptor = InputERC7730Descriptor.load(input_erc7730_path) resolved_descriptor = ERC7730InputToResolved().convert(input_descriptor, ConsoleOutputAdder()) if resolved_descriptor is None or not convert_to_file_and_print_errors( From 80f93c9799c6882a64a74b20d633a9d2ccc235f4 Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Fri, 13 Feb 2026 21:16:58 +0100 Subject: [PATCH 13/15] feat: add v2 support to `erc7730 convert erc7730-to-eip712` command Reconstruct EIP-712 schemas from v2 descriptors where the embedded schemas have been removed. The display.formats keys (encodeType strings) are parsed back into full EIP-712 type definitions and the EIP712Domain is rebuilt from the domain/deployment info. - Add `parse_encode_type()` utility to reverse encodeType strings - New `ERC7730V2toEIP712Converter` for v2 -> legacy EIP-712 conversion - Update `command_convert_erc7730_to_eip712` to dispatch to v2 converter - Add `validate_eip712_domain_fields()` to warn on non-standard or mis-ordered EIP712Domain fields in v1 descriptors - V2 domain reconstruction emits fields in canonical EIP-712 order: name, version, chainId, verifyingContract, salt --- src/erc7730/common/abi.py | 36 ++ .../eip712/convert_erc7730_to_eip712.py | 53 ++ .../eip712/convert_erc7730_v2_to_eip712.py | 573 ++++++++++++++++++ src/erc7730/main.py | 35 +- 4 files changed, 686 insertions(+), 11 deletions(-) create mode 100644 src/erc7730/convert/ledger/eip712/convert_erc7730_v2_to_eip712.py diff --git a/src/erc7730/common/abi.py b/src/erc7730/common/abi.py index 8067dc9..919b256 100644 --- a/src/erc7730/common/abi.py +++ b/src/erc7730/common/abi.py @@ -1,3 +1,4 @@ +import re from dataclasses import dataclass from enum import StrEnum, auto from typing import Any, cast @@ -149,6 +150,41 @@ def get_functions(abis: list[ABI], *, include_read_only: bool = False) -> Functi return functions +_ENCODE_TYPE_RE = re.compile(r"(\w+)\(([^)]*)\)") + + +def parse_encode_type(encode_type: str) -> tuple[str, dict[str, list[tuple[str, str]]]]: + """Parse an EIP-712 encodeType string into primaryType and types dict. + + The encodeType format is: ``PrimaryType(type1 name1,type2 name2,...)DependentType(...)`` + + This is the inverse of the ``generateEncodeType`` function in the migration script. + + :param encode_type: an EIP-712 encodeType string + :return: a tuple of (primaryType, types) where types is a dict mapping type names to lists of (type, name) tuples + :raises ValueError: if the string cannot be parsed + """ + matches = _ENCODE_TYPE_RE.findall(encode_type) + if not matches: + raise ValueError(f"Invalid encodeType string: {encode_type}") + + primary_type = matches[0][0] + types: dict[str, list[tuple[str, str]]] = {} + + for type_name, fields_str in matches: + fields: list[tuple[str, str]] = [] + if fields_str.strip(): + for field in fields_str.split(","): + parts = field.strip().split(" ", 1) + if len(parts) == 2: + fields.append((parts[0], parts[1])) + else: + raise ValueError(f"Invalid field in encodeType '{type_name}': '{field.strip()}'") + types[type_name] = fields + + return primary_type, types + + class ABIDataType(StrEnum): """Solidity data type.""" 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 49159c0..36223e4 100644 --- a/src/erc7730/convert/ledger/eip712/convert_erc7730_to_eip712.py +++ b/src/erc7730/convert/ledger/eip712/convert_erc7730_to_eip712.py @@ -27,6 +27,56 @@ ResolvedValuePath, ) +# EIP-712 canonical domain field order and types as specified in the EIP-712 standard. +# See https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator +EIP712_DOMAIN_CANONICAL_ORDER: list[tuple[str, str]] = [ + ("name", "string"), + ("version", "string"), + ("chainId", "uint256"), + ("verifyingContract", "address"), + ("salt", "bytes32"), +] + +_EIP712_DOMAIN_KNOWN_NAMES = {name for name, _ in EIP712_DOMAIN_CANONICAL_ORDER} + + +def validate_eip712_domain_fields( + domain_fields: list[EIP712SchemaField], + out: OutputAdder, +) -> None: + """Validate the EIP712Domain type fields against the canonical EIP-712 order. + + Emits warnings for: + * Fields not in the canonical list (name, version, chainId, verifyingContract, salt). + * Fields that are out of the EIP-712 canonical order. + + :param domain_fields: the EIP712Domain fields from the schema + :param out: warning handler + """ + canonical_order = [name for name, _ in EIP712_DOMAIN_CANONICAL_ORDER] + + # Check for unknown fields + for field in domain_fields: + if field.name not in _EIP712_DOMAIN_KNOWN_NAMES: + out.warning( + title="Non-standard EIP712Domain field", + message=f'EIP712Domain field "{field.name}" is not part of the EIP-712 standard ' + f"(expected: {', '.join(canonical_order)}).", # no brackets — rich interprets them as tags + ) + + # Check ordering: filter to only known fields and verify they appear in canonical order + known_field_names = [f.name for f in domain_fields if f.name in _EIP712_DOMAIN_KNOWN_NAMES] + canonical_positions = {name: i for i, name in enumerate(canonical_order)} + expected_order = sorted(known_field_names, key=lambda n: canonical_positions[n]) + + if known_field_names != expected_order: + out.warning( + title="EIP712Domain field order", + message=f"EIP712Domain fields are not in the canonical EIP-712 order. " + f"Found: ({', '.join(known_field_names)}), " + f"expected: ({', '.join(expected_order)}).", + ) + @final class ERC7730toEIP712Converter(ERC7730Converter[ResolvedERC7730Descriptor, InputEIP712DAppDescriptor]): @@ -114,6 +164,9 @@ def _get_schema( ) -> dict[str, list[EIP712SchemaField]] | None: for schema in schemas: if schema.primaryType == primary_type: + # Validate EIP712Domain field ordering and names + if "EIP712Domain" in schema.types: + validate_eip712_domain_fields(schema.types["EIP712Domain"], out) return schema.types return out.error(f"schema for type {primary_type} not found") 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 new file mode 100644 index 0000000..229b410 --- /dev/null +++ b/src/erc7730/convert/ledger/eip712/convert_erc7730_v2_to_eip712.py @@ -0,0 +1,573 @@ +""" +Conversion of v2 ERC-7730 input descriptors to Ledger legacy EIP-712 descriptors. + +In v2, the EIP-712 schemas are no longer embedded in ``context.eip712.schemas``. Instead, the +``display.formats`` keys are **encodeType** strings (e.g. +``Order(address owner,Bridge bridge)Bridge(bytes4 sel,uint256 chainId)``) from which the full +EIP-712 schema can be reconstructed. + +This module provides: + +* ``parse_encode_type`` (from ``common.abi``): reverses an encodeType string into + ``(primaryType, types_dict)`` +* ``_reconstruct_eip712_domain``: rebuilds the ``EIP712Domain`` type from the v2 resolved domain + and deployment information +* ``ERC7730V2toEIP712Converter``: the converter class that ties everything together +""" + +from typing import final + +from eip712.model.input.contract import InputEIP712Contract +from eip712.model.input.descriptor import InputEIP712DAppDescriptor +from eip712.model.input.message import InputEIP712Mapper, InputEIP712MapperField, InputEIP712Message +from eip712.model.schema import EIP712SchemaField +from eip712.model.types import EIP712Format, EIP712NameSource, EIP712NameType + +from erc7730.common.abi import parse_encode_type +from erc7730.common.ledger import ledger_network_id +from erc7730.common.output import ConsoleOutputAdder, ExceptionsToOutput, OutputAdder +from erc7730.convert.resolved.v2.convert_erc7730_input_to_resolved import ERC7730InputToResolved +from erc7730.model.display import AddressNameType +from erc7730.model.input.v2.context import InputEIP712Context +from erc7730.model.input.v2.descriptor import InputERC7730Descriptor +from erc7730.model.input.v2.format import FieldFormat +from erc7730.model.paths import ContainerPath, DataPath +from erc7730.model.paths.path_ops import to_relative +from erc7730.model.paths.path_parser import to_path +from erc7730.model.resolved.v2.context import ResolvedDeployment, ResolvedDomain, ResolvedEIP712Context +from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor +from erc7730.model.resolved.v2.display import ( + ResolvedAddressNameParameters, + ResolvedCallDataParameters, + ResolvedField, + ResolvedFieldDescription, + ResolvedFieldGroup, + ResolvedTokenAmountParameters, +) + + +# --------------------------------------------------------------------------- +# Domain reconstruction +# --------------------------------------------------------------------------- + + +def _reconstruct_eip712_domain( + domain: ResolvedDomain | None, + has_deployments: bool, + out: OutputAdder, +) -> list[EIP712SchemaField]: + """Reconstruct the ``EIP712Domain`` type fields from v2 resolved domain info. + + Fields are emitted in the canonical EIP-712 order: + ``name``, ``version``, ``chainId``, ``verifyingContract``, ``salt``. + + Rules (as specified): + * Always add ``name`` (string) and ``version`` (string); warn if absent in domain. + * If deployments exist, always add ``chainId`` (uint256) and ``verifyingContract`` (address). + + :param domain: the resolved domain, or ``None`` + :param has_deployments: whether the descriptor has a ``deployments`` array + :param out: error / warning handler + :return: list of ``EIP712SchemaField`` for the ``EIP712Domain`` type, in canonical order + """ + fields: list[EIP712SchemaField] = [] + + # 1. name (always present) + if domain is None or domain.name is None: + out.warning( + title="Missing domain name", + message="EIP-712 domain 'name' is not set in the descriptor; adding to schema with type 'string' anyway.", + ) + fields.append(EIP712SchemaField(name="name", type="string")) + + # 2. version (always present) + if domain is None or domain.version is None: + out.warning( + title="Missing domain version", + message="EIP-712 domain 'version' is not set in the descriptor; adding to schema with type 'string' anyway.", + ) + fields.append(EIP712SchemaField(name="version", type="string")) + + # 3. chainId + 4. verifyingContract (only if deployments are present) + if has_deployments: + fields.append(EIP712SchemaField(name="chainId", type="uint256")) + fields.append(EIP712SchemaField(name="verifyingContract", type="address")) + + return fields + + +# --------------------------------------------------------------------------- +# Schema reconstruction from encodeType strings +# --------------------------------------------------------------------------- + + +def _build_schema( + encode_type_key: str, + domain_fields: list[EIP712SchemaField], + out: OutputAdder, +) -> tuple[str, dict[str, list[EIP712SchemaField]]] | None: + """Build an EIP-712 schema (types dict) from an encodeType format key. + + :param encode_type_key: an encodeType string from ``display.formats`` + :param domain_fields: pre-built ``EIP712Domain`` type fields + :param out: error handler + :return: (primaryType, types dict) or ``None`` on error + """ + try: + primary_type, raw_types = parse_encode_type(encode_type_key) + except ValueError as e: + out.error( + title="Invalid encodeType", + message=f"Failed to parse format key as encodeType: {e}", + ) + return None + + types: dict[str, list[EIP712SchemaField]] = {} + + # Add EIP712Domain + types["EIP712Domain"] = domain_fields + + # Add parsed types + for type_name, field_tuples in raw_types.items(): + types[type_name] = [EIP712SchemaField(name=name, type=typ) for typ, name in field_tuples] + + return primary_type, types + + +# --------------------------------------------------------------------------- +# V2 field conversion helpers +# --------------------------------------------------------------------------- + + +def _resolve_path_string(path_str: str) -> str: + """Convert a v2 resolved path string to a relative path string suitable for the legacy EIP-712 format. + + :param path_str: v2 path string (e.g. ``#.amount``, ``@.from``) + :return: relative path string (e.g. ``amount``, ``@.from``) + """ + parsed = to_path(path_str) + match parsed: + case DataPath(): + return str(to_relative(parsed)) + case ContainerPath(): + return str(parsed) + case _: + return path_str + + +def _convert_v2_field( + field: ResolvedField, + out: OutputAdder, +) -> list[InputEIP712MapperField] | None: + """Convert a v2 resolved field to legacy EIP-712 mapper field(s). + + :param field: a v2 resolved field + :param out: error handler + :return: list of mapper fields, or ``None`` on error + """ + if isinstance(field, ResolvedFieldDescription): + # Skip hidden fields (equivalent of v1 "excluded") + if field.visible == "never": + return [] + + if (output_field := _convert_v2_field_description(field, out)) is None: + return None + return [output_field] + + elif isinstance(field, ResolvedFieldGroup): + output_fields: list[InputEIP712MapperField] = [] + for nested_field in field.fields: + if (nested_output := _convert_v2_field(nested_field, out)) is None: + return None + output_fields.extend(nested_output) + return output_fields + + else: + return out.error( + title="Unknown field type", + message=f"Unexpected resolved field type: {type(field)}", + ) + + +def _convert_v2_field_description( + field: ResolvedFieldDescription, + out: OutputAdder, +) -> InputEIP712MapperField | None: + """Convert a single v2 resolved field description to a legacy EIP-712 mapper field. + + This adapts the v1 ``ERC7730toEIP712Converter.convert_field_description`` to work with v2 + resolved models where paths are strings and parameters use v2 model types. + """ + + # --- Path extraction --- + if field.value is not None: + return out.error( + title="Constant values not supported", + message="Constant values cannot be converted to legacy EIP-712 fields.", + ) + + if field.path is None: + return out.error( + title="Missing path", + message="Field has neither path nor value.", + ) + + field_path_str = str(field.path) + + # Check that the path is a data path (not a container path) + parsed_path = to_path(field_path_str) + if isinstance(parsed_path, ContainerPath): + return out.error( + title="Unsupported path", + message=f'Container path "{field_path_str}" is not supported in EIP-712 conversion.', + ) + if not isinstance(parsed_path, DataPath): + return out.error( + title="Unsupported path type", + message=f'Path "{field_path_str}" is not a data path.', + ) + + relative_path = str(to_relative(parsed_path)) + + # --- Format mapping --- + asset_path: str | None = None + field_format: EIP712Format | None = None + + match field.format: + case None: + field_format = None + case FieldFormat.ADDRESS_NAME: + field_format = EIP712Format.TRUSTED_NAME + case FieldFormat.RAW: + field_format = EIP712Format.RAW + case FieldFormat.ENUM: + field_format = EIP712Format.RAW + case FieldFormat.UNIT: + field_format = EIP712Format.RAW + case FieldFormat.DURATION: + field_format = EIP712Format.RAW + case FieldFormat.NFT_NAME: + field_format = EIP712Format.TRUSTED_NAME + case FieldFormat.CALL_DATA: + field_format = EIP712Format.CALLDATA + case FieldFormat.DATE: + field_format = EIP712Format.DATETIME + 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): + if field.params.tokenPath is not None: + token_path_str = str(field.params.tokenPath) + token_parsed = to_path(token_path_str) + if isinstance(token_parsed, ContainerPath) and str(token_parsed) == "@.to": + # In EIP-712 protocol, format=token with no token path => refers to verifyingContract + asset_path = None + elif isinstance(token_parsed, DataPath): + asset_path = str(to_relative(token_parsed)) + else: + return out.error( + title="Unsupported token path", + message=f'Token path "{token_path_str}" is not supported.', + ) + elif field.params.token is not None: + # token is a resolved constant address -- cannot be represented in legacy format + return out.error( + title="Constant token not supported", + message="Constant token addresses cannot be converted to legacy EIP-712 fields.", + ) + else: + return out.error( + title="Missing token", + message="Token path or reference must be set for tokenAmount format.", + ) + case FieldFormat.INTEROPERABLE_ADDRESS_NAME: + field_format = EIP712Format.TRUSTED_NAME + case FieldFormat.TOKEN_TICKER: + field_format = EIP712Format.RAW + case FieldFormat.CHAIN_ID: + field_format = EIP712Format.RAW + case _: + return out.error( + title="Unsupported format", + message=f'Field format "{field.format}" is not supported for EIP-712 conversion.', + ) + + # --- Trusted names --- + name_types: list[EIP712NameType] | None = None + name_sources: list[EIP712NameSource] | None = None + + if ( + field_format == EIP712Format.TRUSTED_NAME + and field.params is not None + and isinstance(field.params, ResolvedAddressNameParameters) + ): + name_types = _convert_trusted_names_types(field.params.types) + name_sources = _convert_trusted_names_sources(field.params.sources, name_types) + + # --- Calldata params --- + callee_path: str | None = None + chainid_path: str | None = None + selector_path: str | None = None + amount_path: str | None = None + spender_path: str | None = None + + if ( + field_format == EIP712Format.CALLDATA + and field.params is not None + and isinstance(field.params, ResolvedCallDataParameters) + ): + try: + callee_path = _resolve_calldata_param_path(field.params.calleePath) + chainid_path = _resolve_calldata_param_path( + str(field.params.chainId) if field.params.chainId is not None else None # type: ignore[arg-type] + ) + selector_path = _resolve_calldata_param_path( + str(field.params.selectorPath) if field.params.selectorPath is not None else None + ) + amount_path = _resolve_calldata_param_path( + str(field.params.amountPath) if field.params.amountPath is not None else None + ) + spender_path = _resolve_calldata_param_path( + str(field.params.spenderPath) if field.params.spenderPath is not None else None + ) + except ValueError as e: + return out.error( + title="Calldata param error", + message=str(e), + ) + + return InputEIP712MapperField( + path=relative_path, + label=field.label, + assetPath=asset_path, + format=field_format, + nameTypes=name_types, + nameSources=name_sources, + calleePath=callee_path, + chainIdPath=chainid_path, + selectorPath=selector_path, + amountPath=amount_path, + spenderPath=spender_path, + ) + + +def _resolve_calldata_param_path(path_str: str | None) -> str | None: + """Resolve a calldata parameter path string for the legacy format.""" + if path_str is None: + return None + parsed = to_path(path_str) + if isinstance(parsed, DataPath): + return str(to_relative(parsed)) + if isinstance(parsed, ContainerPath) and str(parsed) == "@.to": + return "@.to" + raise ValueError(f'Path "{path_str}" is not supported for calldata parameter conversion.') + + +def _convert_trusted_names_types(types: list[AddressNameType] | None) -> list[EIP712NameType] | None: + """Convert v2 address name types to legacy EIP-712 name types.""" + if types is None: + return None + + name_types: list[EIP712NameType] = [] + for name_type in types: + match name_type: + case AddressNameType.WALLET: + name_types.append(EIP712NameType.WALLET) + case AddressNameType.EOA: + name_types.append(EIP712NameType.EOA) + case AddressNameType.CONTRACT: + name_types.append(EIP712NameType.SMART_CONTRACT) + case AddressNameType.TOKEN: + name_types.append(EIP712NameType.TOKEN) + case AddressNameType.COLLECTION: + name_types.append(EIP712NameType.COLLECTION) + case _: + name_types.append(EIP712NameType(str(name_type))) + return name_types + + +def _convert_trusted_names_sources( + sources: list[str] | None, names: list[EIP712NameType] | None +) -> list[EIP712NameSource] | None: + """Convert v2 trusted name sources to legacy EIP-712 name sources.""" + if sources is None: + return None + name_sources: list[EIP712NameSource] = [] + + if names is not None: + for name in names: + match name: + case EIP712NameType.EOA | EIP712NameType.WALLET | EIP712NameType.COLLECTION: + name_sources.append(EIP712NameSource.ENS) + name_sources.append(EIP712NameSource.UNSTOPPABLE_DOMAIN) + name_sources.append(EIP712NameSource.FREENAME) + case EIP712NameType.SMART_CONTRACT | EIP712NameType.TOKEN: + name_sources.append(EIP712NameSource.CRYPTO_ASSET_LIST) + case EIP712NameType.CONTEXT_ADDRESS: + name_sources.append(EIP712NameSource.DYNAMIC_RESOLVER) + case _: + pass + + for name_source in sources: + if name_source == "local": + name_sources.append(EIP712NameSource.LOCAL_ADDRESS_BOOK) + elif name_source in set(EIP712NameSource) and name_source not in name_sources: + name_sources.append(EIP712NameSource(name_source)) + + if not name_sources: + name_sources = list(EIP712NameSource) + return name_sources + + +# --------------------------------------------------------------------------- +# Converter class +# --------------------------------------------------------------------------- + + +@final +class ERC7730V2toEIP712Converter: + """ + Converts a v2 ERC-7730 input descriptor with EIP-712 context to Ledger legacy EIP-712 descriptors. + + The conversion: + 1. Resolves the v2 input descriptor. + 2. Reconstructs ``EIP712Domain`` from domain info + deployments. + 3. Parses each ``display.formats`` key (an encodeType string) to rebuild the per-message schema. + 4. Converts v2 resolved display fields to ``InputEIP712MapperField``. + 5. Produces one ``InputEIP712DAppDescriptor`` per chain id. + """ + + def convert( + self, + input_descriptor: InputERC7730Descriptor, + out: OutputAdder | None = None, + ) -> dict[str, InputEIP712DAppDescriptor] | None: + """Convert a v2 input descriptor to legacy EIP-712 descriptors. + + :param input_descriptor: a deserialized v2 input ERC-7730 descriptor + :param out: error / warning handler (defaults to console) + :return: dict mapping chain id strings to legacy descriptors, or ``None`` on error + """ + if out is None: + out = ConsoleOutputAdder() + + with ExceptionsToOutput(out): + # Verify context is EIP-712 + if not isinstance(input_descriptor.context, InputEIP712Context): + return out.error( + title="Wrong context type", + message="Descriptor context is not EIP-712; only EIP-712 descriptors can be converted.", + ) + + # Resolve the v2 descriptor + resolved = ERC7730InputToResolved().convert(input_descriptor, out) + if resolved is None: + return None + + return self._convert_resolved(resolved, out) + + return None + + def _convert_resolved( + self, + descriptor: ResolvedERC7730Descriptor, + out: OutputAdder, + ) -> dict[str, InputEIP712DAppDescriptor] | None: + context = descriptor.context + if not isinstance(context, ResolvedEIP712Context): + return out.error( + title="Wrong context type", + message="Resolved context is not EIP-712.", + ) + + domain = context.eip712.domain + has_deployments = len(context.eip712.deployments) > 0 + + # Get dapp name from domain + dapp_name: str | None = domain.name if domain is not None else None + if dapp_name is None: + return out.error( + title="Missing domain name", + message="EIP-712 domain name is required for legacy EIP-712 conversion.", + ) + + # Get contract name from metadata + contract_name = descriptor.metadata.owner + if contract_name is None: + return out.error( + title="Missing owner", + message="metadata.owner is required for legacy EIP-712 conversion.", + ) + + # Reconstruct EIP712Domain type + domain_fields = _reconstruct_eip712_domain(domain, has_deployments, out) + + # Build messages from format keys + messages: list[InputEIP712Message] = [] + for format_key, format_def in descriptor.display.formats.items(): + # Parse the encodeType key into primaryType + types dict + schema_result = _build_schema(format_key, domain_fields, out) + if schema_result is None: + return None + _primary_type, schema_types = schema_result + + # Convert fields + output_fields: list[InputEIP712MapperField] = [] + for field in format_def.fields: + if (converted := _convert_v2_field(field, out)) is None: + return None + output_fields.extend(converted) + + label = format_def.intent if isinstance(format_def.intent, str) else _primary_type + + messages.append( + InputEIP712Message( + schema=schema_types, + mapper=InputEIP712Mapper(label=label, fields=output_fields), + ) + ) + + # Build per-chain descriptors + descriptors: dict[str, InputEIP712DAppDescriptor] = {} + for deployment in context.eip712.deployments: + chain_id = str(deployment.chainId) + output_descriptor = self._build_network_descriptor( + deployment, dapp_name, contract_name, messages, descriptors.get(chain_id), out + ) + if output_descriptor is not None: + descriptors[chain_id] = output_descriptor + + return descriptors + + @staticmethod + def _build_network_descriptor( + deployment: ResolvedDeployment, + dapp_name: str, + contract_name: str, + messages: list[InputEIP712Message], + descriptor: InputEIP712DAppDescriptor | None, + out: OutputAdder, + ) -> InputEIP712DAppDescriptor | None: + if (network := ledger_network_id(deployment.chainId)) is None: + out.error( + title="Unsupported network", + message=f"Network id {deployment.chainId} not supported.", + ) + return descriptor + + contracts = descriptor.contracts if descriptor is not None else [] + contracts.append( + InputEIP712Contract( + address=deployment.address.lower(), + contractName=contract_name, + messages=messages, + ) + ) + + return InputEIP712DAppDescriptor( + blockchainName=network, + chainId=deployment.chainId, + name=dapp_name, + contracts=contracts, + ) diff --git a/src/erc7730/main.py b/src/erc7730/main.py index e0a367f..84f0cf4 100644 --- a/src/erc7730/main.py +++ b/src/erc7730/main.py @@ -287,15 +287,28 @@ def command_convert_erc7730_to_eip712( output_eip712_path: Annotated[Path, Argument(help="The output EIP-712 file path")], ) -> None: if _any_v2_descriptor([input_erc7730_path]): - print("[red]v2 descriptors do not embed EIP-712 schemas; conversion to legacy EIP-712 format is not " - "supported. Please use a v1 descriptor.[/red]") - raise Exit(1) + from erc7730.common.pydantic import model_to_json_file + from erc7730.convert.ledger.eip712.convert_erc7730_v2_to_eip712 import ERC7730V2toEIP712Converter + from erc7730.model.input.v2.descriptor import InputERC7730Descriptor as InputERC7730DescriptorV2 - input_descriptor = InputERC7730Descriptor.load(input_erc7730_path) - resolved_descriptor = ERC7730InputToResolved().convert(input_descriptor, ConsoleOutputAdder()) - if resolved_descriptor is None or not convert_to_file_and_print_errors( - input_descriptor=resolved_descriptor, - output_file=output_eip712_path, - converter=ERC7730toEIP712Converter(), - ): - raise Exit(1) + input_descriptor_v2 = InputERC7730DescriptorV2.load(input_erc7730_path) + out = ConsoleOutputAdder() + result = ERC7730V2toEIP712Converter().convert(input_descriptor_v2, out) + if result is None: + print("[red]conversion failed ❌[/red]") + raise Exit(1) + + # Write output files using the same pattern as v1 (chain id suffix) + for identifier, descriptor in result.items(): + descriptor_file = output_eip712_path.with_suffix(f".{identifier}{output_eip712_path.suffix}") + model_to_json_file(descriptor_file, descriptor) + print(f"[green]generated {descriptor_file} ✅[/green]") + else: + input_descriptor = InputERC7730Descriptor.load(input_erc7730_path) + resolved_descriptor = ERC7730InputToResolved().convert(input_descriptor, ConsoleOutputAdder()) + if resolved_descriptor is None or not convert_to_file_and_print_errors( + input_descriptor=resolved_descriptor, + output_file=output_eip712_path, + converter=ERC7730toEIP712Converter(), + ): + raise Exit(1) From 7fe1fb91136c4e9cc9c15f5c237ffbcdf56f304c Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Sat, 14 Feb 2026 09:34:37 +0100 Subject: [PATCH 14/15] feat: simplify v2 EIP712Domain reconstruction and add salt support - Emit name, version, salt only when present in domain (instead of always) - Emit chainId + verifyingContract when deployments exist (unchanged) - Add salt field to v2 InputDomain and ResolvedDomain models - Propagate salt through the resolve step --- .../eip712/convert_erc7730_v2_to_eip712.py | 32 ++++++++----------- .../v2/convert_erc7730_input_to_resolved.py | 1 + src/erc7730/model/input/v2/context.py | 2 ++ src/erc7730/model/resolved/v2/context.py | 2 ++ 4 files changed, 19 insertions(+), 18 deletions(-) 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 229b410..b07c68a 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 @@ -61,9 +61,9 @@ def _reconstruct_eip712_domain( Fields are emitted in the canonical EIP-712 order: ``name``, ``version``, ``chainId``, ``verifyingContract``, ``salt``. - Rules (as specified): - * Always add ``name`` (string) and ``version`` (string); warn if absent in domain. - * If deployments exist, always add ``chainId`` (uint256) and ``verifyingContract`` (address). + Rules: + * Emit ``name`` (string), ``version`` (string) and ``salt`` (bytes32) if present in the domain. + * If deployments exist, emit ``chainId`` (uint256) and ``verifyingContract`` (address). :param domain: the resolved domain, or ``None`` :param has_deployments: whether the descriptor has a ``deployments`` array @@ -72,27 +72,23 @@ def _reconstruct_eip712_domain( """ fields: list[EIP712SchemaField] = [] - # 1. name (always present) - if domain is None or domain.name is None: - out.warning( - title="Missing domain name", - message="EIP-712 domain 'name' is not set in the descriptor; adding to schema with type 'string' anyway.", - ) - fields.append(EIP712SchemaField(name="name", type="string")) + # 1. name (if present in domain) + if domain is not None and domain.name is not None: + fields.append(EIP712SchemaField(name="name", type="string")) - # 2. version (always present) - if domain is None or domain.version is None: - out.warning( - title="Missing domain version", - message="EIP-712 domain 'version' is not set in the descriptor; adding to schema with type 'string' anyway.", - ) - fields.append(EIP712SchemaField(name="version", type="string")) + # 2. version (if present in domain) + if domain is not None and domain.version is not None: + fields.append(EIP712SchemaField(name="version", type="string")) - # 3. chainId + 4. verifyingContract (only if deployments are present) + # 3. chainId + 4. verifyingContract (if deployments exist) if has_deployments: fields.append(EIP712SchemaField(name="chainId", type="uint256")) fields.append(EIP712SchemaField(name="verifyingContract", type="address")) + # 5. salt (if present in domain) + if domain is not None and domain.salt is not None: + fields.append(EIP712SchemaField(name="salt", type="bytes32")) + return fields 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 5feb55e..f31dccf 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 @@ -244,6 +244,7 @@ def _resolve_domain(cls, domain: InputDomain, out: OutputAdder) -> ResolvedDomai version=domain.version, chainId=domain.chainId, verifyingContract=None if domain.verifyingContract is None else Address(domain.verifyingContract), + salt=domain.salt, ) @classmethod diff --git a/src/erc7730/model/input/v2/context.py b/src/erc7730/model/input/v2/context.py index 3b0ec74..dc5bb47 100644 --- a/src/erc7730/model/input/v2/context.py +++ b/src/erc7730/model/input/v2/context.py @@ -31,6 +31,8 @@ class InputDomain(Model): default=None, title="Verifying Contract", description="The EIP-712 verifying contract address." ) + salt: str | None = Field(default=None, title="Salt", description="The EIP-712 domain salt (bytes32 hex string).") + class InputDeployment(Model): """ diff --git a/src/erc7730/model/resolved/v2/context.py b/src/erc7730/model/resolved/v2/context.py index 56f0e37..79d4384 100644 --- a/src/erc7730/model/resolved/v2/context.py +++ b/src/erc7730/model/resolved/v2/context.py @@ -32,6 +32,8 @@ class ResolvedDomain(Model): description="The EIP-712 verifying contract address (normalized to lowercase).", ) + salt: str | None = Field(default=None, title="Salt", description="The EIP-712 domain salt (bytes32 hex string).") + class ResolvedDeployment(Model): """ From 2e642fde41b1fb0496e1d430d6e10d8185d750e0 Mon Sep 17 00:00:00 2001 From: Laurent Castillo Date: Sat, 14 Feb 2026 09:34:49 +0100 Subject: [PATCH 15/15] feat: add EIP712Domain field order validation to v1 linter - Add ValidateEIP712DomainLinter that checks EIP712Domain fields are in canonical EIP-712 order and warns about non-standard field names - Incorrect ordering is reported as a critical error - Move validate_eip712_domain_fields from converter to linter module - Reuse the validation function from the v1 converter --- .../eip712/convert_erc7730_to_eip712.py | 51 +----------- src/erc7730/lint/lint.py | 9 ++- .../lint/lint_validate_eip712_domain.py | 78 +++++++++++++++++++ 3 files changed, 87 insertions(+), 51 deletions(-) create mode 100644 src/erc7730/lint/lint_validate_eip712_domain.py 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 36223e4..4b7f6b7 100644 --- a/src/erc7730/convert/ledger/eip712/convert_erc7730_to_eip712.py +++ b/src/erc7730/convert/ledger/eip712/convert_erc7730_to_eip712.py @@ -9,6 +9,7 @@ from erc7730.common.ledger import ledger_network_id from erc7730.common.output import ExceptionsToOutput, OutputAdder from erc7730.convert import ERC7730Converter +from erc7730.lint.lint_validate_eip712_domain import validate_eip712_domain_fields from erc7730.model.context import EIP712Schema from erc7730.model.display import AddressNameType, FieldFormat from erc7730.model.paths import ContainerField, ContainerPath, DataPath @@ -27,56 +28,6 @@ ResolvedValuePath, ) -# EIP-712 canonical domain field order and types as specified in the EIP-712 standard. -# See https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator -EIP712_DOMAIN_CANONICAL_ORDER: list[tuple[str, str]] = [ - ("name", "string"), - ("version", "string"), - ("chainId", "uint256"), - ("verifyingContract", "address"), - ("salt", "bytes32"), -] - -_EIP712_DOMAIN_KNOWN_NAMES = {name for name, _ in EIP712_DOMAIN_CANONICAL_ORDER} - - -def validate_eip712_domain_fields( - domain_fields: list[EIP712SchemaField], - out: OutputAdder, -) -> None: - """Validate the EIP712Domain type fields against the canonical EIP-712 order. - - Emits warnings for: - * Fields not in the canonical list (name, version, chainId, verifyingContract, salt). - * Fields that are out of the EIP-712 canonical order. - - :param domain_fields: the EIP712Domain fields from the schema - :param out: warning handler - """ - canonical_order = [name for name, _ in EIP712_DOMAIN_CANONICAL_ORDER] - - # Check for unknown fields - for field in domain_fields: - if field.name not in _EIP712_DOMAIN_KNOWN_NAMES: - out.warning( - title="Non-standard EIP712Domain field", - message=f'EIP712Domain field "{field.name}" is not part of the EIP-712 standard ' - f"(expected: {', '.join(canonical_order)}).", # no brackets — rich interprets them as tags - ) - - # Check ordering: filter to only known fields and verify they appear in canonical order - known_field_names = [f.name for f in domain_fields if f.name in _EIP712_DOMAIN_KNOWN_NAMES] - canonical_positions = {name: i for i, name in enumerate(canonical_order)} - expected_order = sorted(known_field_names, key=lambda n: canonical_positions[n]) - - if known_field_names != expected_order: - out.warning( - title="EIP712Domain field order", - message=f"EIP712Domain fields are not in the canonical EIP-712 order. " - f"Found: ({', '.join(known_field_names)}), " - f"expected: ({', '.join(expected_order)}).", - ) - @final class ERC7730toEIP712Converter(ERC7730Converter[ResolvedERC7730Descriptor, InputEIP712DAppDescriptor]): diff --git a/src/erc7730/lint/lint.py b/src/erc7730/lint/lint.py index b72f8e9..5ef632b 100644 --- a/src/erc7730/lint/lint.py +++ b/src/erc7730/lint/lint.py @@ -19,6 +19,7 @@ from erc7730.lint.lint_transaction_type_classifier import ClassifyTransactionTypeLinter from erc7730.lint.lint_validate_abi import ValidateABILinter from erc7730.lint.lint_validate_display_fields import ValidateDisplayFieldsLinter +from erc7730.lint.lint_validate_eip712_domain import ValidateEIP712DomainLinter from erc7730.lint.lint_validate_max_length import ValidateMaxLengthLinter from erc7730.list.list import get_erc7730_files from erc7730.model.input.descriptor import InputERC7730Descriptor @@ -52,7 +53,13 @@ def lint_all(paths: list[Path], out: OutputAdder) -> int: :return: number of files checked """ linter = MultiLinter( - [ValidateABILinter(), ValidateDisplayFieldsLinter(), ClassifyTransactionTypeLinter(), ValidateMaxLengthLinter()] + [ + ValidateABILinter(), + ValidateDisplayFieldsLinter(), + ValidateEIP712DomainLinter(), + ClassifyTransactionTypeLinter(), + ValidateMaxLengthLinter(), + ] ) files = list(get_erc7730_files(*paths, out=out)) diff --git a/src/erc7730/lint/lint_validate_eip712_domain.py b/src/erc7730/lint/lint_validate_eip712_domain.py new file mode 100644 index 0000000..477a021 --- /dev/null +++ b/src/erc7730/lint/lint_validate_eip712_domain.py @@ -0,0 +1,78 @@ +from typing import final, override + +from eip712.model.schema import EIP712SchemaField + +from erc7730.common.output import OutputAdder +from erc7730.lint import ERC7730Linter +from erc7730.model.resolved.context import EIP712Schema, ResolvedEIP712Context +from erc7730.model.resolved.descriptor import ResolvedERC7730Descriptor + +# EIP-712 canonical domain field order and types as specified in the EIP-712 standard. +# See https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator +EIP712_DOMAIN_CANONICAL_ORDER: list[tuple[str, str]] = [ + ("name", "string"), + ("version", "string"), + ("chainId", "uint256"), + ("verifyingContract", "address"), + ("salt", "bytes32"), +] + +_EIP712_DOMAIN_KNOWN_NAMES = {name for name, _ in EIP712_DOMAIN_CANONICAL_ORDER} + + +def validate_eip712_domain_fields( + domain_fields: list[EIP712SchemaField], + out: OutputAdder, +) -> None: + """Validate the EIP712Domain type fields against the canonical EIP-712 order. + + Emits: + * Warning for fields not in the canonical list (name, version, chainId, verifyingContract, salt). + * Error for fields that are out of the EIP-712 canonical order. + + :param domain_fields: the EIP712Domain fields from the schema + :param out: warning handler + """ + canonical_order = [name for name, _ in EIP712_DOMAIN_CANONICAL_ORDER] + + # Check for unknown fields + for field in domain_fields: + if field.name not in _EIP712_DOMAIN_KNOWN_NAMES: + out.warning( + title="Non-standard EIP712Domain field", + message=f'EIP712Domain field "{field.name}" is not part of the EIP-712 standard ' + f"(expected: {', '.join(canonical_order)}).", # no brackets — rich interprets them as tags + ) + + # Check ordering: filter to only known fields and verify they appear in canonical order + known_field_names = [f.name for f in domain_fields if f.name in _EIP712_DOMAIN_KNOWN_NAMES] + canonical_positions = {name: i for i, name in enumerate(canonical_order)} + expected_order = sorted(known_field_names, key=lambda n: canonical_positions[n]) + + if known_field_names != expected_order: + out.error( + title="EIP712Domain field order", + message=f"EIP712Domain fields are not in the canonical EIP-712 order. " + f"Found: ({', '.join(known_field_names)}), " + f"expected: ({', '.join(expected_order)}).", + ) + + +@final +class ValidateEIP712DomainLinter(ERC7730Linter): + """Validate ``EIP712Domain`` field ordering and names in EIP-712 schemas. + + For each schema that includes an ``EIP712Domain`` type, this linter checks that: + * All fields are part of the canonical EIP-712 set (name, version, chainId, verifyingContract, salt). + * Fields appear in the canonical EIP-712 order. + """ + + @override + def lint(self, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None: + if not isinstance(descriptor.context, ResolvedEIP712Context): + return + if descriptor.context.eip712.schemas is None: + return + for schema in descriptor.context.eip712.schemas: + if isinstance(schema, EIP712Schema) and "EIP712Domain" in schema.types: + validate_eip712_domain_fields(schema.types["EIP712Domain"], out)