LayerZeroFault
ai agents-api

Fix: Viem & Wagmi EIP-712 Nested Struct Hash Mismatch (Invalid Signature Reverts)

VV

Written by

Fact-Checked on September 11, 2026

Verified Expert

Fix: Viem & Wagmi EIP-712 Nested Struct Hash Mismatch (Invalid Signature Reverts)

Developers building decentralized orderbooks, intent engines (such as CoW Swap, UniswapX, or Seaport), and off-chain authorization modules using Viem and Wagmi frequently hit a bewildering runtime bug:

Users successfully sign a typed message via signTypedData() in MetaMask, Rabby, or Coinbase Wallet. The resulting 65-byte signature is submitted on-chain or to an autonomous AI agent executor. However, contract execution abruptly reverts with:

ContractFunctionExecutionError: The contract function "executeOrder" reverted with:
"SignatureValidator: Invalid EIP-712 signature" (or ERC-1271 returned 0x00000000)

The wallet address is correct, the signer private key matches the sender, and the domain separator fields (name, version, chainId, verifyingContract) align perfectly. Yet the on-chain digest evaluated by ecrecover completely differs from the off-chain hash generated by Viem.

If you are dealing with dApp connection lockups or Zustand store freezes, consult our guide on Wagmi reconnect() Stuck at ‘reconnecting’ on isAuthorized Rejection.


The Cryptographic Mechanics of EIP-712 Digest Construction

Under EIP-712, the final 32-byte signing digest is constructed as:

$$\text{digest} = \text{keccak256}\left(\texttt{“\x19\x01”} ,|, \text{domainSeparator} ,|, \text{hashStruct}(\text{primaryType}, \text{data})\right)$$

The core discrepancy virtually always originates inside $\text{hashStruct}$, which is defined as:

$$\text{hashStruct}(s) = \text{keccak256}\left(\text{typeHash} ,|, \text{encodeData}(s)\right)$$

Placeholder: EIP-712 Recursive TypeHash Alphabetization and Struct Hashing Engine

Pitfall 1: Type String Alphabetical Ordering Rules

EIP-712 mandates that the typeHash begins with the primary type definition, followed by any referenced sub-types sorted strictly in alphabetical order by their type name:

Consider a contract with these structs:

struct Route {
    address router;
    Hop[] hops;
    FeeConfig fees;
}

struct Hop {
    address pool;
    uint24 fee;
}

struct FeeConfig {
    address recipient;
    uint256 bps;
}

The canonical type string for Route MUST place FeeConfig before Hop because "F" precedes "H" alphabetically:

$$\texttt{“Route(address router,Hop[] hops,FeeConfig fees)FeeConfig(address recipient,uint256 bps)Hop(address pool,uint24 fee)”}$$

If a Solidity developer manually defines their constant ROUTE_TYPEHASH in the order fields appear in the struct:

// BUGGY ON-CHAIN DEFINITION: Hop placed before FeeConfig!
bytes32 constant ROUTE_TYPEHASH = keccak256(
    "Route(address router,Hop[] hops,FeeConfig fees)Hop(address pool,uint24 fee)FeeConfig(address recipient,uint256 bps)"
);

Viem’s automated hashTypedData internal parser strictly obeys the official EIP-712 specification, generating the alphabetized hash (FeeConfig first). As a result, the off-chain and on-chain typeHash constants diverge completely:

Solidity on-chain: 0xa4b19c8f... (Unhashed order)
Viem client-side:  0x3e88d120... (Canonical EIP-712 alphabetized order)
Result: keccak256 digest mismatch → ecrecover yields wrong signer address!

Pitfall 2: Dynamic Arrays of Structs (Item[])

When a struct contains a dynamic array of another struct (such as Order(address maker,Item[] items)):

Placeholder: EIP-712 Array Struct Hash Concatenation Tree

Under EIP-712 Section Definition of encodeData:

The array values are encoded as the keccak256 of the concatenated encodeData of their contents.

The Common Solidity Encoding Mistake

Developers frequently attempt to hash the array using standard ABI encoding:

// INCORRECT SOLIDITY ENCODING:
bytes32 structHash = keccak256(
    abi.encode(
        ORDER_TYPEHASH,
        order.maker,
        keccak256(abi.encode(order.items)) // ← FATAL ERROR!
    )
);

abi.encode(order.items) encodes EVM memory offsets and head-tail dynamic array lengths. In contrast, EIP-712 mandates an array of 32-byte hashes:

$$\text{encodedArray} = \text{keccak256}\left(\text{hashStruct}(\text{item}_0) ,|, \text{hashStruct}(\text{item}1) ,|, \dots ,|, \text{hashStruct}(\text{item}{n-1})\right)$$

Correct On-Chain Solidity Implementation

function hashOrder(Order memory order) internal pure returns (bytes32) {
    bytes32[] memory itemHashes = new bytes32[](order.items.length);
    for (uint256 i = 0; i < order.items.length; i++) {
        itemHashes[i] = keccak256(
            abi.encode(
                ITEM_TYPEHASH,
                order.items[i].token,
                order.items[i].amount
            )
        );
    }

    return keccak256(
        abi.encode(
            ORDER_TYPEHASH,
            order.maker,
            keccak256(abi.encodePacked(itemHashes)) // ← Correctly hashes concatenated 32-byte words
        )
    );
}

Pitfall 3: Viem Client-Side Type Definitions

In Viem, if you define your types object using as const, ensure that you do NOT define the EIP712Domain key inside types. Viem automatically constructs the EIP712Domain type based on the keys present in the domain parameter.

import { hashTypedData } from 'viem';

// CORRECT VIEM CONFIGURATION
const types = {
  // Do NOT include EIP712Domain here!
  Hop: [
    { name: 'pool', type: 'address' },
    { name: 'fee', type: 'uint24' },
  ],
  FeeConfig: [
    { name: 'recipient', type: 'address' },
    { name: 'bps', type: 'uint256' },
  ],
  Route: [
    { name: 'router', type: 'address' },
    { name: 'hops', type: 'Hop[]' },
    { name: 'fees', type: 'FeeConfig' },
  ],
} as const;

export function computeOffchainDigest(route: any, chainId: number, verifyingContract: `0x${string}`) {
  return hashTypedData({
    domain: {
      name: 'LayerZeroFault Intent Engine',
      version: '1',
      chainId: BigInt(chainId),
      verifyingContract,
    },
    types,
    primaryType: 'Route',
    message: route,
  });
}

Defensive Verification Checklist

  • Print Both Digests: In test environments or Hardhat/Foundry tests, emit an event or console log digest on-chain and compare it character-by-character with Viem’s hashTypedData return value.
  • Verify chainId Type: Ensure chainId in the domain is passed as an unsigned integer/bigint, not a hex string or decimal string.
  • Check ERC-5267 eip712Domain(): Call eip712Domain() on the verifying contract to confirm that the deployed contract is using the expected name, version, and salt parameters.

Fact-Checked by Victor Vance, Senior Smart Contract Security Analyst & Node Operator.

Partner Spotlight: Gate.io

Trade Securely on Gate.io

Don't risk your assets on centralized silos or unverified endpoints. Trade securely on Gate.io with deep liquidity and institutional-grade security protocols.

Claim $100 Sign-up Bonus

Official Partner Referral Link

Related Inquiries

Why does my smart contract reject a valid EIP-712 signature generated by Viem signTypedData?

Smart contracts verify EIP-712 signatures by recomputing the struct hash: keccak256(abi.encode(TYPE_HASH, values...)). If the frontend and on-chain Solidity contracts differ in the recursive alphabetical ordering of nested referenced types in the typeHash string, or if dynamic arrays of structs are encoded without keccak256 concatenation, the generated 32-byte digest diverges, causing ecrecover or ERC-1271 isValidSignature to fail.

What is the EIP-712 rule for ordering referenced subtypes in typeHash strings?

According to EIP-712, if a primary type references custom sub-structs (e.g., Order references Fee and Permit), the referenced type declarations must be concatenated in strict alphabetical order by type name, regardless of their appearance order in the primary struct definition.

How should dynamic arrays of structs be encoded in EIP-712?

An array of structs (e.g. Item[] items) cannot be directly abi.encoded. Instead, each individual struct in the array must first be hashed into a 32-byte bytes32 hash using hashStruct(), and the resulting array of 32-byte hashes must be concatenated and hashed with keccak256(abi.encodePacked(hashes)).