Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-7964: Cross-Chain EIP-712 Signatures

Support EIP-712 signatures for cross-chain account operations.

Authors Ernesto García (@ernestognw)
Created 2025-06-05
Discussion Link https://ethereum-magicians.org/t/universal-cross-chain-signatures-for-account-abstraction/24452
Requires EIP-712

Abstract

This ERC defines a standard approach for creating and verifying cross-chain signatures using EIP-712. By omitting the chainId field from the EIP-712 domain (which is optional per the standard), a signature can be valid across multiple chains. Chain-specific operations are encoded as an array of structured messages, where each chain receives the array of message hashes and only the full message data relevant to that chain. This enables efficient cross-chain intents, multi-chain DAO voting, and unified account management using standard EIP-712 encoding without requiring special wallet support.

Motivation

Current account abstraction solutions require separate signatures for each blockchain network. This creates poor user experience for cross-chain operations such as:

  • Cross-chain intents: Users wanting to trade assets across multiple chains atomically
  • Multi-chain DAO governance: Voting on proposals that affect protocol instances across different networks
  • Unified account management: Managing the same account deployed on multiple chains
  • Cross-chain social recovery: Recovery processes that span multiple networks

Existing proposals either require complex Merkle tree constructions (which need wallet-specific UI to verify all leaves) or non-standard encoding schemes that lack wallet adoption. This ERC provides a simpler approach using only standard EIP-712 encoding with array types, enabling cross-chain signatures with minimal on-chain overhead while maintaining full transparency in standard wallet signing interfaces.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Cross-Chain Domain Semantics

A cross-chain EIP-712 signature MUST omit the chainId field from the EIP712Domain type and domain object. Since chainId is optional per EIP-712, omitting it signals that the signature is intended for cross-chain validity. The domain’s verifyingContract field MAY be set to the account address that controls the operations across chains if it is deterministically deployed at the same address across all target chains.

Message Array Encoding

Cross-chain operations MUST be encoded as an array of chain-specific message structs. Each struct in the array SHOULD include a nested domain struct identifying both the target chain and verifying contract. This follows EIP-712’s recursive encoding pattern and ensures proper binding to chain-specific contracts.

The nested domain struct SHOULD be named to avoid collision with EIP712Domain (e.g., EIP712ChainDomain) and SHOULD include at minimum the chainId field. In case the root EIP712Domain does not include the verifyingContract field, the nested domain struct MUST include it.

Per EIP-712, arrays are encoded as keccak256(encodeData(element[0]) ‖ encodeData(element[1]) ‖ ... ‖ encodeData(element[n])), where each element is a struct that is recursively encoded as hashStruct(element[i]), and nested structs are also recursively hashed.

On-Chain Verification

When verifying a cross-chain signature on a specific chain, contracts MUST:

  1. Accept an array of message hashes corresponding to all chains in the signed message array
  2. Accept the full message data for the current chain only
  3. Compute hashStruct(currentChainMessage) and verify it matches the hash at the expected position in the provided array
  4. Reconstruct the full EIP-712 signature hash using the array of hashes
  5. Verify the signature against the reconstructed hash

This approach minimizes on-chain calldata and computation costs since only one full message is passed to each chain, while the other chains’ operations are represented as 32-byte hashes.

Wallet Display

Standard EIP-712 wallets will automatically display the array of chain-specific messages in a readable format. Wallets MAY enhance the display by grouping operations by chain and showing chain names instead of chain IDs for better user experience.

Rationale

Standard EIP-712 Compatibility

This ERC uses only standard EIP-712 encoding without any extensions or special constructs. All fields in the EIP712Domain are optional per the standard, so omitting chainId is perfectly valid. This means:

  1. No Special Wallet Support Required: Any wallet that supports EIP-712 can already sign these messages
  2. Readable by Default: Wallets automatically display the array of chain-specific operations
  3. Proven Security: Relies on well-tested EIP-712 encoding rather than custom schemes

Array Encoding vs Merkle Trees

Alternative approaches use Merkle trees to commit to cross-chain operations. While Merkle trees can reduce on-chain overhead for many chains, they have a critical drawback:

Wallet Verification Complexity: Standard EIP-712 wallets cannot display Merkle tree leaves. Users signing a Merkle root have no way to verify all operations in their wallet UI. Wallets would need to implement custom logic to:

  1. Request all leaves from the application
  2. Verify the Merkle tree construction
  3. Display all operations across all chains
  4. Ensure no malicious operations are hidden

