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

Metadata

Metadata

Assignees

Labels

C-castCommand: castT-bugType: bug

Type

No type

Projects

Status

Completed

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions