Source code for erc7730.convert.calldata.v1.map

"""
Conversion of map lookups to device side maps: a MAP_REF value, looked up by the device in MAP_ENTRY structs.

The device reads the key from the transaction, then looks up the MAP_ENTRY provided by the wallet whose key matches it
byte for byte, so each map key is encoded exactly as the device reads the key value:
 - a static calldata value is a whole calldata chunk (ABI encoding on 32 bytes)
 - a dynamic calldata value (bytes, string) is its raw content
 - a sliced calldata value is the slice of the above
 - the chain id is a 64 bits big endian integer, the sender / target addresses are 20 bytes, the native value is a
   256 bits big endian integer

See doc/tlv_structs.md#map_entry in https://github.com/LedgerHQ/app-ethereum for specifications of these structs.
"""

from collections.abc import Callable
from typing import assert_never

from erc7730.common.abi import ABIDataType
from erc7730.common.binary import from_hex
from erc7730.common.output import OutputAdder
from erc7730.convert.calldata.v1.abi import ABITree
from erc7730.convert.calldata.v1.path import convert_container_path, convert_data_path
from erc7730.convert.calldata.v1.tlv import tlv_value
from erc7730.model.calldata.v1.instruction import MAX_MAP_ENTRY_SIZE, CalldataDescriptorInstructionMapEntryV1
from erc7730.model.calldata.v1.value import (
    CalldataDescriptorContainerPathV1,
    CalldataDescriptorContainerPathValueV1,
    CalldataDescriptorDataPathV1,
    CalldataDescriptorMapRefV1,
    CalldataDescriptorPathElementArrayV1,
    CalldataDescriptorPathElementLeafV1,
    CalldataDescriptorPathElementSliceV1,
    CalldataDescriptorPathLeafType,
    CalldataDescriptorTypeFamily,
    CalldataDescriptorValueMapRefV1,
    CalldataDescriptorValuePathV1,
)
from erc7730.model.paths import ContainerPath, DataPath
from erc7730.model.resolved.v2.context import ResolvedDeployment
from erc7730.model.resolved.v2.display import ResolvedValueMap
from erc7730.model.types import Address, HexStr, Selector

# size of a calldata chunk (CALLDATA_CHUNK_SIZE in app-ethereum)
CALLDATA_CHUNK_SIZE = 32

# length of an EVM address (ADDRESS_LENGTH in app-ethereum)
ADDRESS_LENGTH = 20

# size of the chain id the device reads from the transaction context (uint64)
CHAIN_ID_SIZE = 8

# maximum size of the KEY VALUE struct embedded in a MAP_REF (MAP_REF_KEY_TLV_MAX_SIZE in app-ethereum)
MAP_REF_KEY_MAX_SIZE = 48

# map ids are encoded on a single byte
MAX_MAP_ID = 255

# reports why a map key is invalid, and returns None
_Invalid = Callable[[str], bytes | None]


