feat(clear_signing): support for calldata formatter. - core changes
What changed, and why it matters
This commit adds a new Trezor firmware feature called 'clear signing' for nested Ethereum calls. It lets the device understand when a transaction wraps another contract call (like a router or multicall) and tries to show the user readable details about the inner call instead of just a blob of hex. The change is a feature addition with built-in safeguards: nested parsing is limited to one level, ambiguous fields like sender and value are blocked inside nested calls, and failures fall back to showing raw hex rather than aborting the whole transaction. There is no indication in the commit that this fixes a known security bug; it reads as a defensive new capability.
Treat as a feature commit rather than an urgent security fix. Reviewers should verify that the depth cap, callee override, rejected container paths, and raw fallback are enforced in all code paths, and that host-supplied definition requests cannot bypass matches_call pinning. Fuzzing or additional tests around malformed calldata fields and hostile definition responses would strengthen confidence.
Security signals we found
New nested calldata parsing path with explicit depth cap of 1
@.to override to callee to prevent token resolution from pointing at the wrapper contract
@.from and @.value rejected in nested parse to avoid displaying confidently wrong sender/value
Raw-hex fallback for unsupported or malformed inner subcalls instead of failing the outer clear-sign flow
matches_call pins requested definition to requested selector/callee/binding context
CalldataFormatter subclasses RawFormatter as defense-in-depth so accidental single-value use renders hex rather than crashing clear signing
Evidence from the diff
The patch introduces CalldataFormatter and supporting logic in core/src/apps/ethereum/clear_signing.py to parse ERC-7730 calldata fields. Key behaviors: resolves an inner display format by callee address and selector; overrides @.to to the callee during nested parsing; rejects @.from and @.value in nested context; caps recursion at depth 1; and degrades to raw-hex fallback on missing/malformed inner formats or ClearSigningFailed/DataError. The commit also refactors display-format lookup into _find_display_format and adds unit tests covering nested transfers, depth cap, raw fallback, invalid values, and container-path restrictions.
Changed components
core/src/apps/ethereum/clear_signing.pycore/src/apps/ethereum/README.mdcore/tests/test_apps.ethereum.clear_signing.pyInspect captured patch +627 / −50
diff --git a/core/src/apps/ethereum/README.md b/core/src/apps/ethereum/README.md
index 475596fb..bfa52d3c 100644
--- a/core/src/apps/ethereum/README.md
+++ b/core/src/apps/ethereum/README.md
@@ -86,6 +86,60 @@ Had the struct contained a dynamic field (like LiFi's `swapData` structs with
`bytes callData`), words w7/w8 would instead be offsets relative to 0xe0, with
the two struct bodies following after the heads.
+## Nested invocations (`calldata` fields)
+
+A display field with `FORMATTER_CALLDATA` points at a dynamic `bytes` value
+holding an **embedded function call** (a router/multicall/`execute()`-style
+wrapper forwarding a call to another contract). The formatter carries two
+params: `callee_path`, a path to the address of the called contract, and an
+optional `selector` - per ERC-7730, an explicit selector means the blob is
+args-only; otherwise the blob's first 4 bytes are the selector.
+
+`_expand_calldata_field` resolves a display format for `(chain_id, callee,
+inner selector)` via `_find_display_format` - built-ins, then a wire
+definition request; the host-provided blob slot is skipped for nested lookups
+since it holds the one format embedded for the *outer* call - and, on
+success, runs the inner format's `parse_calldata` over the blob's body. The
+subcall is **flattened** into the outer confirm screen as plain rows: a
+`(Subcall) Provider` row (the inner format's `provider_name`, falling back to
+the callee's `KNOWN_ADDRESSES` name, falling back to the callee
+address), a `(Subcall) Intent` row, then the inner format's own fields with
+`(Subcall) `-prefixed labels. There is no separate confirmation for the
+subcall. The field's path must resolve to exactly one `bytes` blob -
+multicall-style `bytes[]` arrays are not (yet) supported.
+
+Rules that keep the nested parse honest:
+
+* **Depth is capped at 1.** The inner parse runs with `nested=True`: a
+ `calldata` field found *inside* a subcall degrades as below, with no
+ tertiary lookup.
+* **`@.to` is overridden to the callee.** Container paths in the inner format
+ refer to the subcall, not the outer transaction - critically, the built-in
+ ERC-20 `transfer`/`approve` formats resolve their token via
+ `token_path=@.to`, which must be the callee (the token contract), never the
+ outer `to` (the wrapper).
+* **`@.from` and `@.value` are rejected when nested.** The subcall's
+ `msg.sender` is ambiguous: it is the wrapper contract if the wrapper
+ `CALL`s the callee, but the original signer if it `DELEGATECALL`s
+ (self-multicall / `execute()`-style wrappers) - and the device cannot tell
+ which from the transaction. Since `@.from` typically labels a beneficiary,
+ guessing wrong would confidently display the wrong recipient. The
+ forwarded `@.value` is decided by wrapper logic and genuinely unknowable.
+ Either field raises and the subcall degrades as below.
+* **The response to a definition request is pinned to the request**: every
+ candidate must pass `DisplayFormat.matches_call` (selector + binding
+ context against the callee), so a validly-signed blob for a different
+ function or contract cannot be substituted in.
+
+Fallback ladder: if no inner display format is available (the "easy case" -
+e.g. contract not supported), or the inner parse fails
+(`ClearSigningFailed`/`DataError`), the subcall degrades to two rows - the
+callee under `Subcall to`, and the raw hex blob under the field's own label -
+and the outer transaction still clear-signs. Only a malformed `calldata`
+field *definition* itself (a path that isn't `bytes`, a callee that isn't an
+address) fails the whole display format, falling back to blind signing like
+any other malformed definition.
+
## Failure behavior
All parsing errors derive from `ClearSigningFailed`. `sign_tx` catches it and
diff --git a/core/src/apps/ethereum/clear_signing.py b/core/src/apps/ethereum/clear_signing.py
index 87c8f2b0..a2b06bdf 100644
--- a/core/src/apps/ethereum/clear_signing.py
+++ b/core/src/apps/ethereum/clear_signing.py
@@ -46,10 +46,18 @@ if TYPE_CHECKING:
# Assumes that the memoryview contains just that value.
Parser = Callable[[memoryview], Value]
+ # One displayed row: ((label, formatted value, is_mono), token, token_address)
+ DisplayedField = tuple[
+ tuple[str, str | AboveThreshold | None, bool | None],
+ EthereumTokenInfo | None,
+ AnyBytes | None,
+ ]
+
SC_FUNC_SIG_BYTES = const(4)
_EVM_WORD_SIZE = const(32) # in bytes
_EVM_WORD_BITS = const(8 * _EVM_WORD_SIZE)
+_ADDRESS_BYTES = const(20)
class ClearSigningFailed(Exception):
@@ -102,11 +110,10 @@ def _check_padding_zero(
def parse_address(raw_data: memoryview) -> Value:
- _ZERO_PADDING = const(20)
if len(raw_data) < _EVM_WORD_SIZE:
raise OutOfBounds
- _check_padding_zero(raw_data, _ZERO_PADDING, DirtyAddress)
- return bytes(raw_data[_EVM_WORD_SIZE - _ZERO_PADDING :])
+ _check_padding_zero(raw_data, _ADDRESS_BYTES, DirtyAddress)
+ return bytes(raw_data[_EVM_WORD_SIZE - _ADDRESS_BYTES :])
def parse_uint256(raw_data: memoryview) -> int:
@@ -496,6 +503,22 @@ class DateFormatter(FieldFormatter):
raise InvalidFormatDefinition
+class CalldataFormatter(RawFormatter):
+ """ERC-7730 `calldata` format: the field's value is the embedded calldata
+ of a nested call, rendered with the display format of the called contract
+ (resolved from `callee_path`). Unlike every other formatter it expands
+ into multiple display rows, so `DisplayFormat.parse_calldata` handles it
+ directly (see `_expand_calldata_field`) instead of going through the
+ single-value `format` interface. Subclassing `RawFormatter` is defense
+ in depth: should a code path ever fail to special-case this formatter,
+ the inherited `format` renders the blob as raw hex instead of failing
+ clear signing."""
+
+ def __init__(self, callee_path: Path, selector: bytes | None = None) -> None:
+ self.callee_path = callee_path
+ self.selector = selector
+
+
async def _format_field_value(
formatter: FieldFormatter,
value: AnyValue,
@@ -833,6 +856,13 @@ class FieldDefinition:
formatter = RawFormatter
elif fmt_type == FT.FORMATTER_DATE:
formatter = DateFormatter
+ elif fmt_type == FT.FORMATTER_CALLDATA:
+ if info.callee_path is None:
+ raise InvalidFormatDefinition
+ selector = bytes(info.selector) if info.selector is not None else None
+ if selector is not None and len(selector) != SC_FUNC_SIG_BYTES:
+ raise InvalidFormatDefinition
+ formatter = CalldataFormatter(decode_path(info.callee_path), selector)
else:
raise InvalidFormatDefinition
@@ -851,17 +881,17 @@ class DisplayFormat:
self,
binding_context: BindingContext | None,
func_sig: bytes,
- provider_name: str | None,
intent: str,
parameter_definitions: list[ABIValue],
field_definitions: list[FieldDefinition],
+ provider_name: str | None = None,
) -> None:
self.binding_context = binding_context
- self.provider_name = provider_name
self.func_sig = func_sig
self.intent = intent
self.parameter_definitions = parameter_definitions
self.field_definitions = field_definitions
+ self.provider_name = provider_name
self.parameters = []
@@ -873,21 +903,26 @@ class DisplayFormat:
return self.binding_context.matches(chain_id, address)
+ def matches_call(self, func_sig: bytes, chain_id: int, address: bytes) -> bool:
+ """Assert the received descriptor is what was requested"""
+ return self.func_sig == func_sig and self.matches_context(chain_id, address)
+
async def parse_calldata(
self,
calldata: memoryview,
msg: MsgInSignTx,
defs: Definitions,
- ) -> tuple[
- list[AnyValue],
- list[
- tuple[
- tuple[str, str | AboveThreshold | None, bool | None],
- EthereumTokenInfo | None,
- AnyBytes | None,
- ]
- ],
- ]:
+ nested: bool = False,
+ override_callee: bytes | None = None,
+ ) -> tuple[list[AnyValue], list[DisplayedField]]:
+ """Parse `calldata` (without the selector) and format the display fields.
+
+ `nested` marks the parse of an embedded subcall's calldata (see
+ `_expand_calldata_field`): container paths other than `@.to` are
+ unresolvable there, and any further calldata fields render as raw
+ hex instead of triggering tertiary lookups. `override_callee` substitutes
+ the `@.to` container path (the subcall's callee, not the outer
+ transaction's recipient)."""
parameters: list[AnyValue] = []
offset = 0
@@ -913,13 +948,28 @@ class DisplayFormat:
return path
if isinstance(path, int): # ContainerPath
# standard container paths like @.from, @.value...
+ if path == ContainerPath.To:
+ # `@.to` means "the contract this call goes to". For the
+ # outer transaction that is `msg.to`. When parsing a
+ # subcall, the wrapper (`msg.to`) merely forwards the
+ # call: `override_callee` holds the actual callee, so e.g. a
+ # nested transfer's `token_path=@.to` resolves to the
+ # token contract, not to the wrapper.
+ if override_callee is not None:
+ return override_callee
+ return bytes_from_address(msg.to)
+ if nested:
+ # In a subcall, `@.from` is ambiguous - the wrapper
+ # contract if it CALLs the callee, the original signer if
+ # it DELEGATECALLs - and `@.value` is decided by wrapper
+ # logic; neither can be resolved from `msg` without
+ # risking a confidently wrong display.
+ raise InvalidFormatDefinition
if path == ContainerPath.From:
account, _ = get_account_and_path(msg.address_n)
return account
elif path == ContainerPath.Value:
return int.from_bytes(msg.value, "big")
- elif path == ContainerPath.To:
- return bytes_from_address(msg.to)
else:
raise NotImplementedError # TODO
else:
@@ -959,17 +1009,26 @@ class DisplayFormat:
raise InvalidFormatDefinition
return p
- fields: list[
- tuple[
- tuple[str, str | AboveThreshold | None, bool | None],
- EthereumTokenInfo | None,
- AnyBytes | None,
- ]
- ] = []
+ fields: list[DisplayedField] = []
for field_definition in self.field_definitions:
try:
- value = get_value_for_path(field_definition.path)
formatter = field_definition.get_formatter()
+ if isinstance(formatter, CalldataFormatter):
+ # Expands into multiple rows (the subcall's provider and
+ # intent, then its own fields), so it bypasses the
+ # single-value formatting below.
+ fields.extend(
+ await _expand_calldata_field(
+ field_definition,
+ formatter,
+ get_value_for_path,
+ msg,
+ defs,
+ nested,
+ )
+ )
+ continue
+ value = get_value_for_path(field_definition.path)
formatted, token, token_address = await _format_field_value(
formatter, value, msg, defs, get_value_for_path
)
@@ -1046,6 +1105,127 @@ async def request_definitions(
return definitions, display_format
+async def _find_display_format(
+ func_sig: bytes, address_bytes: bytes, msg: MsgInSignTx, nested: bool = False
+) -> DisplayFormat | None:
+ """Find a display format for calling `func_sig` on the `address_bytes`
+ contract, trying built-ins, then the definitions provided in the initial
+ request, then a definition request over the wire."""
+
+ from .clear_signing_definitions import all_display_formats
+
+ for f in all_display_formats():
+ if f.matches_call(func_sig, msg.chain_id, address_bytes):
+ return f
+
+ if not nested and msg.definitions and msg.definitions.encoded_display_format:
+ f = DisplayFormat.from_encoded(msg.definitions.encoded_display_format)
+ if f.matches_call(func_sig, msg.chain_id, address_bytes):
+ return f
+
+ if msg.supports_definition_request:
+ _, f = await request_definitions(msg.chain_id, address_bytes, func_sig)
+ if f is not None and f.matches_call(func_sig, msg.chain_id, address_bytes):
+ return f
+
+ return None
+
+
+async def _expand_calldata_field(
+ field_definition: FieldDefinition,
+ formatter: CalldataFormatter,
+ path_walker: PathWalker,
+ msg: MsgInSignTx,
+ defs: Definitions,
+ nested: bool,
+) -> list[DisplayedField]:
+ """Expand one `calldata` field - an embedded subcall - into display rows.
+
+ The field's path resolves to one `bytes` blob of embedded calldata, and
+ `callee_path` to the address of the contract it is sent to. On success
+ the rows are the subcall's provider and intent, followed by the fields
+ of the callee's display format, labels prefixed. Whenever the subcall
+ cannot be clear-signed - no display format available, malformed inner
+ calldata, unresolvable inner fields - it degrades to two rows, the
+ callee and the raw hex blob, instead of failing the outer transaction."""
+ from .sc_constants import lookup_known_address
+
+ blob = path_walker(field_definition.path)
+ if not isinstance(blob, bytes):
+ # Calldata should be a bytes field
+ raise InvalidFormatDefinition
+
+ callee = path_walker(formatter.callee_path)
+ if not isinstance(callee, bytes) or len(callee) != _ADDRESS_BYTES:
+ raise InvalidFormatDefinition
+
+ def callee_str() -> str:
+ return lookup_known_address(msg.chain_id, callee) or address_from_bytes(
+ callee, defs.network
+ )
+
+ def raw_rows() -> list[DisplayedField]:
+ """No subparsing. Show the callee and the raw hex blob."""
+ from ubinascii import hexlify
+
+ return [
+ ((TR.ethereum__subcall_to, callee_str(), None), None, None),
+ ((field_definition.label, hexlify(blob).decode(), None), None, None),
+ ]
+
+ if nested:
+ # already inside a subcall: no tertiary lookups
+ return raw_rows()
+
+ if formatter.selector is not None:
+ # per ERC-7730, an explicit selector means the blob is args-only
+ func_sig = formatter.selector
+ body = memoryview(blob)
+ elif len(blob) >= SC_FUNC_SIG_BYTES:
+ func_sig = bytes(blob[:SC_FUNC_SIG_BYTES])
+ body = memoryview(blob)[SC_FUNC_SIG_BYTES:]
+ else:
+ return raw_rows()
+
+ try:
+ inner_format = await _find_display_format(func_sig, callee, msg, nested=True)
+ if inner_format is None:
+ return raw_rows()
+ _, inner_fields = await inner_format.parse_calldata(
+ body, msg, defs, nested=True, override_callee=callee
+ )
+ except (ClearSigningFailed, DataError) as e:
+ if __debug__:
+ from trezor import log
+
+ log.debug(
+ __name__,
+ 'clear signing: subcall "%s" degraded to raw hex (%s)',
+ field_definition.label,
+ type(e).__name__,
+ )
+ return raw_rows()
+
+ subcall = TR.ethereum__subcall
+ rows: list[DisplayedField] = [
+ (
+ (
+ f"({subcall}) {TR.words__provider}",
+ inner_format.provider_name or callee_str(),
+ None,
+ ),
+ None,
+ None,
+ ),
+ ((f"({subcall}) {TR.words__intent}", inner_format.intent, None), None, None),
+ ]
+ for (inner_label, formatted, is_mono), token, token_address in inner_fields:
+ rows.append(
+ ((f"({subcall}) {inner_label}", formatted, is_mono), token, token_address)
+ )
+ return rows
+
+
async def try_confirm(
data: AnyBytes,
address_bytes: bytes,
@@ -1058,7 +1238,6 @@ async def try_confirm(
from .clear_signing_definitions import (
APPROVE_DISPLAY_FORMAT,
TRANSFER_DISPLAY_FORMAT,
- all_display_formats,
)
if not address_bytes:
@@ -1069,30 +1248,7 @@ async def try_confirm(
func_sig = bytes(data[0:SC_FUNC_SIG_BYTES])
- display_format = None
- for f in all_display_formats():
- # Start by trying built-in definitions...
- if f.func_sig == func_sig and f.matches_context(msg.chain_id, address_bytes):
- display_format = f
- break
- else:
- if msg.definitions and msg.definitions.encoded_display_format:
- # ... look at definitions provided in the initial request...
- f = DisplayFormat.from_encoded(msg.definitions.encoded_display_format)
- if f.func_sig == func_sig and f.matches_context(
- msg.chain_id, address_bytes
- ):
- display_format = f
- if display_format is None:
- # ... finally request the display format via another call!
- if msg.supports_definition_request:
- _, f = await request_definitions(msg.chain_id, address_bytes, func_sig)
- if f:
- if f.func_sig == func_sig and f.matches_context(
- msg.chain_id, address_bytes
- ):
- display_format = f
-
+ display_format = await _find_display_format(func_sig, address_bytes, msg)
if display_format is None:
return False
diff --git a/core/tests/test_apps.ethereum.clear_signing.py b/core/tests/test_apps.ethereum.clear_signing.py
index 9d68428c..ac659bf5 100644
--- a/core/tests/test_apps.ethereum.clear_signing.py
+++ b/core/tests/test_apps.ethereum.clear_signing.py
@@ -5,17 +5,23 @@ import unittest
if not utils.BITCOIN_ONLY:
+ from ubinascii import hexlify
+
from ethereum_common import *
from trezor.enums import EthereumERC7730FieldFormatterType as FT
from trezor.messages import EthereumERC7730FieldInfo, EthereumERC7730Path
+ from apps.ethereum import clear_signing_definitions
from apps.ethereum.clear_signing import (
AddressNameFormatter,
Array,
Atomic,
+ CalldataFormatter,
+ ContainerPath,
DateFormatter,
DirtyAddress,
DisplayFormat,
+ DynamicLeaf,
FieldDefinition,
InvalidFormatDefinition,
OutOfBounds,
@@ -23,12 +29,14 @@ if not utils.BITCOIN_ONLY:
TokenAmountFormatter,
Tuple,
ValueOverflow,
+ _expand_calldata_field,
_format_field_value,
make_fixed_bytes_parser,
make_int_parser,
make_uint_parser,
parse_address,
parse_bool,
+ parse_bytes,
parse_string,
parse_uint256,
)
@@ -922,6 +930,365 @@ class TestEthereumClearSigning(unittest.TestCase):
with self.assertRaises(InvalidFormatDefinition):
await_result(fmt.format(1, None, None, None))
+ # --- `calldata` fields (nested invocations) ---
+
+ TRANSFER_SIG = b"\xa9\x05\x9c\xbb"
+ RECIPIENT = unhexlify("d8da6bf26964af9d7eed9e03e53415d37aa96045")
+ # stETH: mapped to "Lido" in KNOWN_ADDRESSES, exercising the provider /
+ # callee name resolution. Will be updated once those definitions are removed.
+ CALLEE = unhexlify("ae7ab96520de3a18e5e111b5eaab095312d7fe84")
+
+ @staticmethod
+ def _msg(**kwargs):
+ class _Msg:
+ chain_id = 1
+ definitions = None
+ supports_definition_request = False
+ value = b"\x00"
+ to = "0x0000000000000000000000000000000000000000"
+ address_n = []
+
+ msg = _Msg()
+ for k, v in kwargs.items():
+ setattr(msg, k, v)
+ return msg
+
+ @staticmethod
+ def _defs():
+ class _Defs:
+ network = make_eth_network()
+
+ def __init__(self):
+ self.token_requests = []
+
+ def get_token(self, address):
+ self.token_requests.append(bytes(address))
+ return make_eth_token(symbol="TST", decimals=6, address=address)
+
+ return _Defs()
+
+ def _transfer_blob(self):
+ # ERC-20 transfer(RECIPIENT, 2 TST), selector included
+ return self.TRANSFER_SIG + b"\x00" * 12 + self.RECIPIENT + to_bytes(2_000_000)
+
+ def _outer_format(self, formatter):
+ # wrapper(address callee, bytes data) with `data` displayed by `formatter`
+ return DisplayFormat(
+ binding_context=None,
+ func_sig=b"\x00\x00\x00\x00",
+ intent="Test",
+ parameter_definitions=[
+ Atomic(parse_address), # callee
+ DynamicLeaf(parse_bytes), # data
+ ],
+ field_definitions=[FieldDefinition((1,), "Wrapped call", formatter)],
+ )
+
+ def _outer_calldata(self, blob, callee=None):
+ return (
+ b"\x00" * 12
+ + (callee or self.CALLEE) # left-pad the address to a 32-byte word
+ + to_bytes(64) # pointer to `data`
+ + to_bytes(len(blob))
+ + blob
+ )
+
+ def _expand(self, blob, formatter=None, callee=None, **parse_kwargs):
+ """Run the full nested-call pipeline and return the display rows.
+
+ Wraps `blob` (the embedded subcall's calldata) into a
+ `wrapper(callee, data)` outer transaction, parses it with the outer
+ display format whose one field is a calldata `formatter` (default:
+ plain `CalldataFormatter` reading the callee from parameter 0), and
+ returns only that expansion's rows - i.e. exactly what the subcall
+ contributes to the confirm screen."""
+ display_format = self._outer_format(
+ formatter or CalldataFormatter(callee_path=(0,))
+ )
+ _, fields = await_result(
+ display_format.parse_calldata(
+ memoryview(self._outer_calldata(blob, callee)),
+ self._msg(),
+ self._defs(),
+ **parse_kwargs,
+ )
+ )
+ return fields
+
+ def _assert_raw_fallback(self, fields, blob, callee_str="Lido"):
+ # the fallback is two rows: the callee, then the raw hex blob
+ self.assertEqual(len(fields), 2)
+ self.assertEqual(fields[0][0][:2], ("Subcall to", callee_str))
+ self.assertEqual(fields[1][0][:2], ("Wrapped call", hexlify(blob).decode()))
+ for _, token, token_address in fields:
+ self.assertIsNone(token)
+ self.assertIsNone(token_address)
+
+ def test_from_proto_calldata_formatter(self):
+ info = EthereumERC7730FieldInfo(
+ path=EthereumERC7730Path(path=[1]),
+ label="Wrapped call",
+ formatter=FT.FORMATTER_CALLDATA,
+ callee_path=EthereumERC7730Path(path=[0]),
+ selector=self.TRANSFER_SIG,
+ )
+ fmt = FieldDefinition.from_proto(info).get_formatter()
+ self.assertIsInstance(fmt, CalldataFormatter)
+ self.assertEqual(fmt.callee_path, (0,))
+ self.assertEqual(fmt.selector, self.TRANSFER_SIG)
+
+ # callee_path is mandatory
+ info = EthereumERC7730FieldInfo(
+ path=EthereumERC7730Path(path=[1]),
+ label="Wrapped call",
+ formatter=FT.FORMATTER_CALLDATA,
+ )
+ with self.assertRaises(InvalidFormatDefinition):
+ FieldDefinition.from_proto(info)
+
+ # a selector must be exactly 4 bytes
+ info = EthereumERC7730FieldInfo(
+ path=EthereumERC7730Path(path=[1]),
+ label="Wrapped call",
+ formatter=FT.FORMATTER_CALLDATA,
+ callee_path=EthereumERC7730Path(path=[0]),
+ selector=b"\xa9\x05",
+ )
+ with self.assertRaises(InvalidFormatDefinition):
+ FieldDefinition.from_proto(info)
+
+ def test_calldata_nested_transfer(self):
+ # End to end: the embedded blob is an ERC-20 transfer, resolved against
+ # the built-in TRANSFER format - offline, no definition requests. The
+ # inner token must resolve via the *callee* (`@.to` override), not the
+ # outer transaction's `to`.
+ blob = self._transfer_blob()
+ display_format = self._outer_format(CalldataFormatter(callee_path=(0,)))
+ defs = self._defs()
+
+ _, fields = await_result(
+ display_format.parse_calldata(
+ memoryview(self._outer_calldata(blob)), self._msg(), defs
+ )
+ )
+
+ self.assertEqual(len(fields), 4)
+ # built-in TRANSFER has no provider_name -> KNOWN_ADDRESSES fallback
+ self.assertEqual(fields[0][0][:2], ("(Subcall) Provider", "Lido"))
+ self.assertEqual(fields[1][0][:2], ("(Subcall) Intent", "Send"))
+ (label, formatted, _), _, _ = fields[2]
+ self.assertEqual(label, "(Subcall) To")
+ self.assertEqual(formatted.lower(), "0x" + hexlify(self.RECIPIENT).decode())
+ (label, formatted, _), token, token_address = fields[3]
+ self.assertEqual(label, "(Subcall) Amount")
+ self.assertEqual(formatted, "2 TST")
+ self.assertEqual(token.symbol, "TST")
+ self.assertEqual(token_address, self.CALLEE)
+ # the token was looked up by the callee address (`@.to` override)
+ self.assertEqual(defs.token_requests, [self.CALLEE])
+
+ def test_calldata_selector_param(self):
+ # An explicit selector means the blob is args-only (per ERC-7730).
+ blob = self._transfer_blob()[4:] # strip the selector
+ fields = self._expand(
+ blob, CalldataFormatter(callee_path=(0,), selector=self.TRANSFER_SIG)
+ )
+
+ self.assertEqual(len(fields), 4)
+ self.assertEqual(fields[1][0][:2], ("(Subcall) Intent", "Send"))
+
+ def test_calldata_scalars_descriptor(self):
+ # A richer inner call: the debug-only "Trezor Test Scalars" built-in
+ # (7e577e01, bound to chain 1 / 0xdd..dd) exercises the address, native
+ # amount, raw, unit and date formatters through the nested path.
+ test_callee = b"\xdd" * 20
+ note = b"hi"
+ payload = b"\x01\x02"
+ blob = (
+ b"\x7e\x57\x7e\x01"
+ + b"\x00" * 12
+ + self.RECIPIENT # recipient
+ + to_bytes(10**18) # nativeAmount: 1 FAKE
+ + to_bytes(123456789) # rawInt
+ + to_bytes(12345) # unitValue: 123.45 UNIT
+ + to_bytes(1616051824) # timestamp
+ + b"\xab" * 32 # hashBytes32
+ + to_bytes(1) # flagBool
+ + to_bytes(42) # sizedUint
+ + to_bytes(320) # pointer to note
+ + to_bytes(384) # pointer to payload
+ + to_bytes(len(note))
+ + note
+ + b"\x00" * (32 - len(note))
+ + to_bytes(len(payload))
+ + payload
+ )
+
+ fields = self._expand(blob, callee=test_callee)
+
+ expected = [
+ ("(Subcall) Provider", "Trezor Test. DO NOT USE"),
+ ("(Subcall) Intent", "Trezor Test Scalars. DO NOT USE"),
+ ("(Subcall) Recipient", None), # checksummed, checked below
+ ("(Subcall) Native Amount", "1 FAKE"),
+ ("(Subcall) Raw Integer", "123456789"),
+ ("(Subcall) Unit Value", "123.45 UNIT"),
+ ("(Subcall) Date", "2021-03-18 07:17:04"),
+ ("(Subcall) Raw Bytes32", "ab" * 32),
+ ("(Subcall) Raw Bool", "True"),
+ ("(Subcall) Raw Uint160", "42"),
+ ("(Subcall) Raw String", "hi"),
+ ("(Subcall) Raw Bytes", "0102"),
+ ]
+ self.assertEqual(len(fields), len(expected))
+ for (label, value), ((got_label, got_value, _), _, _) in zip(expected, fields):
+ self.assertEqual(got_label, label)
+ if value is not None:
+ self.assertEqual(got_value, value)
+ recipient_value = fields[2][0][1]
+ self.assertEqual(
+ recipient_value.lower(), "0x" + hexlify(self.RECIPIENT).decode()
+ )
+
+ def test_calldata_no_inner_format_raw_fallback(self):
+ # No built-in matches and definition requests are unsupported -> the
+ # "easy case": the callee row plus the raw hex of the whole blob.
+ blob = b"\xde\xad\xbe\xef" + to_bytes(5)
+ fields = self._expand(blob)
+ self._assert_raw_fallback(fields, blob)
+
+ def test_calldata_short_blob_raw_fallback(self):
+ # A blob too short to carry a selector cannot be looked up.
+ blob = b"\xa9\x05"
+ fields = self._expand(blob)
+ self._assert_raw_fallback(fields, blob)
+
+ def test_calldata_depth_capped_at_one(self):
+ # Two levels end to end: the outer call embeds a call to a "middle"
+ # format that itself contains a calldata field. The innermost blob is
+ # a transfer that a lookup *would* resolve (TRANSFER is available),
+ # but the depth cap renders it as callee + raw hex instead.
+ from apps.ethereum.clear_signing_definitions import TRANSFER_DISPLAY_FORMAT
+
+ innermost_callee = b"\x99" * 20 # not in KNOWN_ADDRESSES
+ innermost_blob = self._transfer_blob()
+ middle_format = DisplayFormat(
+ binding_context=None,
+ func_sig=b"\x12\x34\x56\x78",
+ intent="Middle",
+ parameter_definitions=[
+ Atomic(parse_address), # callee
+ DynamicLeaf(parse_bytes), # data
+ ],
+ field_definitions=[
+ FieldDefinition((1,), "Inner call", CalldataFormatter(callee_path=(0,)))
+ ],
+ )
+ middle_blob = b"\x12\x34\x56\x78" + self._outer_calldata(
+ innermost_blob, callee=innermost_callee
+ )
+
+ original = clear_signing_definitions.all_display_formats
+ clear_signing_definitions.all_display_formats = lambda: iter(
+ [middle_format, TRANSFER_DISPLAY_FORMAT]
+ )
+ try:
+ fields = self._expand(middle_blob)
+ finally:
+ clear_signing_definitions.all_display_formats = original
+
+ self.assertEqual(len(fields), 4)
+ self.assertEqual(fields[0][0][:2], ("(Subcall) Provider", "Lido"))
+ self.assertEqual(fields[1][0][:2], ("(Subcall) Intent", "Middle"))
+ # the middle format's calldata field degraded to callee + raw hex
+ (label, formatted, _), _, _ = fields[2]
+ self.assertEqual(label, "(Subcall) Subcall to")
+ self.assertEqual(formatted.lower(), "0x" + hexlify(innermost_callee).decode())
+ self.assertEqual(
+ fields[3][0][:2],
+ ("(Subcall) Inner call", hexlify(innermost_blob).decode()),
+ )
+ # the transfer was never resolved: no third-level formatting happened
+ self.assertFalse(any("Send" == f[0][1] for f in fields))
+
+ def test_calldata_invalid_values_rejected(self):
+ msg, defs = self._msg(), self._defs()
+ blob = self._transfer_blob()
+
+ cases = [
+ # field path does not resolve to one bytes blob
+ {(0,): 5, (1,): self.CALLEE},
+ {(0,): [blob, blob], (1,): self.CALLEE}, # arrays not supported
+ # callee is not a 20-byte address
+ {(0,): blob, (1,): 5},
+ {(0,): blob, (1,): b"\x00\x01"},
+ ]
+ for values in cases:
+ fmt = CalldataFormatter(callee_path=(1,))
+ fd = FieldDefinition((0,), "Wrapped call", fmt)
+ with self.assertRaises(InvalidFormatDefinition):
+ await_result(
+ _expand_calldata_field(
+ fd, fmt, lambda path: values[path], msg, defs, False
+ )
+ )
+
+ def test_calldata_container_paths_in_nested_parse(self):
+ # `@.to` resolves to the override; `@.from` and `@.value` are
+ # unresolvable in a subcall.
+ def format_for(path):
+ return DisplayFormat(
+ binding_context=None,
+ func_sig=b"\x00\x00\x00\x00",
+ intent="Test",
+ parameter_definitions=[],
+ field_definitions=[FieldDefinition(path, "Field", RawFormatter)],
+ )
+
+ override = self.CALLEE
+ _, fields = await_result(
+ format_for(ContainerPath.To).parse_calldata(
+ memoryview(b""),
+ self._msg(),
+ self._defs(),
+ nested=True,
+ override_callee=override,
+ )
+ )
+ self.assertEqual(fields[0][0][1], hexlify(override).decode())
+
+ for path in (ContainerPath.From, ContainerPath.Value):
+ with self.assertRaises(InvalidFormatDefinition):
+ await_result(
+ format_for(path).parse_calldata(
+ memoryview(b""), self._msg(), self._defs(), nested=True
+ )
+ )
+
+ def test_calldata_inner_failure_degrades_to_raw(self):
+ # An inner display format exists but cannot be rendered (it references
+ # `@.value`, unresolvable in a subcall) -> the subcall degrades to the
+ # callee + raw hex rows instead of failing the outer transaction.
+ blob = b"\x12\x34\x56\x78" + to_bytes(1)
+ inner_format = DisplayFormat(
+ binding_context=None,
+ func_sig=b"\x12\x34\x56\x78",
+ intent="Inner",
+ parameter_definitions=[Atomic(parse_uint256)],
+ field_definitions=[
+ FieldDefinition(ContainerPath.Value, "Amount", RawFormatter)
+ ],
+ )
+
+ original = clear_signing_definitions.all_display_formats
+ clear_signing_definitions.all_display_formats = lambda: iter([inner_format])
+ try:
+ fields = self._expand(blob)
+ finally:
+ clear_signing_definitions.all_display_formats = original
+
+ self._assert_raw_fallback(fields, blob)
+
if __name__ == "__main__":
unittest.main()
Why this scored 39/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.