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)$$
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)):
Under EIP-712 Section Definition of encodeData:
The array values are encoded as the
keccak256of the concatenatedencodeDataof 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
digeston-chain and compare it character-by-character with Viem’shashTypedDatareturn value. - Verify
chainIdType: EnsurechainIdin the domain is passed as an unsigned integer/bigint, not a hex string or decimal string. - Check ERC-5267
eip712Domain(): Calleip712Domain()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.