[docs] class MapEntries: """ Device side maps of a calldata descriptor (bound to a single deployment and selector). Map ids are allocated per descriptor: a map looked up several times with the same key and value encodings shares its id and MAP_ENTRY structs, a map looked up with different encodings (for instance its values read as addresses in one field and as strings in another) gets one id per encoding. """ def __init__(self, deployment: ResolvedDeployment, selector: Selector) -> None: self.deployment = deployment self.selector = selector self.entries: list[CalldataDescriptorInstructionMapEntryV1] = [] self._ids: dict[tuple[str, tuple[tuple[bytes, bytes], ...]], int] = {}
[docs] def map_ref( self, value_map: ResolvedValueMap, abi_type: ABIDataType, abi: ABITree, out: OutputAdder, ) -> CalldataDescriptorValueMapRefV1 | None: """ Convert a map lookup to a MAP_REF value, and register the MAP_ENTRY structs of the map. :param value_map: resolved map lookup :param abi_type: data type the map values are encoded with :param abi: function ABI tree :param out: error handler :return: MAP_REF value, or None on error """ map_name = str(value_map.map) if (encoded_keys := encode_map_keys(value_map, abi, out)) is None: return None key, keys_bytes = encoded_keys encoded: list[tuple[str, bytes, bytes]] = [] for key_source, value in value_map.values.items(): key_bytes = keys_bytes[key_source] value_bytes = from_hex(value.raw) # encoded with the parameter type when resolved if not 1 <= len(value_bytes) <= MAX_MAP_ENTRY_SIZE: return out.error( title="Invalid map value", message=f"""Value for key "{key_source}" of map {map_name} must encode to 1 to """ f"{MAX_MAP_ENTRY_SIZE} bytes, it encodes to {len(value_bytes)}.", ) encoded.append((key_source, key_bytes, value_bytes)) signature = (map_name, tuple(sorted((key_bytes, value_bytes) for _, key_bytes, value_bytes in encoded))) if (map_id := self._ids.get(signature)) is None: if (map_id := len(self._ids)) > MAX_MAP_ID: return out.error( title="Too many maps", message=f"At most {MAX_MAP_ID + 1} maps can be looked up in a single function.", ) self._ids[signature] = map_id for key_source, key_bytes, value_bytes in encoded: self.entries.append( CalldataDescriptorInstructionMapEntryV1( chain_id=self.deployment.chainId, address=Address(self.deployment.address), selector=self.selector, map=map_name, id=map_id, key_source=key_source, key=HexStr("0x" + key_bytes.hex()), value_source=value_map.values[key_source].value, value=HexStr("0x" + value_bytes.hex()), ) ) # the formatters only read the declared size to interpret signed integers and to check a minimum length, # which only holds if all the values have it value_sizes = {len(value_bytes) for _, _, value_bytes in encoded} return CalldataDescriptorValueMapRefV1( type_family=CalldataDescriptorTypeFamily[abi_type.name], type_size=value_sizes.pop() if len(value_sizes) == 1 else None, map_ref=CalldataDescriptorMapRefV1(map=map_name, id=map_id, key=key), )
[docs] def encode_map_keys( value_map: ResolvedValueMap, abi: ABITree, out: OutputAdder ) -> tuple[CalldataDescriptorValuePathV1, dict[str, bytes]] | None: """ Convert the key path of a map lookup to the value the device reads the key from, and encode the map keys the way the device reads that value. :param value_map: resolved map lookup :param abi: function ABI tree :param out: error handler :return: value the device reads the key from, and encoded keys indexed by source key, or None on error """ map_name = str(value_map.map) if (key := _convert_key_path(value_map, abi, out)) is None: return None if len(tlv_value(key)) > MAP_REF_KEY_MAX_SIZE: return out.error( title="Unsupported map key", message=f"Path {value_map.keyPath} used as key of map {map_name} is too complex: the device " f"accepts at most {MAP_REF_KEY_MAX_SIZE} bytes to encode it.", ) keys_bytes: dict[str, bytes] = {} sources: dict[bytes, str] = {} valid = True for key_source in value_map.values: # all keys are checked, so that every invalid key is reported at once if (key_bytes := _encode_key(key_source, key, map_name, out)) is None: valid = False continue if (other_key := sources.get(key_bytes)) is not None: valid = False out.error( title="Invalid map key", message=f"""Keys "{other_key}" and "{key_source}" of map {map_name} encode to the same """ f"{key.type_family.name.lower()} key.", ) continue sources[key_bytes] = key_source keys_bytes[key_source] = key_bytes return (key, keys_bytes) if valid else None
def _convert_key_path( value_map: ResolvedValueMap, abi: ABITree, out: OutputAdder ) -> CalldataDescriptorValuePathV1 | None: """Convert the key path of a map lookup to the value the device reads the key from.""" match value_map.keyPath: case ContainerPath() as container_path: return convert_container_path(container_path, out) case DataPath() as data_path: if (key := convert_data_path(data_path, abi, out)) is None: return None assert isinstance(key.binary_path, CalldataDescriptorDataPathV1) # nosec B101 - data path converts to one # the device looks up a single key: it rejects a path reading several values for element in key.binary_path.elements: if isinstance(element, CalldataDescriptorPathElementArrayV1) and not _is_single_element(element): return out.error( title="Unsupported map key", message=f"Path {data_path} used as key of map {value_map.map} references several values, a " "map key must reference a single value.", ) return key case _: assert_never(value_map.keyPath) def _is_single_element(element: CalldataDescriptorPathElementArrayV1) -> bool: """Whether an array path element selects a single array element.""" match element.start, element.end: case int(start), int(end): return end == start + 1 case -1, None: return True case _: return False def _encode_key(key_source: str, key: CalldataDescriptorValuePathV1, map_name: str, out: OutputAdder) -> bytes | None: """ Encode a map key the way the device reads the key value from the transaction. :param key_source: map key, as written in the source descriptor :param key: value the device reads the key from :param map_name: source map reference, for error messages :param out: error handler :return: encoded key, or None on error """ def invalid(reason: str) -> bytes | None: out.error( title="Invalid map key", message=f"""Key "{key_source}" of map {map_name} cannot be matched against {key.abi_path}: {reason}.""", ) return None match key.binary_path: case CalldataDescriptorContainerPathV1(value=container_value): match container_value: case CalldataDescriptorContainerPathValueV1.CHAIN_ID: return _encode_uint(key_source, CHAIN_ID_SIZE, invalid) case CalldataDescriptorContainerPathValueV1.FROM | CalldataDescriptorContainerPathValueV1.TO: return _encode_hex(key_source, ADDRESS_LENGTH, invalid, pad_left=True) case CalldataDescriptorContainerPathValueV1.VALUE: return _encode_uint(key_source, CALLDATA_CHUNK_SIZE, invalid) case _: assert_never(container_value) case CalldataDescriptorDataPathV1(elements=elements): leaf_slice: CalldataDescriptorPathElementSliceV1 | None = None if isinstance(elements[-1], CalldataDescriptorPathElementSliceV1): leaf_slice = elements[-1] elements = elements[:-1] if not isinstance(leaf := elements[-1], CalldataDescriptorPathElementLeafV1): return invalid("the key path does not end with a value") match leaf.leaf_type: case CalldataDescriptorPathLeafType.STATIC_LEAF: length: int | None = CALLDATA_CHUNK_SIZE case CalldataDescriptorPathLeafType.DYNAMIC_LEAF: length = None case _: return invalid("the key path must reference a static or dynamic value") # a sliced key is written in the map as the slice itself, there is no way to tell the rest of the value if leaf_slice is not None: return _encode_sliced_key(key_source, key, length, leaf_slice, invalid) if length is not None: return _encode_static_key(key_source, key, invalid) return _encode_dynamic_key(key_source, key, invalid) case _: assert_never(key.binary_path) def _encode_static_key(key_source: str, key: CalldataDescriptorValuePathV1, invalid: _Invalid) -> bytes | None: """Encode a key read from a static calldata value: the device reads the whole ABI encoded chunk.""" match key.type_family: case CalldataDescriptorTypeFamily.UINT: if (encoded := _encode_uint(key_source, key.type_size or CALLDATA_CHUNK_SIZE, invalid)) is None: return None return encoded.rjust(CALLDATA_CHUNK_SIZE, b"\x00") case CalldataDescriptorTypeFamily.INT: if (value := _parse_int(key_source)) is None: return invalid("expected an integer") size = key.type_size or CALLDATA_CHUNK_SIZE bound = 1 << (size * 8 - 1) if not -bound <= value < bound: return invalid(f"it does not fit in {size} bytes") return value.to_bytes(CALLDATA_CHUNK_SIZE, byteorder="big", signed=True) case CalldataDescriptorTypeFamily.ADDRESS: if (encoded := _encode_hex(key_source, ADDRESS_LENGTH, invalid, pad_left=True)) is None: return None return encoded.rjust(CALLDATA_CHUNK_SIZE, b"\x00") case CalldataDescriptorTypeFamily.BOOL: if (encoded := _encode_bool(key_source, invalid)) is None: return None return encoded.rjust(CALLDATA_CHUNK_SIZE, b"\x00") case CalldataDescriptorTypeFamily.BYTES: # bytesN values are left aligned in their chunk if (encoded := _encode_hex(key_source, key.type_size or CALLDATA_CHUNK_SIZE, invalid)) is None: return None return encoded.ljust(CALLDATA_CHUNK_SIZE, b"\x00") case _: return invalid(f"{key.type_family.name.lower()} keys are not supported") def _encode_dynamic_key(key_source: str, key: CalldataDescriptorValuePathV1, invalid: _Invalid) -> bytes | None: """Encode a key read from a dynamic calldata value: the device reads its raw content.""" match key.type_family: case CalldataDescriptorTypeFamily.STRING: encoded = key_source.encode("utf-8") case CalldataDescriptorTypeFamily.BYTES: if (hex_encoded := _encode_hex(key_source, MAX_MAP_ENTRY_SIZE, invalid)) is None: return None encoded = hex_encoded case _: return invalid(f"dynamic {key.type_family.name.lower()} keys are not supported") return _check_key_size(encoded, invalid) def _encode_sliced_key( key_source: str, key: CalldataDescriptorValuePathV1, length: int | None, leaf_slice: CalldataDescriptorPathElementSliceV1, invalid: _Invalid, ) -> bytes | None: """Encode a key read from a slice of a calldata value: the key is the slice itself.""" width = _slice_width(length, leaf_slice) if width == 0: return invalid("the key path slice is empty") match key.type_family: case CalldataDescriptorTypeFamily.STRING: encoded = key_source.encode("utf-8") case CalldataDescriptorTypeFamily.UINT | CalldataDescriptorTypeFamily.INT if not key_source.startswith("0x"): if (value := _parse_int(key_source)) is None: return invalid("expected an integer") signed = key.type_family is CalldataDescriptorTypeFamily.INT try: encoded = value.to_bytes( width or max(1, (value.bit_length() + 8 * signed + 7) // 8), byteorder="big", signed=signed ) except OverflowError: return invalid(f"it does not fit in the {width} bytes of the slice") case CalldataDescriptorTypeFamily.BOOL if not key_source.startswith("0x"): if (encoded_bool := _encode_bool(key_source, invalid)) is None: return None encoded = encoded_bool.rjust(width or 1, b"\x00") case _: # numbers and addresses are right aligned in the value they are sliced from pad_left = key.type_family in (CalldataDescriptorTypeFamily.UINT, CalldataDescriptorTypeFamily.ADDRESS) if (hex_encoded := _encode_hex(key_source, width or MAX_MAP_ENTRY_SIZE, invalid, pad_left)) is None: return None encoded = hex_encoded if width is not None and len(encoded) != width: return invalid(f"it encodes to {len(encoded)} bytes, but the key path slice is {width} bytes") return _check_key_size(encoded, invalid) def _slice_width(length: int | None, leaf_slice: CalldataDescriptorPathElementSliceV1) -> int | None: """Width of a slice, when it can be known at conversion time (the device applies it as python slices do).""" start, end = leaf_slice.start, leaf_slice.end if length is not None: return len(range(length)[start:end]) match start, end: case None, int(end) if end >= 0: return end case int(start), None if start < 0: return -start case int(start), int(end) if (start >= 0) == (end >= 0): return max(0, end - start) case _: return None def _check_key_size(encoded: bytes, invalid: _Invalid) -> bytes | None: if not 1 <= len(encoded) <= MAX_MAP_ENTRY_SIZE: return invalid(f"a key must encode to 1 to {MAX_MAP_ENTRY_SIZE} bytes, it encodes to {len(encoded)}") return encoded def _parse_int(key_source: str) -> int | None: try: return int(key_source, 16) if key_source.lower().startswith("0x") else int(key_source, 10) except ValueError: return None def _encode_uint(key_source: str, size: int, invalid: _Invalid) -> bytes | None: if (value := _parse_int(key_source)) is None or value < 0: return invalid("expected an unsigned integer") if value.bit_length() > size * 8: return invalid(f"it does not fit in {size} bytes") return value.to_bytes(size, byteorder="big") def _encode_hex(key_source: str, size: int, invalid: _Invalid, pad_left: bool = False) -> bytes | None: """Decode a hex key of at most size bytes, left padded to size bytes if requested.""" if not key_source.startswith("0x"): return invalid("expected a hexadecimal string") try: encoded = from_hex(key_source) except ValueError: return invalid("expected a hexadecimal string") if not 1 <= len(encoded) <= size: return invalid(f"expected 1 to {size} bytes, got {len(encoded)}") return encoded.rjust(size, b"\x00") if pad_left else encoded def _encode_bool(key_source: str, invalid: _Invalid) -> bytes | None: match key_source.lower(): case "true" | "1": return b"\x01" case "false" | "0": return b"\x00" case _: return invalid("expected a boolean")