This breaks the principle of trustless signing, users must trust the application to correctly provide all leaves, and wallet developers must implement and maintain custom verification logic.

The array-based approach provides full transparency using standard EIP-712. Users see all chain-specific operations in any compliant wallet without custom support. No hidden operations are possible since all array elements are displayed as part of the standard EIP-712 message structure.

On-chain overhead comparison: For reasonable cross-chain operations, the overhead difference is minimal.

With the array approach, each chain receives all N operation hashes (N × 32 bytes). With Merkle trees (assuming a binary tree), each chain receives a Merkle proof of size ceil(log₂(N)) × 32 bytes. Both approaches reconstruct the root/array hash on-chain from the provided data and verify it against the signature, no additional calldata for the root is needed. While Merkle proofs grow logarithmically vs. the array’s linear growth, the practical savings are small for reasonable use cases:

Chains Array (N × 32) Merkle (⌈log₂(N)⌉ × 32) Savings
2 64 bytes (2 × 32) 32 bytes (1 × 32) 32 bytes (1 hash)
3 96 bytes (3 × 32) 64 bytes (2 × 32) 32 bytes (1 hash)
4 128 bytes (4 × 32) 64 bytes (2 × 32) 64 bytes (2 hashes)
5 160 bytes (5 × 32) 96 bytes (3 × 32) 64 bytes (2 hashes)
8 256 bytes (8 × 32) 96 bytes (3 × 32) 160 bytes (5 hashes)

For 2-5 chains (the reasonable case for cross-chain operations), Merkle trees save only 32-64 bytes (1-2 hashes) per transaction. This minimal savings doesn’t justify the complexity and loss of transparency. Merkle trees only become significantly more efficient at 8+ chains, which is an uncommon use case and may indicate the operation should be split into multiple signatures for better user comprehension and failure isolation.

Account-Centric Design

By setting verifyingContract to the user’s account address, this design enables:

  1. Signature Binding: Each signature is bound to a specific account, preventing unauthorized reuse
  2. Counterfactual Accounts: Works with accounts that haven’t been deployed yet
  3. Replay Protection: Applications can implement nonces or other replay protection at the account level

Omitting chainId vs Using Zero

Omitting chainId entirely is cleaner than using a special value like 0:

  1. Semantic Clarity: Absence explicitly signals “no specific chain”
  2. Standards Compliance: All EIP-712 domain fields are optional
  3. No Special Cases: No need to treat 0 as a magic value

Backwards Compatibility

This ERC uses standard EIP-712 without modifications. Existing wallets and applications that support EIP-712 can immediately work with cross-chain signatures without any changes, though they may not recognize the cross-chain semantics.

Applications that verify signatures with a domain that includes a specific chainId will reject cross-chain signatures (where chainId is omitted), providing safe failure by default. Applications that wish to support cross-chain signatures must explicitly implement the verification pattern described in this ERC.

Reference Implementation

A collection of examples of how to use this ERC to fulfill the Motivation use cases.

Cross-Chain Intent Example

A user wants to execute a cross-chain trade: sell USDC on Ethereum, receive ETH on Arbitrum:

{
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" },
      { name: "verifyingContract", type: "address" } // Deterministically deployed at the same address across all target chains
      // Note: chainId is omitted for cross-chain validity
    ],
    CrossChainIntent: [
      { name: "operations", type: "ChainOperation[]" },
      { name: "nonce", type: "uint256" },
      { name: "deadline", type: "uint256" }
    ],
    ChainOperation: [
      { name: "domain", type: "EIP712ChainDomain" },
      { name: "target", type: "address" },
      { name: "value", type: "uint256" },
      { name: "data", type: "bytes" }
    ],
    EIP712ChainDomain: [
      { name: "chainId", type: "uint256" }
      // Note: verifyingContract is omitted since it's specified in the root EIP712Domain
    ]
  },
  primaryType: "CrossChainIntent",
  domain: {
    name: "CrossChainDEX",
    version: "1",
    verifyingContract: "0x123321..." // User's account
  },
  message: {
    operations: [
      {
        domain: {
          chainId: 1
        },
        target: "0xA0b86a33E6776885F5Db...", // USDC contract
        value: 0,
        data: "0xa9059cbb..." // transfer(settler, 1000 USDC)
      },
      {
        domain: {
          chainId: 42161
        },
        target: "0xArbitrumSettler...",
        value: 0,
        data: "0x3ccfd60b..." // claim(0.5 ETH min)
      }
    ],
    nonce: 42,
    deadline: 1704067200
  }
}

