Source code for erc7730.model.calldata.v1.instruction

"""
Data model for calldata descriptor instructions.

These model classes represent the exact same data fields that are serialized into TLV structs.
See documentation in https://github.com/LedgerHQ/app-ethereum for specifications of this protocol
"""

from abc import ABC
from enum import IntEnum
from functools import cached_property
from typing import Annotated, Literal, Self

from pydantic import Field, computed_field, model_validator
from pydantic_string_url import HttpUrl

from erc7730.common.pydantic import pydantic_enum_by_name
from erc7730.model.calldata.v1.param import (
    CalldataDescriptorParamV1,
)
from erc7730.model.calldata.v1.struct import (
    CalldataDescriptorStructV1,
)
from erc7730.model.types import Address, HexStr, Selector

CalldataDescriptorInstructionHex = Annotated[
    HexStr,
    Field(
        title="Serialized TLV instruction payload",
        description="Serialized, hex encoded TLV payload.",
        min_length=8,
        max_length=32768,
    ),
]

CalldataDescriptorInstructionProtobuf = Annotated[
    HexStr,
    Field(
        title="Serialized production signature payload",
        description="Serialized, hex encoded Protobuf payload used for production signature.",
        min_length=8,
        max_length=32768,
    ),
]


# maximum number of CONSTRAINT tags per FIELD struct, as documented in app-ethereum
MAX_FIELD_CONSTRAINTS = 5


[docs] @pydantic_enum_by_name class CalldataDescriptorFieldVisibilityV1(IntEnum): """ Visibility of a calldata field (``VisibleType`` in the FIELD struct). ``MUST_BE`` and ``IF_NOT_IN`` require at least one constraint value; the device rejects the transaction when a ``MUST_BE`` value matches no constraint. """ ALWAYS = 0x00 MUST_BE = 0x01 IF_NOT_IN = 0x02
# extra must be ignored because of descriptor computed field
[docs] class CalldataDescriptorInstructionBaseV1(CalldataDescriptorStructV1, ABC, extra="ignore"): """Base class for calldata descriptor instructions."""
[docs] class CalldataDescriptorInstructionTransactionInfoV1(CalldataDescriptorInstructionBaseV1): """Instruction descriptor for the TRANSACTION_INFO struct.""" version: Literal[1] = Field( default=1, title="Struct version", description="Version of the TRANSACTION_INFO struct", ) chain_id: int = Field( title="Chain ID", description="The contract deployment EIP-155 chain id.", ge=1, ) address: Address = Field( title="Contract address", description="The contract deployment address.", ) selector: Selector = Field( title="Function selector", description="The 4-bytes function selector this descriptor applies to.", ) hash: str = Field( title="Structs hash", description="Hash of all the FIELD structs", pattern=r"^[a-f0-9]+$", min_length=64, max_length=64, ) operation_type: str = Field( title="Operation type", description="Displayed in review first screens", min_length=1, # No max_length, may be truncated by Ethereum app ) creator_name: str | None = Field( default=None, title="Creator name", description="Displayed in review first screens", min_length=1, # No max_length, may be truncated by Ethereum app ) creator_legal_name: str | None = Field( default=None, title="Creator legal name", description="Displayed in review first screens", min_length=1, # No max_length, may be truncated by Ethereum app ) creator_url: HttpUrl | None = Field( default=None, title="Creator URL", description="Displayed in review first screens", min_length=1, # No max_length, may be truncated by Ethereum app ) contract_name: str | None = Field( default=None, title="Contract name", description="Displayed in review first screens", min_length=1, # No max_length, may be truncated by Ethereum app ) deploy_date: str | None = Field( default=None, title="Deploy date", description="Displayed in review first screens", ) @computed_field(title="Descriptor", description="Hex encoded TRANSACTION_INFO TLV struct") # type: ignore[misc] @cached_property def descriptor(self) -> CalldataDescriptorInstructionHex: from erc7730.convert.calldata.v1.tlv import ( tlv_transaction_info, ) return tlv_transaction_info(self).hex()
[docs] class CalldataDescriptorInstructionEnumValueV1(CalldataDescriptorInstructionBaseV1): """Instruction descriptor for the ENUM_VALUE struct.""" version: Literal[1] = Field( default=1, title="Struct version", description="Version of the ENUM struct", ) chain_id: int = Field( title="Chain ID", description="The contract deployment EIP-155 chain id.", ge=1, ) address: Address = Field( title="Contract address", description="The contract deployment address.", ) selector: Selector = Field( title="Function selector", description="The 4-bytes function selector this descriptor applies to.", ) enum_id: str = Field( title="Source enum identifier", description="Source identifier of the enum (to differentiate multiple enums in one contract)", ) id: int = Field( title="Enum identifier", description="Identifier of the enum (to differentiate multiple enums in one contract)", ge=0, le=255, ) value: int = Field( title="Enum entry value", description="Identifier of this specific entry (ordinal of the entry, type agnostic)", ge=0, le=255, ) name: str = Field( title="Enum entry name", description="Enum display name (ASCII)", min_length=1, # No max_length, may be truncated by Ethereum app ) @computed_field(title="Descriptor", description="Hex encoded ENUM TLV struct") # type: ignore[misc] @cached_property def descriptor(self) -> CalldataDescriptorInstructionHex: from erc7730.convert.calldata.v1.tlv import tlv_enum_value return tlv_enum_value(self).hex()
[docs] class CalldataDescriptorInstructionFieldV1(CalldataDescriptorInstructionBaseV1): """Instruction descriptor for the FIELD struct.""" version: Literal[1] = Field( default=1, title="Struct version", description="Version of the FIELD struct", ) name: str = Field( title="Field name", description="Field display name (ASCII)", min_length=1, # No max_length, may be truncated by Ethereum app ) param: CalldataDescriptorParamV1 = Field( title="Field parameter", description="Parameter of the field", ) visibility: CalldataDescriptorFieldVisibilityV1 = Field( default=CalldataDescriptorFieldVisibilityV1.ALWAYS, title="Field visibility", description="Whether the field is displayed, and whether its value is constrained", ) constraints: list[HexStr] | None = Field( default=None, title="Field value constraints", description="Constraint values (raw bytes, hex encoded) checked against the field value, " "with OR semantics. Only meaningful when visibility is MUST_BE or IF_NOT_IN.", ) @model_validator(mode="after") def _validate_constraints(self) -> Self: constraints = self.constraints or [] if self.visibility is CalldataDescriptorFieldVisibilityV1.ALWAYS: if constraints: raise ValueError("Constraints are only allowed with MUST_BE or IF_NOT_IN visibility.") return self if not constraints: raise ValueError(f"Visibility {self.visibility.name} requires at least one constraint.") if len(constraints) > MAX_FIELD_CONSTRAINTS: raise ValueError(f"At most {MAX_FIELD_CONSTRAINTS} constraints are allowed, got {len(constraints)}.") for constraint in constraints: # a constraint is a byte array, and an odd number of digits would only fail later when # the TLV payload is serialized digits = constraint.removeprefix("0x") if len(digits) % 2 != 0: raise ValueError(f"Constraint value must be whole bytes, got {len(digits)} hex digits.") # size is encoded on a single byte by the device, and an empty value is rejected size = len(digits) // 2 if not 1 <= size <= 255: raise ValueError(f"Constraint value must be 1 to 255 bytes, got {size}.") return self @computed_field(title="Descriptor", description="Hex encoded FIELD TLV struct") # type: ignore[misc] @cached_property def descriptor(self) -> CalldataDescriptorInstructionHex: from erc7730.convert.calldata.v1.tlv import tlv_field return tlv_field(self).hex()