ERC-7964 - Crosschain EIP-712 Signatures

Created 2025-06-05
Status Draft
Category ERC
Type Standards Track
Authors
Requires

Abstract

This ERC defines a standard approach for creating and verifying crosschain signatures using EIP-712. By omitting the chainId field from the EIP-712 domain, 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 crosschain signature validation 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 crosschain operations such as:

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 crosschain 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.

Crosschain Domain Semantics

A crosschain 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 crosschain validity.

Contract addresses and chainIds MUST be validated per-chain, so typically, only name and version are included in the main domain. The verifyingContract field MAY be used to bind the signature to a specific application deployed deterministically on the same address across all chains the signature is intended to be valid on, allowing to omit the contract address from the chain-specific structs.

Message Array Encoding

Crosschain operations MUST be encoded as an array of message structs with chain-specific fields. Each struct in the array SHOULD include the target chainId field. When contracts are deployed at different addresses across chains, the struct SHOULD also include the verifyingContract (or equivalent) field to bind each operation to its specific contract address unless the main domain includes the verifyingContract field.

Implementers MAY use the canonical EIP712Domain struct but named to avoid collisions with EIP712Domain (e.g., EIP712ChainDomain) so it can be nested inside the chain-specific structs, allowing to include the chainId and verifyingContract fields.

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.

In practice, this means the array of chain-specific operations is represented as an array of pre-computed bytes32 struct hashes (one for each chain). The hash of this array is computed as keccak256(abi.encodePacked(structsArray)), which concatenates all 32-byte hashes and produces the array hash that matches the standard EIP-712 array encoding for reference types.

Struct Hash

Per EIP-712, the signature is computed over a complete message struct (e.g., CrossChainIntent, MultiChainVote), not just the array of operations. Applications implementing verification MUST define a type hash for the complete message struct that includes the array of operations.

Wrapping the array of operations in a main struct also allows to include other message fields (e.g., nonce, deadline) in the struct hash. For example, for a CrossChainIntent struct would be encoded as:

   bytes32 constant CROSSCHAIN_INTENT_TYPEHASH = keccak256(
       "ChainOperation(EIP712ChainDomain domain,address target,uint256 value,bytes data)CrossChainIntent(ChainOperation[] operations,uint256 nonce,uint256 deadline)EIP712ChainDomain(uint256 chainId,address verifyingContract)"
   );

   fullStructHash = keccak256(abi.encode(
       CROSSCHAIN_INTENT_TYPEHASH,
       keccak256(abi.encodePacked(chainOperationsArray)),
       nonce,
       deadline
   ));

Applications implementing verification MUST use the same typehash across all chains where the application is deployed.

On-Chain Verification

To enable applications to detect and verify crosschain signatures through standard ECDSA validation or ERC-1271 isValidSignature calls, crosschain signatures SHOULD be encoded with metadata in the signature parameter. The signature validation flow requires the contract application to detect the encoding of this ERC, parse the signature components, and then verify the signature agains the reconstructed crosschain message hash.

The signature encoding format is as follows:

[0x00:0x09] magic (9 bytes)
[0x09:0x0a] fields (1 byte)
[0x0a:0x0c] structIndex (2 bytes, big-endian uint16)
[0x0c:0x20] application (20 bytes)
[0x20:0x40] structsArrayLength (32 bytes, big-endian uint256)
[0x40:0x40 + structsArrayLength * 32] structsArray (structsArrayLength * 32 bytes)
[0x40 + structsArrayLength * 32:0x40 + structsArrayLength * 32 + 32] crossChainSignatureLength (32 bytes, big-endian uint256)
[0x40 + structsArrayLength * 32 + 32:0x40 + structsArrayLength * 32 + 32 + crossChainSignatureLength] crossChainSignature (crossChainSignatureLength bytes)

Where:

This encoding allows applications to identify when a signature is a crosschain signature, rebuild the EIP-712 domain information needed to reconstruct the expected typed hash, and verify the signature against the reconstructed crosschain message hash.