On-chain verification on Ethereum:

function executeIntent(
    bytes32[] calldata operationHashes, // [hash(op[0]), hash(op[1])]
    ChainOperation calldata currentOp,   // Full data for op[0]
    uint256 currentIndex,                // 0 (Ethereum is first)
    uint256 nonce,
    uint256 deadline,
    bytes calldata signature
) external {
    // Verify the current operation hash matches
    require(hashStruct(currentOp) == operationHashes[currentIndex], "Invalid operation");
    require(currentOp.domain.chainId == block.chainid, "Wrong chain");
    // Note: verifyingContract validation is omitted because it's already part of the root EIP712Domain

    // Reconstruct the full intent hash
    bytes32 operationsHash = keccak256(abi.encodePacked(operationHashes));
    bytes32 intentHash = hashStruct(CrossChainIntent({
        operations: operationsHash,  // Array is encoded as hash of elements
        nonce: nonce,
        deadline: deadline
    }));

    // Verify signature
    bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, intentHash));
    require(account.isValidSignature(digest, signature) == ERC1271_MAGIC_VALUE);

    // Execute the operation
    (bool success, ) = currentOp.target.call{value: currentOp.value}(currentOp.data);
    require(success, "Execution failed");
}

The Arbitrum settler would use the same signature with currentIndex: 1 and the full data for op[1]. Each chain only receives the full calldata for its own operation, while other operations are represented as 32-byte hashes.

Multi-Chain Governance Example

A DAO member votes on a proposal affecting all chain deployments:

{
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" }
      // Note: verifyingContract is omitted since the governor is deployed on different addresses across chains for this example
    ],
    MultiChainVote: [
      { name: "votes", type: "ChainVote[]" },
      { name: "nonce", type: "uint256" }
    ],
    ChainVote: [
      { name: "domain", type: "EIP712ChainDomain" },
      { name: "proposalId", type: "uint256" },
      { name: "support", type: "uint8" },
      { name: "reason", type: "string" }
    ],
    EIP712ChainDomain: [
      { name: "chainId", type: "uint256" },
      { name: "verifyingContract", type: "address" }
    ]
  },
  primaryType: "MultiChainVote",
  domain: {
    name: "MultiChainDAO",
    version: "1"
  },
  message: {
    votes: [
      {
        domain: {
          chainId: 1,
          verifyingContract: "0x123321..." // DAO contract on Ethereum
        },
        proposalId: 42,
        support: 1, // For
        reason: "This upgrade improves security"
      },
      {
        domain: {
          chainId: 137,
          verifyingContract: "0x321321..." // DAO contract on Polygon
        },
        proposalId: 42,
        support: 1, // For
        reason: "This upgrade improves security"
      }
    ],
    nonce: 7
  }
}

On-chain verification on each DAO:

function castVoteWithSignature(
    bytes32[] calldata voteHashes,
    ChainVote calldata currentVote,
    uint256 currentIndex,
    uint256 nonce,
    bytes calldata signature
) external {
    require(hashStruct(currentVote) == voteHashes[currentIndex], "Invalid vote");
    require(currentVote.domain.chainId == block.chainid, "Wrong chain");
    require(currentVote.domain.verifyingContract == address(this), "Wrong governor");

    bytes32 votesHash = keccak256(abi.encodePacked(voteHashes));
    bytes32 digest = hashTypedData(MultiChainVote({
        votes: votesHash,
        nonce: nonce
    }));

    address voter = recoverSigner(digest, signature);
    _castVote(currentVote.proposalId, voter, currentVote.support, currentVote.reason);
}

This vote signature can be submitted to DAO contracts on both Ethereum and Polygon, enabling coordinated multi-chain governance decisions.

Unified Account Management Example

A user wants to add a new signer to their multisig account deployed across multiple chains:

{
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" },
      { name: "verifyingContract", type: "address" } // Deterministically deployed at the same address across all target chains
    ],
    MultiChainAccountUpdate: [
      { name: "updates", type: "AccountUpdate[]" },
      { name: "nonce", type: "uint256" }
    ],
    AccountUpdate: [
      { name: "domain", type: "EIP712ChainDomain" },
      { name: "operation", type: "uint8" }, // 0=addSigner, 1=removeSigner, 2=changeThreshold
      { name: "signerData", type: "bytes" },
      { name: "threshold", type: "uint256" }
    ],
    EIP712ChainDomain: [
      { name: "chainId", type: "uint256" },
    ]
  },
  primaryType: "MultiChainAccountUpdate",
  domain: {
    name: "MultiChainMultisig",
    version: "1",
    verifyingContract: "0x123321..." // Initiating account
  },
  message: {
    updates: [
      {
        domain: {
          chainId: 1,
        },
        operation: 0, // addSigner
        signerData: "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
        threshold: 3
      },
      {
        domain: {
          chainId: 137,
        },
        operation: 0, // addSigner
        signerData: "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
        threshold: 3
      },
      {
        domain: {
          chainId: 42161,
        },
        operation: 0, // addSigner
        signerData: "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
        threshold: 3
      }
    ],
    nonce: 42
  }
}

On-chain execution:

function updateAccountWithSignature(
    bytes32[] calldata updateHashes,
    AccountUpdate calldata currentUpdate,
    uint256 currentIndex,
    uint256 nonce,
    bytes calldata signature
) external {
    require(hashStruct(currentUpdate) == updateHashes[currentIndex]);
    require(currentUpdate.domain.chainId == block.chainid);
    // Note: verifyingContract validation is omitted because it's already part of the root EIP712Domain

    bytes32 updatesHash = keccak256(abi.encodePacked(updateHashes));
    bytes32 digest = hashTypedData(MultiChainAccountUpdate({
        updates: updatesHash,
        nonce: nonce
    }));

    require(isValidSignature(digest, signature) == ERC1271_MAGIC_VALUE);

    if (currentUpdate.operation == 0) {
        _addSigner(currentUpdate.signerData, currentUpdate.threshold);
    } // ... other operations
}

This signature enables the multisig owners to add a new signer and update the threshold across all chain deployments simultaneously. The same account address exists on Ethereum, Polygon, and Arbitrum, and this single signature authorizes the updates on all three networks.

Cross-Chain Social Recovery Example

A user has lost access to their account and guardians need to initiate recovery across multiple networks:

{
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" }
      // Note: verifyingContract is omitted since the recovery module is deployed on different addresses across chains for this example
    ],
    MultiChainRecovery: [
      { name: "recoveries", type: "ChainRecovery[]" },
      { name: "nonce", type: "uint256" }
    ],
    ChainRecovery: [
      { name: "domain", type: "EIP712ChainDomain" },
      { name: "newOwner", type: "address" }
    ],
    EIP712ChainDomain: [
      { name: "chainId", type: "uint256" },
      { name: "verifyingContract", type: "address" }
    ]
  },
  primaryType: "MultiChainRecovery",
  domain: {
    name: "CrossChainSocialRecovery",
    version: "1"
  },
  message: {
    recoveries: [
      {
        domain: {
          chainId: 1,
          verifyingContract: "0x123321..." // Recovery module on Ethereum
        },
        newOwner: "0x9999999999999999999999999999999999999999"
      },
      {
        domain: {
          chainId: 137,
          verifyingContract: "0x321321..." // Recovery module on Polygon
        },
        newOwner: "0x9999999999999999999999999999999999999999"
      },
      {
        domain: {
          chainId: 42161,
          verifyingContract: "0x432432..." // Recovery module on Arbitrum
        },
        newOwner: "0x9999999999999999999999999999999999999999"
      }
    ],
    nonce: 1
  }
}

On-chain execution with guardian multisig:

function initiateRecoveryWithSignature(
    bytes32[] calldata recoveryHashes,
    ChainRecovery calldata currentRecovery,
    uint256 currentIndex,
    uint256 nonce,
    bytes[] calldata guardianSignatures
) external {
    require(hashStruct(currentRecovery) == recoveryHashes[currentIndex]);
    require(currentRecovery.domain.chainId == block.chainid);
    require(currentRecovery.domain.verifyingContract == address(this));

    bytes32 recoveriesHash = keccak256(abi.encodePacked(recoveryHashes));
    bytes32 digest = hashTypedData(MultiChainRecovery({
        recoveries: recoveriesHash,
        nonce: nonce
    }));

    // Verify guardian signatures (3-of-5 threshold)
    uint256 validSignatures = 0;
    for (uint256 i = 0; i < guardianSignatures.length; i++) {
        address guardian = recoverSigner(digest, guardianSignatures[i]);
        if (isGuardian(guardian)) validSignatures++;
    }
    require(validSignatures >= 3, "Insufficient guardian signatures");

    // Schedule recovery with delay
    _scheduleRecovery(currentRecovery.newOwner, block.timestamp + RECOVERY_DELAY);
}

