Repository: ApeWorX/ape

Issue:
Signing EIP-712 message does not show all the data
### Have you ensured that all of these are up to date?

- [x] Ape
- [x] Ape plugins

### What version of Ape are you on?

_No response_

### What command(s) is the bug in?

_No response_

### Operating System

macOS (Apple Silicon)

### Ape Configuration

_No response_

### Description

When signing am EIP-712 message with a custom type, the data does not display for the custom type:

```
Signing EIP712 Message
Domain
        Name: MyName
        Chain ID: 11155111
        Contract: 0x1234556694Ea18d94dD68bFa786A4D96000000000

Message
        order: Order
```

Order should be shown...

### Reproduction code

_No response_

Discussion:
@linear: <p><a href="https://linear.app/apeworx/issue/APE-1949/signing-eip-712-message-does-not-show-all-the-data">APE-1949 Signing EIP-712 message does not show all the data</a></p>

Solution patch:
diff --git a/src/ape_accounts/accounts.py b/src/ape_accounts/accounts.py
index 3fdd41ace..e0896b6cc 100644
--- a/src/ape_accounts/accounts.py
+++ b/src/ape_accounts/accounts.py
@@ -6,7 +6,7 @@ from pathlib import Path
 from typing import TYPE_CHECKING, Any, Optional
 
 import click
-from eip712.messages import EIP712Message
+from eip712.messages import EIP712Message, EIP712Type
 from eth_account import Account as EthAccount
 from eth_account.hdaccount import ETHEREUM_DEFAULT_PATH
 from eth_account.messages import encode_defunct
@@ -166,49 +166,8 @@ class KeyfileAccount(AccountAPI):
         self.keyfile_path.unlink()
 
     def sign_message(self, msg: Any, **signer_options) -> Optional[MessageSignature]:
-        if isinstance(msg, str):
-            display_msg = f"Signing raw string: '{msg}'"
-            msg = encode_defunct(text=msg)
-
-        elif isinstance(msg, int):
-            display_msg = f"Signing raw integer: {msg}"
-            msg = encode_defunct(hexstr=to_hex(msg))
-
-        elif isinstance(msg, bytes):
-            display_msg = f"Signing raw bytes: '{to_hex(msg)}'"
-            msg = encode_defunct(primitive=msg)
-
-        elif isinstance(msg, EIP712Message):
-            # Display message data to user
-            display_msg = "Signing EIP712 Message\n"
-
-            # Domain Data
-            display_msg += "Domain\n"
-            if msg._name_:
-                display_msg += f"\tName: {msg._name_}\n"
-            if msg._version_:
-                display_msg += f"\tVersion: {msg._version_}\n"
-            if msg._chainId_:
-                display_msg += f"\tChain ID: {msg._chainId_}\n"
-            if msg._verifyingContract_:
-                display_msg += f"\tContract: {msg._verifyingContract_}\n"
-            if msg._salt_:
-                display_msg += f"\tSalt: {to_hex(msg._salt_)}\n"
-
-            # Message Data
-            display_msg += "Message\n"
-            for field, value in msg._body_["message"].items():
-                if isinstance(value, bytes):
-                    value = to_hex(value)
-                display_msg += f"\t{field}: {value}\n"
-
-            # Convert EIP712Message to SignableMessage for handling below
-            msg = msg.signable_message
-
-        elif isinstance(msg, SignableMessage):
-            display_msg = str(msg)
-
-        else:
+        display_msg, msg = _get_signing_message_with_display(msg)
+        if display_msg is None:
             logger.warning("Unsupported message type, (type=%r, msg=%r)", type(msg), msg)
             return None
 