Applications can detect crosschain signatures by checking if the first 9 bytes of the signature equal the magic value 0x796479647964796479. If detected, applications MUST:

  1. Parse the encoded metadata from the signature
  2. Verify that structsArray[structIndex] matches the struct hash of the current chain's message
  3. Query the ERC-5267 contract at application to obtain the EIP-712 domain
  4. Reconstruct the full EIP-712 hash using the domain, the main struct hash and the array of chain-specific struct hashes
  5. Verify the crossChainSignature against the reconstructed typed hash

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 any wallet that supports EIP-712 can sign these messages and any application that supports EIP-712 can verify them.

The main benefit of this approach is that users can achieve a full transparent view of the crosschain message in any wallet provider that supports EIP-712.

Main Struct Hash

For wallet providers to show the crosschain message properly, they need a primaryType to display the message. This specification does not mandate a specific primaryType to allow for flexibility in the implementation, but defines the requirements for the array of operations and the main struct to ensure a secure EIP-712 message.

The specification requires that each of the operations include the chainId field to avoid replaying the same operation on other chains. The verifyingContract field (or equivalent) is used to bind the operation to its specific contract address and is optional if the main EIP712Domain includes the verifyingContract field since the domain hash would include the application's address.

Array Encoding vs Merkle Trees

Alternative approaches use Merkle trees to commit to crosschain 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 crosschain 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 crosschain 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.

Omitting chainId vs using 0

Omitting chainId entirely is cleaner than using a special value like 0 because it explicitly signals that the signature is intended for crosschain validity.

Signature Encoding Format

The custom binary encoding format for crosschain signatures was selected to minimize calldata and memory overhead. The format packs metadata values that can be read to the stack and uses ABI encoding for structsArray and crossChainSignature for easy calldata and memory casting.

The magic value is used to detect crosschain signatures and is designed to be easy to parse and verify. It's a fixed 9-byte value that is easy to identify and verify. The length was selected to pack it along with bytes1(fields), uint16(structIndex) and address(application) into a single 32-byte word. While 9 bytes is shorter than a full 32-byte hash, the collision probability remains negligible in practice—an attacker would need to produce a valid ECDSA or ERC-1271 signature that randomly begins with these exact 9 bytes, which has probability 2^-72 (approximately 1 in 4.7 × 10^21) assuming a uniform distribution.

The fields byte encodes which EIP-712 domain fields are present in the main domain (per ERC-5267), enabling dynamic field selection. Only name and version are normally set in the main domain, since chainId is omitted for crosschain validity since each chain-specific struct includes its own chainId and optionally verifyingContract (or equivalent), allowing each chain to validate its own contract address as part of the struct hash verification.

The application address refers to any contract that implements ERC-5267's eip712Domain() function. This contract provides the domain separator information needed to reconstruct the crosschain message hash. The application contract does not need to be the contract that executes the operation: it serves solely as a source of EIP-712 domain metadata. Applications could either deploy dedicated immutable domain separator contracts to ensure consistent domain information across all chains, or use the executing contract itself if it implements ERC-5267.

Backwards Compatibility

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

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

Reference Implementation

To validate a crosschain signature, the application can use the following library:

import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import { IERC5267 } from "@openzeppelin/contracts/interfaces/IERC5267.sol";
import { Calldata } from "@openzeppelin/contracts/utils/Calldata.sol";