This signature enables guardians to schedule a recovery operation across all chains. The process includes:

  1. Guardian Signatures: Multiple guardians sign the same cross-chain recovery message (3-of-5 threshold)
  2. Schedule Phase: Once sufficient guardians sign, the recovery is scheduled on all networks with a delay
  3. Security Window: Delay period where malicious recovery attempts can be detected and canceled
  4. Execution Phase: After the delay, the recovery replaces the account’s owner
  5. Cross-Chain Consistency: The same recovery operation is scheduled simultaneously on Ethereum, Polygon, and Arbitrum

Security Considerations

Cross-Chain Replay

This ERC intentionally enables replay across chains, the same signature is designed to be used on multiple chains. The verifyingContract field (set to the user’s account address) binds the signature to a specific account, preventing unauthorized use. However, applications MUST implement their own replay protection mechanisms:

  1. Nonces: Include chain-specific or global nonces in the message
  2. Deadlines: Include expiration timestamps
  3. Execution Tracking: Mark operations as executed to prevent re-execution

Account Validation

Applications verifying signatures SHOULD check that the signing account exists on the current chain. An account that exists on Ethereum but not Polygon should not have signatures accepted on Polygon. For counterfactual accounts that have not been deployed yet, applications should follow ERC-6492 to validate signatures.

Code and State Differences

Contract code and state may differ across chains at the same address. Signatures that pass isValidSignature() on one chain may fail on another due to:

  1. Different Implementations: The account contract may have different code on different chains
  2. State Divergence: Nonces, signers, or other state may differ
  3. Chain-Specific Logic: Some accounts may implement chain-specific validation rules

Applications MUST handle signature validation failures gracefully and should not assume uniform behavior across chains.

Partial Execution Risk

Cross-chain signatures do not guarantee atomic execution. A signature may be:

  1. Successfully executed on some chains but not others
  2. Front-run or censored on specific chains
  3. Invalid on some chains due to state differences

This can result in:

  • Incomplete Intents: User operations partially fulfilled
  • Locked Funds: Assets stuck on intermediate chains
  • Loss of Value: MEV or unfavorable execution

Applications SHOULD implement:

  1. Refund Mechanisms: Allow users to recover from failed partial executions
  2. Execution Coordination: Use solvers or bridges that ensure atomic settlement
  3. Monitoring: Track execution status across all target chains

Message Array Integrity

The message array defines operations for all chains. Applications MUST:

  1. Validate Array Position: Verify the provided message hash appears at the expected index
  2. Verify Chain ID: Confirm the message’s chainId matches the current chain
  3. Prevent Substitution: Ensure the full message data matches the hash

Failing to validate these can allow:

  • Execution of operations intended for other chains
  • Replay of operations across chains in unintended order
  • Substitution attacks where an attacker provides wrong message data

Signature Expiration

Signatures without explicit expiration remain valid indefinitely. If an operation is:

  • Not executed on some chains
  • Delayed due to network congestion
  • Temporarily invalid due to state changes

The signature can be executed later, potentially with unexpected consequences. Applications MUST include deadline or validUntil fields in messages to prevent stale signature execution.

Wallet Display Considerations

Since this ERC relies on standard EIP-712 wallet display, users depend on their wallet to correctly show all cross-chain operations. Users should:

  1. Review All Operations: Check every element in the message array
  2. Verify Chain IDs: Ensure operations target the expected chains
  3. Check Amounts: Verify asset amounts and addresses on each chain
  4. Understand Atomicity: Know that operations may execute independently

Wallet developers SHOULD enhance displays to highlight cross-chain nature and show warnings about partial execution risks.

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Ernesto García (@ernestognw), "ERC-7964: Cross-Chain EIP-712 Signatures [DRAFT]," Ethereum Improvement Proposals, no. 7964, June 2025. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7964.