@@ -383,3 +342,70 @@ def import_account_from_private_key(
     account = EthAccount.from_key(to_bytes(hexstr=private_key))
 
     return _write_and_return_account(alias, passphrase, account)
+
+
+# Abstracted to make testing easier.
+def _get_signing_message_with_display(msg) -> tuple[Optional[str], Any]:
+    display_msg = None
+
+    if isinstance(msg, str):
+        display_msg = f"Signing raw string: '{msg}'"
+        msg = encode_defunct(text=msg)
+
+    elif isinstance(msg, int):
+        display_msg = f"Signing raw integer: {msg}"
+        msg = encode_defunct(hexstr=to_hex(msg))
+
+    elif isinstance(msg, bytes):
+        display_msg = f"Signing raw bytes: '{to_hex(msg)}'"
+        msg = encode_defunct(primitive=msg)
+
+    elif isinstance(msg, EIP712Message):
+        # Display message data to user
+        display_msg = "Signing EIP712 Message\n"
+
+        # Domain Data
+        display_msg += "Domain\n"
+        if msg._name_:
+            display_msg += f"\tName: {msg._name_}\n"
+        if msg._version_:
+            display_msg += f"\tVersion: {msg._version_}\n"
+        if msg._chainId_:
+            display_msg += f"\tChain ID: {msg._chainId_}\n"
+        if msg._verifyingContract_:
+            display_msg += f"\tContract: {msg._verifyingContract_}\n"
+        if msg._salt_:
+            display_msg += f"\tSalt: {to_hex(msg._salt_)}\n"
+
+        # Message Data
+        display_msg += "Message\n"
+        for field, value in msg._body_["message"].items():
+            if isinstance(value, EIP712Type):
+                msg_fields = [x for x in dir(value) if not x.startswith("_")]
+                msg_value = ""
+                for msg_field in msg_fields:
+                    attr = getattr(value, msg_field)
+                    if isinstance(attr, bytes):
+                        attr = to_hex(attr)
+
+                    msg_value += f"\t\t{msg_field}: {attr}\n"
+
+                display_msg += f"\t{field}:\n{msg_value}\n"
+
+            else:
+                if isinstance(value, bytes):
+                    value = to_hex(value)
+
+                display_msg += f"\t{field}: {value}\n"
+
+        display_msg = display_msg.strip()
+        msg = msg.signable_message
+
+    elif isinstance(msg, SignableMessage):
+        display_msg = f"{msg}"
+
+    # Using 2 spaces is cleaner than a full tab.
+    if display_msg is not None:
+        display_msg = display_msg.replace("\t", "  ")
+
+    return (display_msg, msg)


Test patch:
diff --git a/tests/functional/test_accounts.py b/tests/functional/test_accounts.py
index 4880a0dd3..76fba87cc 100644
--- a/tests/functional/test_accounts.py
+++ b/tests/functional/test_accounts.py
@@ -1,7 +1,7 @@
 from os import environ
 
 import pytest
-from eip712.messages import EIP712Message
+from eip712.messages import EIP712Message, EIP712Type
 from eth_account.messages import encode_defunct
 from eth_pydantic_types import HexBytes
 from eth_utils import to_hex
@@ -23,6 +23,7 @@ from ape.types.signatures import recover_signer
 from ape.utils.testing import DEFAULT_TEST_MNEMONIC
 from ape_accounts.accounts import (
     KeyfileAccount,
+    _get_signing_message_with_display,
     generate_account,
     import_account_from_mnemonic,
     import_account_from_private_key,
@@ -61,9 +62,14 @@ def message():
     return encode_defunct(text="Hello Apes!")
 
 
+class Baz(EIP712Type):
+    addr: "address"  # type: ignore  # noqa: F821
+
+
 class Foo(EIP712Message):
     _name_: "string" = "Foo"  # type: ignore  # noqa: F821
     bar: "address"  # type: ignore  # noqa: F821
+    baz: Baz
 
 
 def test_sign_message(signer, message):
@@ -112,7 +118,8 @@ def test_recover_signer(signer, message):
 
 
 def test_sign_eip712_message(signer):
-    foo = Foo(signer.address)  # type: ignore[call-arg]
+    baz = Baz(addr=signer.address)  # type: ignore[call-arg]
+    foo = Foo(bar=signer.address, baz=baz)  # type: ignore[call-arg]
     signature = signer.sign_message(foo)
     assert signer.check_signature(foo, signature)
 
@@ -134,6 +141,23 @@ def test_sign_message_with_prompts(runner, keyfile_account, message):
     assert start_nonce == end_nonce
 
 
+def test_sign_eip712_message_shows_custom_types(signer):
+    baz = Baz(addr=signer.address)  # type: ignore[call-arg]
+    foo = Foo(bar=signer.address, baz=baz)  # type: ignore[call-arg]
+    display_msg, msg = _get_signing_message_with_display(foo)
+    expected = """
+Signing EIP712 Message
+Domain
+  Name: Foo
+Message
+  bar: 0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc
+  baz:
+    addr: 0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc
+""".strip()
+    assert display_msg
+    assert display_msg.strip() == expected
+
+
 def test_sign_raw_hash(runner, keyfile_account):
     # NOTE: `message` is a 32 byte raw hash, which is treated specially
     message = b"\xab" * 32