iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…