feat(clear_signing): calldata array support
What changed, and why it matters
This commit adds support in Trezor's Ethereum clear-signing feature for transactions that contain multiple embedded subcalls (like a multicall). Previously, only a single embedded call could be clearly displayed. The change lets the device show each subcall separately with labels like "(Subcall #1)" and handles both a single shared recipient address or a parallel list of recipients. It is a feature addition, not a fix for a known vulnerability.
Review as a normal feature commit. No immediate security action required based on the diff alone. If auditing clear-signing, verify that array length mismatch and non-byns element cases are also handled at the protobuf/message boundary, not only in unit tests.
Security signals we found
New input validation added: callee array length must match subcall array length
Type validation enforced: each subcall blob must be bytes, each callee must be a 20-byte address
Graceful degradation preserved: unparseable subcalls fall back to raw hex display rather than failing the whole transaction
No memory-unsafe operations or secret-handling changes visible in diff
Evidence from the diff
The patch refactors _expand_calldata_field in core/src/apps/ethereum/clear_signing.py to accept either a single bytes blob or a list of blobs, and either a single callee address or a parallel list of addresses. It introduces _expand_one_subcall to process each element, adds length-mismatch validation, and updates fallback/raw display labels to include the subcall index. Tests cover arrays with shared/parallel callees, malformed elements, and empty arrays.
Changed components
Trezor firmware Ethereum clear-signing modulecore/src/apps/ethereum/clear_signing.pycore/tests/test_apps.ethereum.clear_signing.pyInspect captured patch +186 / −16
diff --git a/core/src/apps/ethereum/clear_signing.py b/core/src/apps/ethereum/clear_signing.py
index dc861650..c57d718f 100644
--- a/core/src/apps/ethereum/clear_signing.py
+++ b/core/src/apps/ethereum/clear_signing.py
@@ -1172,26 +1172,78 @@ async def _expand_calldata_field(
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."""
+ """Expand one `calldata` field into display rows.
+
+ The field's path resolves either to one `bytes` blob of embedded calldata
+ (a single subcall), or to an array of such blobs (e.g. a multicall's
+ `bytes[] data`). In the array case each element is expanded as its own
+ subcall and its rows are labeled "(Subcall #1)", "(Subcall #2)", ...;
+ `callee_path` then resolves either to a single address shared by all
+ subcalls or to a parallel array of addresses of the same length."""
+ blobs = path_walker(field_definition.path)
+ callees = path_walker(formatter.callee_path)
+
+ if not isinstance(blobs, list):
+ return await _expand_one_subcall(
+ field_definition, formatter, blobs, callees, msg, defs, nested
+ )
+
+ if isinstance(callees, list):
+ if len(callees) != len(blobs):
+ raise InvalidFormatDefinition
+ else:
+ # Same callee for all subcalls
+ callees = [callees] * len(blobs)
+
+ rows: list[DisplayedField] = []
+ for i, (blob, callee) in enumerate(zip(blobs, callees)):
+ rows.extend(
+ await _expand_one_subcall(
+ field_definition,
+ formatter,
+ blob,
+ callee,
+ msg,
+ defs,
+ nested,
+ index=i + 1,
+ )
+ )
+ return rows
+
+
+async def _expand_one_subcall(
+ field_definition: FieldDefinition,
+ formatter: CalldataFormatter,
+ blob: AnyValue,
+ callee: AnyValue,
+ msg: MsgInSignTx,
+ defs: Definitions,
+ nested: bool,
+ index: int | None = None,
+) -> list[DisplayedField]:
+ """Expand one embedded subcall into display rows.
+
+ 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.
+ `index` is the subcall's 1-based position when it comes from an array of
+ subcalls, reflected in the label prefix: "(Subcall #<index>)"."""
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
+ subcall = TR.ethereum__subcall
+ if index is not None:
+ subcall = f"{subcall} #{index}"
+
def callee_str() -> str:
return lookup_known_address(msg.chain_id, callee) or address_from_bytes(
callee, defs.network
@@ -1201,9 +1253,14 @@ async def _expand_calldata_field(
"""No subparsing. Show the callee and the raw hex blob."""
from ubinascii import hexlify
+ to_label = TR.ethereum__subcall_to
+ blob_label = field_definition.label
+ if index is not None:
+ to_label = f"({subcall}) {TR.ethereum__to}"
+ blob_label = f"({subcall}) {blob_label}"
return [
- ((TR.ethereum__subcall_to, callee_str(), None), None, None),
- ((field_definition.label, hexlify(blob).decode(), None), None, None),
+ ((to_label, callee_str(), None), None, None),
+ ((blob_label, hexlify(blob).decode(), None), None, None),
]
if nested:
@@ -1239,7 +1296,6 @@ async def _expand_calldata_field(
)
return raw_rows()
- subcall = TR.ethereum__subcall
rows: list[DisplayedField] = [
(
(
diff --git a/core/tests/test_apps.ethereum.clear_signing.py b/core/tests/test_apps.ethereum.clear_signing.py
index 7507c849..23cb4143 100644
--- a/core/tests/test_apps.ethereum.clear_signing.py
+++ b/core/tests/test_apps.ethereum.clear_signing.py
@@ -1313,12 +1313,16 @@ class TestEthereumClearSigning(unittest.TestCase):
blob = self._transfer_blob()
cases = [
- # field path does not resolve to one bytes blob
+ # field path does not resolve to bytes blob(s)
{(0,): 5, (1,): self.CALLEE},
- {(0,): [blob, blob], (1,): self.CALLEE}, # arrays not supported
+ {(0,): [blob, 5], (1,): self.CALLEE}, # non-bytes array element
# callee is not a 20-byte address
{(0,): blob, (1,): 5},
{(0,): blob, (1,): b"\x00\x01"},
+ # a callee array must match the subcall array's length
+ {(0,): [blob, blob], (1,): [self.CALLEE]},
+ # a single blob takes a single callee, not an array
+ {(0,): blob, (1,): [self.CALLEE]},
]
for values in cases:
fmt = CalldataFormatter(callee_path=(1,))
@@ -1386,6 +1390,116 @@ class TestEthereumClearSigning(unittest.TestCase):
self._assert_raw_fallback(fields, blob)
+ # --- arrays of subcalls (`bytes[] data`, e.g. a multicall) ---
+
+ @staticmethod
+ def _encode_bytes_array(blobs):
+ # ABI body of a `bytes[]`: element count, one offset word per element
+ # (relative to the start of the offsets block), then the
+ # length-prefixed, right-padded elements.
+ offsets = []
+ tails = b""
+ for blob in blobs:
+ offsets.append(32 * len(blobs) + len(tails))
+ tails += to_bytes(len(blob)) + blob + b"\x00" * (-len(blob) % 32)
+ return (
+ to_bytes(len(blobs)) + b"".join(to_bytes(o) for o in offsets) + tails
+ )
+
+ def _expand_array(self, blobs, callees=None):
+ """Like `_expand`, but the wrapper is `wrapper(callee(s), bytes[] data)`:
+ `data` carries one embedded subcall per element. `callees` switches the
+ first parameter from a single shared address to a parallel `address[]`."""
+ if callees is None:
+ callee_param = Atomic(parse_address)
+ calldata = b"\x00" * 12 + self.CALLEE + to_bytes(64)
+ else:
+ callee_param = Array(Atomic(parse_address))
+ callees_body = to_bytes(len(callees)) + b"".join(
+ b"\x00" * 12 + c for c in callees
+ )
+ calldata = to_bytes(64) + to_bytes(64 + len(callees_body)) + callees_body
+ calldata += self._encode_bytes_array(blobs)
+
+ display_format = DisplayFormat(
+ binding_context=None,
+ func_sig=b"\x00\x00\x00\x00",
+ intent="Test",
+ parameter_definitions=[callee_param, Array(DynamicLeaf(parse_bytes))],
+ field_definitions=[
+ FieldDefinition(
+ (1,), "Wrapped call", CalldataFormatter(callee_path=(0,))
+ )
+ ],
+ )
+ defs = self._defs()
+ _, fields = await_result(
+ display_format.parse_calldata(memoryview(calldata), self._msg(), defs)
+ )
+ return fields, defs
+
+ def test_calldata_array_of_subcalls(self):
+ # Two embedded transfers sharing one callee: each expands as its own
+ # subcall, rows indexed "(Subcall #1)" / "(Subcall #2)", and the token
+ # resolves per subcall via the shared callee.
+ blob = self._transfer_blob()
+ fields, defs = self._expand_array([blob, blob])
+
+ self.assertEqual(len(fields), 8)
+ for n, group in ((1, fields[:4]), (2, fields[4:])):
+ prefix = f"(Subcall #{n}) "
+ self.assertEqual(group[0][0][:2], (prefix + "Provider", "Lido"))
+ self.assertEqual(group[1][0][:2], (prefix + "Intent", "Send"))
+ (label, formatted, _), _, _ = group[2]
+ self.assertEqual(label, prefix + "To")
+ self.assertEqual(
+ formatted.lower(), "0x" + hexlify(self.RECIPIENT).decode()
+ )
+ (label, formatted, _), token, token_address = group[3]
+ self.assertEqual(label, prefix + "Amount")
+ self.assertEqual(formatted, "2 TST")
+ self.assertEqual(token.symbol, "TST")
+ self.assertEqual(token_address, self.CALLEE)
+ self.assertEqual(defs.token_requests, [self.CALLEE, self.CALLEE])
+
+ def test_calldata_array_parallel_callees(self):
+ # `callee_path` resolves to an `address[]` parallel to the subcall
+ # array: each subcall is displayed against its own callee.
+ other_callee = b"\x99" * 20 # not in KNOWN_ADDRESSES
+ blob = self._transfer_blob()
+ fields, defs = self._expand_array(
+ [blob, blob], callees=[self.CALLEE, other_callee]
+ )
+
+ self.assertEqual(len(fields), 8)
+ self.assertEqual(fields[0][0][:2], ("(Subcall #1) Provider", "Lido"))
+ (label, provider, _), _, _ = fields[4]
+ self.assertEqual(label, "(Subcall #2) Provider")
+ self.assertEqual(provider.lower(), "0x" + hexlify(other_callee).decode())
+ # each subcall's token resolves via its own callee
+ self.assertEqual(defs.token_requests, [self.CALLEE, other_callee])
+
+ def test_calldata_array_raw_fallback(self):
+ # Elements that cannot be clear-signed degrade independently, with the
+ # index carried into the fallback rows' labels.
+ good_blob = self._transfer_blob()
+ bad_blob = b"\xde\xad\xbe\xef" + to_bytes(5)
+ fields, _ = self._expand_array([bad_blob, good_blob])
+
+ self.assertEqual(len(fields), 6)
+ self.assertEqual(fields[0][0][:2], ("(Subcall #1) To", "Lido"))
+ self.assertEqual(
+ fields[1][0][:2],
+ ("(Subcall #1) Wrapped call", hexlify(bad_blob).decode()),
+ )
+ self.assertEqual(fields[2][0][:2], ("(Subcall #2) Provider", "Lido"))
+
+ def test_calldata_array_empty(self):
+ # An empty subcall array legitimately contributes no rows.
+ fields, defs = self._expand_array([])
+ self.assertEqual(fields, [])
+ self.assertEqual(defs.token_requests, [])
+
if __name__ == "__main__":
unittest.main()
Why this scored 30/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.