Skip to content

Cast Wallet Sign Does not work when colons are in type name #10765

Closed
@addiaddiaddi

Description

@addiaddiaddi

Component

Cast

Have you ensured that all of these are up to date?

  • Foundry
    Foundryup

What version of Foundry are you on?

forge Version: 1.2.3-stable Commit SHA: a813a2c Build Timestamp: 2025-06-08T15:42:50.507050000Z (1749397370) Build Profile: maxperf

What version of Foundryup are you on?

foundryup: 1.1.0

What command(s) is the bug in?

cast wallet sign

Operating System

None

Describe the bug

Cast EIP-712 Type Name Colon Bug Report

Summary

cast wallet sign --data fails to parse valid EIP-712 structured data when type names contain colons (:) characters, even though colons are permitted in EIP-712 type names according to the specification.

Bug Details

Error Message:

Error: failed to parse json file: data did not match any variant of untagged enum StrOrVal

Affected Command:

cast wallet sign --private-key <key> --data --from-file <eip712.json>

Expected Behavior

Cast should successfully parse and sign EIP-712 data with type names containing colons, as these are valid according to the EIP-712 specification.

Actual Behavior

Cast fails with a parsing error when EIP-712 type names contain colons.

Proof of Concept

Working Case (No Colon)

{
  "types": {
    "EIP712Domain": [...],
    "TestMessage": [
      {"name": "content", "type": "string"}
    ]
  },
  "primaryType": "TestMessage",
  ...
}

Result: Cast successfully signs this data

Failing Case (With Colon)

{
  "types": {
    "EIP712Domain": [...],
    "Test:Message": [
      {"name": "content", "type": "string"}
    ]
  },
  "primaryType": "Test:Message",
  ...
}

Result: Cast fails with parsing error

Real-World Impact

This bug affects protocols that use colons in their EIP-712 type names, such as:

  • Hyperliquid (uses types like HyperliquidTransaction:UsdSend, HyperliquidTransaction:CDeposit)
  • Other protocols following similar naming conventions

Verification

The Python eth_account library correctly handles both cases, proving that colons in EIP-712 type names are valid:

from eth_account.messages import encode_typed_data
from eth_account import Account

# Both of these work correctly with eth_account:
structured_data_1 = encode_typed_data(full_message=data_without_colon)
structured_data_2 = encode_typed_data(full_message=data_with_colon)

wallet = Account.create()
signature_1 = wallet.sign_message(structured_data_1)  # ✅ Works
signature_2 = wallet.sign_message(structured_data_2)  # ✅ Works

Reproduction Steps

  1. Create a simple EIP-712 structure with a colon in the type name
  2. Try to sign it with cast wallet sign --data --from-file
  3. Observe the parsing failure
  4. Remove the colon and try again - it will work

Here is an example script that demonstrates the issue. This script signs 2 bodies with colons, and 2 without colons. eth_account succeeds for all four, while cast fails for the bodies with colons.

#!/usr/bin/env python3
"""
Minimal debug to find exactly what's breaking cast
"""

import json
import subprocess
import tempfile
from eth_account import Account
from eth_account.messages import encode_typed_data