library CrossChainSignatureChecker {
  error InvalidCrossChainSignatureEncoding();

  function isValidCrossChainSignatureNow(
        address signer,
        bytes32 hash,
        bytes calldata erc7964Signature,
        function(bytes32) internal view returns (bytes32) structHash
    ) internal view returns (bool) {
        (
            bool success,
            bytes1 fields,
            uint16 structIndex,
            address application,
            bytes32[] calldata structsArray,
            bytes calldata crossChainSignature
        ) = parseCrossChainSignatureCalldata(erc7964Signature);
        if (!success || structsArray[structIndex] != hash) return false;

        string memory name;
        string memory version;
        uint256 chainId;
        address verifyingContract;
        bytes32 domainSalt;
        (, name, version, chainId, verifyingContract, domainSalt, ) = IERC5267(application).eip712Domain();

        bytes32 typedHash = MessageHashUtils.toTypedDataHash(
            MessageHashUtils.toDomainSeparator(fields, name, version, chainId, verifyingContract, domainSalt),
            structHash(keccak256(abi.encodePacked(structsArray)))
        );
        return SignatureChecker.isValidSignatureNowCalldata(signer, typedHash, crossChainSignature);
    }

  function parseCrossChainSignature(
        bytes calldata erc7964Signature
    )
        internal
        pure
        returns (
            bool success,
            bytes1 fields,
            uint16 structIndex,
            address application,
            bytes32[] calldata structsArray,
            bytes calldata crossChainSignature
        )
    {
        // magic (9 bytes) + fields (1 byte) + structIndex (2 bytes) + application (20 bytes) + structsArrayLength (32 bytes)
        if (erc7964Signature.length < 64 || bytes9(erc7964Signature[0:9]) != 0x796479647964796479) {
            return (false, 0, 0, address(0), _empty32BytesArrayCalldata(), Calldata.emptyBytes());
        }
        fields = erc7964Signature[9];
        structIndex = uint16(bytes2(erc7964Signature[10:12]));
        application = address(bytes20(erc7964Signature[12:32]));
        uint256 structsArrayLength = uint256(bytes32(erc7964Signature[32:64]));
        uint256 crossChainSignatureLengthOffset = 64 + structsArrayLength * 32;
        if (erc7964Signature.length < crossChainSignatureLengthOffset + 32) {
            return (false, 0, 0, address(0), _empty32BytesArrayCalldata(), Calldata.emptyBytes());
        }
        uint256 crossChainSignatureLength = uint256(
            bytes32(erc7964Signature[crossChainSignatureLengthOffset:crossChainSignatureLengthOffset + 32])
        );
        uint256 crossChainSignatureOffset = crossChainSignatureLengthOffset + 32;
        if (erc7964Signature.length < crossChainSignatureOffset + crossChainSignatureLength) {
            return (false, 0, 0, address(0), _empty32BytesArrayCalldata(), Calldata.emptyBytes());
        }

        assembly ("memory-safe") {
            structsArray.offset := 64
            structsArray.length := structsArrayLength
        }
        crossChainSignature = erc7964Signature[
            crossChainSignatureOffset:crossChainSignatureOffset + crossChainSignatureLength
        ];
        return (true, fields, structIndex, application, structsArray, crossChainSignature);
    }

    function _empty32BytesArrayCalldata() private pure returns (bytes32[] calldata result) {
        assembly ("memory-safe") {
            result.offset := 0
            result.length := 0
        }
    }
}

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

Crosschain Intent Example

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

{
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" }
      // Note: chainId is omitted for crosschain 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" },
      { name: "verifyingContract", type: "address" }
    ]
  },
  primaryType: "CrossChainIntent",
  domain: {
    name: "CrossChainDEX",
    version: "1"
  },
  message: {
    operations: [
      {
        domain: {
          chainId: 1,
          verifyingContract: "0x123321..." // User's account on Ethereum
        },
        target: "0xA0b86a33E6776885F5Db...", // USDC contract
        value: 0,
        data: "0xa9059cbb..." // transfer(settler, 1000 USDC)
      },
      {
        domain: {
          chainId: 42161,
          verifyingContract: "0x123321..." // User's account on Arbitrum
        },
        target: "0xArbitrumSettler...",
        value: 0,
        data: "0x3ccfd60b..." // claim(0.5 ETH min)
      }
    ],
    nonce: 42,
    deadline: 1704067200
  }
}

On-chain verification on Ethereum:

Applications can verify crosschain signatures using the encoded format. The signature contains all necessary metadata to reconstruct and verify the crosschain message:

import { CrossChainSignatureChecker } from "./CrossChainSignatureChecker.sol";

bytes32 constant CROSSCHAIN_INTENT_TYPEHASH = keccak256(
    "CrossChainIntent(bytes32 operations,uint256 nonce,uint256 deadline)"
);

// Store state for struct hash computation
uint256 private _tempNonce;
uint256 private _tempDeadline;

function executeIntent(
    ChainOperation calldata currentOp,   // Full data for op[0]
    uint256 nonce,
    uint256 deadline,
    bytes calldata signature             // Encoded ERC-7964 signature
) external {
    bytes32 currentOpHash = hashStruct(currentOp); // chainId is implicit in the struct hash

    // Store parameters for struct hash computation
    _tempNonce = nonce;
    _tempDeadline = deadline;

    require(
        CrossChainSignatureChecker.isValidCrossChainSignatureNow(
            account,
            currentOpHash,
            signature,
            _computeIntentStructHash
        ),
        "Invalid signature"
    );

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

function _computeIntentStructHash(
    bytes32 operationsHash
) internal view returns (bytes32) {
    return keccak256(abi.encode(
        CROSSCHAIN_INTENT_TYPEHASH,
        operationsHash,
        _tempNonce,
        _tempDeadline
    ));
}

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" }
    ],
    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:

import { CrossChainSignatureChecker } from "./CrossChainSignatureChecker.sol";

bytes32 constant MULTICHAIN_VOTE_TYPEHASH = keccak256(
    "MultiChainVote(bytes32 votes,uint256 nonce)"
);

uint256 private _tempNonce;

function castVoteWithSignature(
    ChainVote calldata currentVote,
    uint256 nonce,
    address voter,
    bytes calldata erc7964Signature
) external {
    // Verify the current vote hash matches the signature
    bytes32 currentVoteHash = hashStruct(currentVote); // chainId and verifyingContract are implicit in the struct hash

    _tempNonce = nonce;

    require(
        CrossChainSignatureChecker.isValidCrossChainSignatureNow(
            voter,
            currentVoteHash,
            erc7964Signature,
            _computeVoteStructHash
        ),
        "Invalid signature"
    );

    _castVote(currentVote.proposalId, voter, currentVote.support, currentVote.reason);
}

function _computeVoteStructHash(
    bytes32 votesHash
) internal view returns (bytes32) {
    return keccak256(abi.encode(
        MULTICHAIN_VOTE_TYPEHASH,
        votesHash,
        _tempNonce
    ));
}

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" }
    ],
    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" },
      { name: "verifyingContract", type: "address" }
    ]
  },
  primaryType: "MultiChainAccountUpdate",
  domain: {
    name: "MultiChainMultisig",
    version: "1"
  },
  message: {
    updates: [
      {
        domain: {
          chainId: 1,
          verifyingContract: "0x123321..." // Account on Ethereum
        },
        operation: 0, // addSigner
        signerData: "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
        threshold: 3
      },
      {
        domain: {
          chainId: 137,
          verifyingContract: "0x123321..." // Account on Polygon
        },
        operation: 0, // addSigner
        signerData: "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
        threshold: 3
      },
      {
        domain: {
          chainId: 42161,
          verifyingContract: "0x123321..." // Account on Arbitrum
        },
        operation: 0, // addSigner
        signerData: "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
        threshold: 3
      }
    ],
    nonce: 42
  }
}

On-chain execution:

import { CrossChainSignatureChecker } from "./CrossChainSignatureChecker.sol";

bytes32 constant MULTICHAIN_ACCOUNT_UPDATE_TYPEHASH = keccak256(
    "MultiChainAccountUpdate(bytes32 updates,uint256 nonce)"
);

uint256 private _tempNonce;

