EIP-712 typed structured data signing was designed to make off-chain signatures human-readable. Before it, signing a permit or an order meant presenting your Ledger with a raw 32-byte hash and clicking confirm on something you couldn't interpret. EIP-712 added structure: named fields, typed values, a domain separator that scopes the signature to a specific contract and chain. On paper, it's a significant improvement. In practice, multi-chain usage has exposed a set of problems that the spec did not fully anticipate.
The core issue is that the EIP-712 domain separator contains a chainId field precisely to prevent cross-chain replay of off-chain signatures — but most multi-chain signing flows never verify that this field matches the chain the user is actually operating on. Here is what Ledger firmware actually sees when you sign a structured message, and where the chain ID check is supposed to happen.
The structure of an EIP-712 message
An EIP-712 signing request has two components: the domain separator and the message payload. The domain separator is defined by the following fields (all optional per the spec, but chainId and verifyingContract are standard for DeFi protocols):
{
"domain": {
"name": "Permit2",
"version": "1",
"chainId": 8453,
"verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3"
},
"types": {
"PermitSingle": [
{ "name": "details", "type": "PermitDetails" },
{ "name": "spender", "type": "address" },
{ "name": "sigDeadline","type": "uint256" }
]
},
"primaryType": "PermitSingle",
"message": { ... }
}
The domain hash is computed as keccak256(abi.encode(domainTypeHash, nameHash, versionHash, chainId, verifyingContract)). The final signed digest is keccak256("\x19\x01" || domainHash || messageHash). If chainId is 8453 in the domain, the signature is mathematically bound to Base — it will not validate against a Permit2 deployment on any other chain, even the same contract address deployed elsewhere.
This is the protection. The problem is that it only works if the chainId in the domain separator actually reflects the chain you're transacting on.
What Ledger firmware shows — and what it doesn't
Ledger's approach to EIP-712 has evolved across firmware versions. On Nano S hardware, the display is physically limited: a 128×32 pixel screen can show roughly two short lines at a time. Complex EIP-712 payloads with nested structs — like Permit2's PermitSingle wrapping a PermitDetails — require scrolling through dozens of screens. Most traders stop reading after the first few fields.
When Ledger added native EIP-712 support (initially as an opt-in feature, later as default on Nano X and Stax), the firmware received the typed data structure directly via the Ethereum app and could display field names and values rather than raw hash bytes. But the display logic depends on the app having a registered metadata definition for that domain. If the domain isn't registered in Ledger's CAL (Contract Address List), the firmware may fall back to showing the raw hash — which puts you back at the blind-signing problem.
Crucially, Ledger firmware displays the chainId field from the domain separator as one of the scrollable fields. It does not cross-reference that value against the current wallet network context. If your Ethereum app is configured for Base (chain ID 8453) but the signing request arrives with a domain separator specifying Ethereum mainnet (chainId: 1), the firmware shows chainId: 1 as a field value — it does not warn you that this doesn't match your current chain context. The device will sign it successfully.
Where the mismatch actually happens
Consider a protocol that deployed on Ethereum mainnet first and later expanded to Base and Arbitrum. The front end was updated to route transactions to the correct chain, but the EIP-712 domain configuration in the signing request was not updated for all message types. A user operating on Base triggers an off-chain order signature — the front end constructs the EIP-712 payload with the original mainnet domain separator, chainId: 1. The Ledger signs it without protest. The signature is technically valid, but bound to the wrong chain. If the relayer or contract is chain-agnostic or accepts cross-chain signatures, the outcome is unpredictable.
A more targeted scenario: an attacker intercepts the EIP-712 signing request at the RPC or middleware layer and modifies the chainId in the domain separator to a chain where they control a contract at the same address as verifyingContract. The user's hardware wallet signs the modified payload — it shows the right-looking fields, just with a different chain ID that most users skip past without reading.
Why dApps omit chainId — and why that's a problem
The EIP-712 spec makes chainId optional in the domain separator. Protocols that were built before multi-chain deployment was common often omit it entirely. When chainId is absent from the domain, the signature is not chain-scoped. It can technically be replayed on any chain where the same contract exists at the same address.
We're not saying protocols that omit chainId are being negligent — there are legitimate cases where a signature is intended to be chain-agnostic, such as off-chain order books that operate across networks. But in the context of permissioned actions like token approvals, omitting chainId from an EIP-712 permit is a security gap that grows as the same contracts deploy across more chains.
When Ledger displays an EIP-712 payload without a chainId field, there is no cross-chain scoping protection — and nothing on the device display tells you that. The absence of the field is not surfaced as a warning.
What a signer should check before approving
For any EIP-712 structured data request, the verification steps before confirming on the hardware wallet are:
- Scroll to the
chainIdfield in the domain separator. Verify it matches the chain you're operating on. Do not skip this field. - Check that
verifyingContractmatches the known deployment address of the protocol for that specific chain — not the mainnet address, not a sister chain address. Each chain has its own deployment. - If no
chainIdfield appears in the domain separator, treat the signature as potentially cross-chain replayable. Assess whether that's acceptable for the specific action being signed. - For Nano S users: if the payload is falling back to hash display (raw
0x...bytes instead of named fields), you are effectively blind-signing. Do not confirm until you can verify the payload through an alternative channel.
The multi-chain domain separator problem as deployments scale
As protocols expand to more L2s, the domain separator problem compounds. A protocol with deployments on Ethereum, Base, Arbitrum, Optimism, and Polygon has five distinct verifyingContract addresses and five distinct chainId values that should appear in EIP-712 domains. Each front end integration, each wallet library, each relayer middleware is a point where the domain separator could be constructed with the wrong chain ID — either from a configuration error or from a stale cached value that was correct for a prior chain context.
The hardware wallet is the last verification layer before a signature leaves the device. On Ledger hardware, the chain ID field in the EIP-712 domain separator is the only on-device indicator of which chain the signature is scoped to. If you don't read it, no other layer will catch the mismatch — not the dApp, not the relayer, not the contract, which simply validates the signature against its own domain and chain ID rather than the one you intended.
The protection is there in the protocol. Whether it works depends on whether you actually look at the field before you press confirm.