EIP-712 Typed Data
EIP-712 is the standard for typed structured-data signing on Ethereum. Instead of opaque hex strings, wallets show users human-readable JSON before they sign — what they’re approving is clear. The foundation of meta-transactions, permits, NFT listings, and gasless approvals.
Typed data, signing, verifying, EIP-2612
EXAMPLE
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
// 1) Sample contract — accept signed orders without spending user's gas
contract OrderBook is EIP712 {
using ECDSA for bytes32;
bytes32 private constant ORDER_TYPEHASH = keccak256(
"Order(address maker,address tokenIn,address tokenOut,uint256 amountIn,uint256 amountOut,uint256 nonce,uint256 deadline)"
);
mapping(address => uint256) public nonces;
constructor() EIP712("OrderBook", "1") { }
function fillOrder(
address maker,
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 amountOut,
uint256 nonce,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "expired");
require(nonces[maker] == nonce, "bad nonce");
bytes32 structHash = keccak256(abi.encode(
ORDER_TYPEHASH,
maker, tokenIn, tokenOut, amountIn, amountOut, nonce, deadline
));
bytes32 hash = _hashTypedDataV4(structHash); // domain-separated hash
address recovered = hash.recover(signature);
require(recovered == maker, "bad signature");
nonces[maker]++;
// ... execute swap ...
}
}
// _hashTypedDataV4 produces:
// keccak256(abi.encodePacked('\x19\x01', DOMAIN_SEPARATOR, structHash))
//
// DOMAIN_SEPARATOR includes:
// name — 'OrderBook'
// version — '1'
// chainId — current chain id
// verifyingContract — this contract's address
//
// This ties signatures to a SPECIFIC contract on a SPECIFIC chain — prevents replay.
// 2) Signing from the frontend (ethers v6)
import { ethers } from 'ethers';
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const orderBookAddress = '0x...';
const domain = {
name: 'OrderBook',
version: '1',
chainId: (await provider.getNetwork()).chainId,
verifyingContract: orderBookAddress,
};
const types = {
Order: [
{ name: 'maker', type: 'address' },
{ name: 'tokenIn', type: 'address' },
{ name: 'tokenOut', type: 'address' },
{ name: 'amountIn', type: 'uint256' },
{ name: 'amountOut', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};
const value = {
maker: await signer.getAddress(),
tokenIn: '0xUSDC...',
tokenOut: '0xWETH...',
amountIn: ethers.parseUnits('1000', 6),
amountOut: ethers.parseEther('0.4'),
nonce: 0,
deadline: Math.floor(Date.now() / 1000) + 3600,
};
const signature = await signer.signTypedData(domain, types, value);
// User's wallet shows:
// You are signing data for OrderBook:
// maker: 0x...
// tokenIn: 0xUSDC...
// tokenOut: 0xWETH...
// amountIn: 1000
// amountOut: 0.4
// nonce: 0
// deadline: 1730000000
// Anyone can submit this signature to fillOrder; the maker pays 0 gas.
// 3) EIP-2612 — gasless ERC-20 approvals (permit)
// Most ERC-20 tokens require approve() + transferFrom() — TWO transactions, two gas fees.
// EIP-2612 adds permit() — a signed approval that doesn't require an on-chain transaction.
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract MyToken is ERC20Permit {
constructor() ERC20("MyToken", "MTK") ERC20Permit("MyToken") {}
}
// Frontend
const tokenContract = new ethers.Contract(tokenAddr, ERC20PermitAbi, signer);
const nonce = await tokenContract.nonces(userAddress);
const name = await tokenContract.name();
const domain = {
name,
version: '1',
chainId,
verifyingContract: tokenAddr,
};
const types = {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};
const message = {
owner: userAddress,
spender: spenderAddress,
value: ethers.parseUnits('100', 18),
nonce,
deadline: Math.floor(Date.now() / 1000) + 3600,
};
const sig = await signer.signTypedData(domain, types, message);
const { v, r, s } = ethers.Signature.from(sig);
// On chain — the spender calls permit, then transferFrom — ALL IN ONE TX
await dexContract.swapWithPermit(
tokenAddr, userAddress,
message.value, message.deadline, v, r, s,
);
// 4) Common EIP-712 use cases
// • Gasless approvals (ERC-20 Permit)
// • Off-chain orderbooks (Uniswap UniswapX, 0x, Seaport)
// • Sign-In With Ethereum (SIWE — EIP-4361)
// • Voting / governance (off-chain Snapshot)
// • Meta-transactions / account abstraction (EIP-4337)
// • Multi-sig wallet signatures (Gnosis Safe)
// • NFT royalty splits
// 5) Domain separator best practices
// • UNIQUE per contract — chainId + verifyingContract ensures no cross-chain replay
// • Version bump invalidates all old signatures — useful for emergency invalidation
// • Salt field exists but rarely used; OpenZeppelin omits it by default
// 6) Backend: verify signature server-side without on-chain call
import { verifyTypedData } from 'ethers';
const recovered = verifyTypedData(domain, types, value, signature);
if (recovered.toLowerCase() !== expectedAddress.toLowerCase()) {
throw new Error('invalid signature');
}
// Useful for:
// • Login (verify user owns the address)
// • Allowlists (require signed pre-allocation)
// • Trust-but-verify off-chain workflows
// 7) Nonce + deadline patterns
// nonce: monotonically increasing per user; prevents replay
// deadline: unix timestamp; expires the signature; mitigates capture-replay-later
//
// Both should be checked on chain BEFORE accepting the signature.
// 8) Typed data shapes — nested structs supported
const types = {
Order: [
{ name: 'maker', type: 'address' },
{ name: 'tokens', type: 'TokenAmount[]' },
{ name: 'expiry', type: 'uint256' },
],
TokenAmount: [
{ name: 'token', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
};
const value = {
maker: '0x...',
tokens: [
{ token: '0xUSDC', amount: 1000n },
{ token: '0xWETH', amount: 1n },
],
expiry: 1730000000n,
};
// 9) Sign-In With Ethereum (SIWE / EIP-4361)
// SIWE is a different spec (EIP-4361) — uses 'personal_sign' under the hood, not EIP-712.
// But the underlying signature recovery is the same idea.
import { SiweMessage } from 'siwe';
const message = new SiweMessage({
domain: 'app.example.com',
address: await signer.getAddress(),
statement: 'Sign in to MyApp',
uri: 'https://app.example.com',
version: '1',
chainId,
nonce: 'random-string',
issuedAt: new Date().toISOString(),
});
const signature = await signer.signMessage(message.prepareMessage());
// Server: SiweMessage.verify(signature)
// 10) Wallet UX — what users see
// MetaMask, WalletConnect, Coinbase Wallet all render EIP-712 structured data nicely.
// vs personal_sign: 'sign 0x4f72646572207b226d616b6572223a...' (opaque hex — phishing risk)
// vs EIP-712: full struct in a readable list — user knows WHAT they're approving
//
// Always prefer EIP-712 for any user-facing signing.
// 11) Common bugs
// • Mismatched type definitions between client + contract → signature 'valid' but recovered != expected
// • Forgetting chainId in domain → cross-chain replay possible
// • Mutable nonce not enforced → signature replayable
// • Deadline in seconds vs milliseconds → 'always expired' bug
// • Token field type uint vs uint256 in TYPEHASH → wrong hash
// • Caching DOMAIN_SEPARATOR but contract gets redeployed at new address → signature won't verify
// • Signing as a contract account (multisig) → must use ERC-1271 isValidSignature pattern
// • Showing signature value to user — never useful; they care about the structured data
// • Backend verifying chainId of '1' (Mainnet) while signature was on Sepolia — verify chainId match
Why it matters
EIP-712 lets users sign STRUCTURED data: wallets show the actual fields (recipient, amount, expiry) instead of opaque hex, dramatically reducing phishing. Pair with a nonce + deadline to prevent replay, lean on OpenZeppelin’s EIP712 for the domain separator, and reach for it for gasless approvals (EIP-2612), off-chain orderbooks, governance, and login flows.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// EIP-712 lets users sign structured, human-readable data instead of opaque hashes.
bytes32 constant DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"),
keccak256("MyApp"), block.chainid, address(this)
));
Try it Yourself »
Discussion
Loading…