function updateAccountWithSignature(
    AccountUpdate calldata currentUpdate,
    uint256 nonce,
    bytes calldata erc7964Signature
) external {
    bytes32 currentUpdateHash = hashStruct(currentUpdate); // chainId and verifyingContract are implicit in the struct hash

    _tempNonce = nonce;

    require(
        CrossChainSignatureChecker.isValidCrossChainSignatureNow(
            address(this),
            currentUpdateHash,
            erc7964Signature,
            _computeAccountUpdateStructHash
        ),
        "Invalid signature"
    );

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

function _computeAccountUpdateStructHash(
    bytes32 updatesHash
) internal view returns (bytes32) {
    return keccak256(abi.encode(
        MULTICHAIN_ACCOUNT_UPDATE_TYPEHASH,
        updatesHash,
        _tempNonce
    ));
}

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.

CrossChain 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:

import { CrossChainSignatureChecker } from "./CrossChainSignatureChecker.sol";

bytes32 constant MULTICHAIN_RECOVERY_TYPEHASH = keccak256(
    "MultiChainRecovery(bytes32 recoveries,uint256 nonce)"
);

uint256 private _tempNonce;

function initiateRecoveryWithSignature(
    ChainRecovery calldata currentRecovery,
    uint256 nonce,
    address[] calldata guardians,
    bytes[] calldata guardianErc7964Signatures
) external {
    require(guardians.length == guardianErc7964Signatures.length, "Mismatched arrays");
    // Note: chainId and verifyingContract validation is implicit in the struct hash verification

    // Verify the current recovery hash matches all guardian signatures
    bytes32 currentRecoveryHash = hashStruct(currentRecovery);

    _tempNonce = nonce;

    // Verify all guardian signatures are valid and count valid guardian signatures
    uint256 validSignatures = 0;
    for (uint256 i = 0; i < guardians.length; i++) {
        if (
            CrossChainSignatureChecker.isValidCrossChainSignatureNow(
                guardians[i],
                currentRecoveryHash,
                guardianErc7964Signatures[i],
                _computeRecoveryStructHash
            ) && isGuardian(guardians[i])
        ) {
            validSignatures++;
        }
    }
    require(validSignatures >= 3, "Insufficient guardian signatures");

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

function _computeRecoveryStructHash(
    bytes32 recoveriesHash
) internal view returns (bytes32) {
    return keccak256(abi.encode(
        MULTICHAIN_RECOVERY_TYPEHASH,
        recoveriesHash,
        _tempNonce
    ));
}

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

  1. Guardian Signatures: Multiple guardians sign the same crosschain 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. CrossChain Consistency: The same recovery operation is scheduled simultaneously on Ethereum, Polygon, and Arbitrum

Security Considerations

Crosschain 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 such as including nonces and deadlines in the message.

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 state divergence or chain-specific logic. For example, a crosschain message that has uses a nonce field may find that the nonce is already used on one of the chains. Applications should handle signature validation failures gracefully and should not assume uniform behavior across chains.

Partial Execution Risk

Crosschain signatures do not guarantee atomic execution. A signature may be successfully validated on some chains but not others. This can result in partial fulfillment of the intent. Applications should implement refund mechanisms to allow users to recover from failed partial executions.

Additionally, operations may execute in different orders across chains due to varying network conditions, block times, and congestion levels. An operation intended to execute first may complete last on a congested chain, leading to unexpected state changes. For example, in a crosschain trade, a user might sell an asset on one chain before successfully acquiring its replacement on another chain, creating temporary exposure. Applications should design operations to be order-independent where possible, or implement coordination mechanisms to ensure proper sequencing.

Signature Expiration

Signatures without explicit expiration remain valid indefinitely. If an operation is not executed on some chains, it can be executed later, potentially with unexpected consequences. Applications may 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 crosschain 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 crosschain nature and show warnings about partial execution risks.

Copyright

Copyright and related rights waived via CC0.