def test_cast_variations():
    """Test different variations to isolate the issue"""
    
    wallet = Account.create()
    print(f"Test wallet: {wallet.address}")
    print()
    
    # Test 1: Very simple structure (should work)
    simple = {
        "types": {
            "EIP712Domain": [
                {"name": "name", "type": "string"},
                {"name": "version", "type": "string"},
                {"name": "chainId", "type": "uint256"},
                {"name": "verifyingContract", "type": "address"}
            ],
            "TestMessage": [
                {"name": "content", "type": "string"}
            ]
        },
        "primaryType": "TestMessage",
        "domain": {
            "name": "Test",
            "version": "1",
            "chainId": 1,
            "verifyingContract": "0x0000000000000000000000000000000000000000"
        },
        "message": {
            "content": "hello"
        }
    }
    
    # Test 2: Same but with colon in type name
    with_colon = {
        "types": {
            "EIP712Domain": [
                {"name": "name", "type": "string"},
                {"name": "version", "type": "string"},
                {"name": "chainId", "type": "uint256"},
                {"name": "verifyingContract", "type": "address"}
            ],
            "Test:Message": [
                {"name": "content", "type": "string"}
            ]
        },
        "primaryType": "Test:Message",
        "domain": {
            "name": "Test",
            "version": "1",
            "chainId": 1,
            "verifyingContract": "0x0000000000000000000000000000000000000000"
        },
        "message": {
            "content": "hello"
        }
    }
    
    # Test 3: Hyperliquid domain but simple message
    hyperliquid_domain = {
        "types": {
            "EIP712Domain": [
                {"name": "name", "type": "string"},
                {"name": "version", "type": "string"},
                {"name": "chainId", "type": "uint256"},
                {"name": "verifyingContract", "type": "address"}
            ],
            "Message": [
                {"name": "content", "type": "string"}
            ]
        },
        "primaryType": "Message",
        "domain": {
            "name": "HyperliquidSignTransaction",
            "version": "1",
            "chainId": 421614,
            "verifyingContract": "0x0000000000000000000000000000000000000000"
        },
        "message": {
            "content": "hello"
        }
    }
    
    # Test 4: Hyperliquid domain with colon type
    hyperliquid_colon = {
        "types": {
            "EIP712Domain": [
                {"name": "name", "type": "string"},
                {"name": "version", "type": "string"},
                {"name": "chainId", "type": "uint256"},
                {"name": "verifyingContract", "type": "address"}
            ],
            "Test:Message": [
                {"name": "content", "type": "string"}
            ]
        },
        "primaryType": "Test:Message",
        "domain": {
            "name": "HyperliquidSignTransaction",
            "version": "1",
            "chainId": 421614,
            "verifyingContract": "0x0000000000000000000000000000000000000000"
        },
        "message": {
            "content": "hello"
        }
    }
    
    tests = [
        (simple, "Simple structure"),
        (with_colon, "With colon in type name"),
        (hyperliquid_domain, "Hyperliquid domain, simple message"),
        (hyperliquid_colon, "Hyperliquid domain with colon type")
    ]
    
    # Test cast signing
    print("CAST WALLET SIGN TESTS:")
    print("=" * 50)
    for data, name in tests:
        test_cast_signing(data, name, wallet)
    
    # Test eth_account signing
    print("\nETH_ACCOUNT SIGNING TESTS:")
    print("=" * 50)
    test_eth_account_signing(tests, wallet)

def test_cast_signing(data, name, wallet):
    """Test cast wallet sign with given data"""
    print(f"=== {name} ===")
    
    # Use a permanent file instead of temporary
    filename = f"test_data_{name.lower().replace(' ', '_').replace(',', '')}.json"
    
    print(f"Writing to {filename}")
    with open(filename, 'w') as f:
        json.dump(data, f, indent=2)
    
    try:
        result = subprocess.run([
            'cast', 'wallet', 'sign',
            '--private-key', wallet.key.hex(),
            '--data',
            '--from-file',
            filename
        ], capture_output=True, text=True)
        
        if result.returncode == 0:
            signature_hex = result.stdout.strip()
            print(f"✅ SUCCESS: Cast signed successfully")
            print(f"   Raw signature: {signature_hex}")
            
            # Decode the signature (cast returns 0x + 64 bytes r + 64 bytes s + 1 byte v)
            if signature_hex.startswith('0x'):
                sig_data = signature_hex[2:]
            else:
                sig_data = signature_hex
            
            if len(sig_data) == 130:  # 64 + 64 + 2 hex chars
                r_hex = sig_data[:64]
                s_hex = sig_data[64:128]
                v_hex = sig_data[128:130]
                
                print(f"   r: 0x{r_hex}")
                print(f"   s: 0x{s_hex}")
                print(f"   v: {int(v_hex, 16)}")
            else:
                print(f"   ⚠️  Unexpected signature length: {len(sig_data)} chars")
        else:
            print(f"❌ FAILED: {result.stderr.strip()}")
        
    except Exception as e:
        print(f"❌ EXCEPTION: {e}")
    
    print()

def test_eth_account_signing(tests, wallet):
    """Test eth_account signing with the same data structures"""
    print("Testing eth_account library with the same EIP-712 structures...")
    print("(This demonstrates that the data structures are valid)")
    print()
    
    for data, name in tests:
        print(f"=== {name} ===")
        
        try:
            # Use eth_account to encode and sign the typed data
            structured_data = encode_typed_data(full_message=data)
            signed_message = wallet.sign_message(structured_data)
            
            print(f"✅ SUCCESS: eth_account signed successfully")
            print(f"   Message hash: {signed_message.message_hash.hex()}")
            print(f"   Signature: {signed_message.signature.hex()}")
            print(f"   r: {hex(signed_message.r)}")
            print(f"   s: {hex(signed_message.s)}")
            print(f"   v: {signed_message.v}")
            
        except Exception as e:
            print(f"❌ FAILED: {e}")
        
        print()
    
    print("CONCLUSION:")
    print("- eth_account successfully handles ALL test cases, including those with colons")
    print("- This proves the EIP-712 structures are valid according to the specification")
    print("- Cast's failure with colons is a bug in cast's JSON parser, not the data")

if __name__ == "__main__":
    test_cast_variations() 

Environment

  • Cast version: 0.2.0 (git: 8b1c9b2, book: 68bf1dc)
  • OS: macOS
  • Python: 3.9+
  • eth_account: 0.8.0+

Proposed Fix

The cast EIP-712 parser should be updated to accept colons in type names, as they are valid characters according to the EIP-712 specification.

Workaround

For now, protocols can work around this by:

  1. Replacing colons with underscores in type names when using cast
  2. Ensuring the replacement is consistent across primaryType and types definitions
  3. Note: This changes the signature hash, so it's not compatible with existing signatures

References

Activity

addiaddiaddi

addiaddiaddi commented on Jun 12, 2025

@addiaddiaddi
Author

Reproduction script response:

python3 cast_debug_minimal.py
Test wallet: 0x35fAd9e359d802Dd638aCd336963a9d06a11998F

CAST WALLET SIGN TESTS:
==================================================
=== Simple structure ===
Writing to test_data_simple_structure.json
✅ SUCCESS: Cast signed successfully
   Raw signature: 0x6e2f612df52e1b405438333364f592b1e7bbebe210db7242b9df4866183b9ad831a7ecfc209cef9153c56c841efe6e5902530e2bbbe3a894e8c76f73563d083d1c
   r: 0x6e2f612df52e1b405438333364f592b1e7bbebe210db7242b9df4866183b9ad8
   s: 0x31a7ecfc209cef9153c56c841efe6e5902530e2bbbe3a894e8c76f73563d083d
   v: 28

=== With colon in type name ===
Writing to test_data_with_colon_in_type_name.json
❌ FAILED: Error: failed to parse json file: "test_data_with_colon_in_type_name.json": data did not match any variant of untagged enum StrOrVal

=== Hyperliquid domain, simple message ===
Writing to test_data_hyperliquid_domain_simple_message.json
✅ SUCCESS: Cast signed successfully
   Raw signature: 0x8e449db77851a0312fbc1d9c70b7690bd46f0d9c78e554d16d7db1063a26ab99338babc535c557fb2670413d5897fa2e61da3e2924d555b2c0a22d628a8247a41c
   r: 0x8e449db77851a0312fbc1d9c70b7690bd46f0d9c78e554d16d7db1063a26ab99
   s: 0x338babc535c557fb2670413d5897fa2e61da3e2924d555b2c0a22d628a8247a4
   v: 28

=== Hyperliquid domain with colon type ===
Writing to test_data_hyperliquid_domain_with_colon_type.json
❌ FAILED: Error: failed to parse json file: "test_data_hyperliquid_domain_with_colon_type.json": data did not match any variant of untagged enum StrOrVal


ETH_ACCOUNT SIGNING TESTS:
==================================================
Testing eth_account library with the same EIP-712 structures...
(This demonstrates that the data structures are valid)

=== Simple structure ===
✅ SUCCESS: eth_account signed successfully
   Message hash: 7e7453ea342b211f1f96229eac54106a87e1d9304f059202c27d298d2a6366da
   Signature: 6e2f612df52e1b405438333364f592b1e7bbebe210db7242b9df4866183b9ad831a7ecfc209cef9153c56c841efe6e5902530e2bbbe3a894e8c76f73563d083d1c
   r: 0x6e2f612df52e1b405438333364f592b1e7bbebe210db7242b9df4866183b9ad8
   s: 0x31a7ecfc209cef9153c56c841efe6e5902530e2bbbe3a894e8c76f73563d083d
   v: 28

=== With colon in type name ===
✅ SUCCESS: eth_account signed successfully
   Message hash: 6865f9ca461056e5577002f33bb1d7cecfa52ad34a8c6dc285ce2fda51d9365b
   Signature: 7cadacefd91da3243d6a1387f7af7049005191040758f38af05d69077f94d6890d2f0a56efaba854367fcf9324c7ff19a9ea106dd015d8ca7395acf865f55fe01c
   r: 0x7cadacefd91da3243d6a1387f7af7049005191040758f38af05d69077f94d689
   s: 0xd2f0a56efaba854367fcf9324c7ff19a9ea106dd015d8ca7395acf865f55fe0
   v: 28

=== Hyperliquid domain, simple message ===
✅ SUCCESS: eth_account signed successfully
   Message hash: 3396a9a1a199ada5b73f019360b6a14ccb34b4055987b7a8484e45b672c9327b
   Signature: 8e449db77851a0312fbc1d9c70b7690bd46f0d9c78e554d16d7db1063a26ab99338babc535c557fb2670413d5897fa2e61da3e2924d555b2c0a22d628a8247a41c
   r: 0x8e449db77851a0312fbc1d9c70b7690bd46f0d9c78e554d16d7db1063a26ab99
   s: 0x338babc535c557fb2670413d5897fa2e61da3e2924d555b2c0a22d628a8247a4
   v: 28

=== Hyperliquid domain with colon type ===
✅ SUCCESS: eth_account signed successfully
   Message hash: 45f2dd63abcc6e512fff7a8234cca0256db9f91e8f00a74caf814739b75b86c7
   Signature: 07cdbf8e6f389fad1a788347dfc3712ed69a8e94ec206bea6faca7fb8360479c3d4bdaf268714e26cc687cb526068fc5f332d8805ab9cc0dbbd97f4922941f111c
   r: 0x7cdbf8e6f389fad1a788347dfc3712ed69a8e94ec206bea6faca7fb8360479c
   s: 0x3d4bdaf268714e26cc687cb526068fc5f332d8805ab9cc0dbbd97f4922941f11
   v: 28

CONCLUSION:
- eth_account successfully handles ALL test cases, including those with colons
- This proves the EIP-712 structures are valid according to the specification
- Cast's failure with colons is a bug in cast's JSON parser, not the data
added a commit that references this issue on Jun 13, 2025
6f76667
added
C-castCommand: cast
and removed
T-needs-triageType: this issue needs to be labelled
on Jun 13, 2025
moved this from Backlog to In Progress in Foundryon Jun 13, 2025
prestwich

prestwich commented on Jun 13, 2025

@prestwich
Contributor

cast wallet sign --data fails to parse valid EIP-712 structured data when type names contain colons (:) characters, even though colons are permitted in EIP-712 type names according to the specification.

the spec defines them to be valid identifiers, and the identifier grammar rule does not allow :, so according to the 712 spec, colons are illegal

alloy-rs/core#963 (comment)

0xrusowsky

0xrusowsky commented on Jun 16, 2025

@0xrusowsky
Contributor

despite Hyperliquid namespacing using : and not being compliant with the EIP712 spec, we decided to support it.

see the relevant discussion in the PR alloy-rs/core#964

moved this from In Progress to Done in Foundryon Jun 20, 2025
added a commit that references this issue on Jun 20, 2025
2b3f9ff
moved this from Done to Completed in Foundryon Jun 24, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

C-castCommand: castT-bugType: bug

Type

No type

Projects

Status

Completed

Milestone

No milestone

Relationships

None yet

    Development

    Participants

    @prestwich@mattsse@addiaddiaddi@0xrusowsky@zerosnacks

    Issue actions

      Cast Wallet Sign Does not work when colons are in type name · Issue #10765 · foundry-rs/foundry