standards/erc-signature-standards

ERC Signature Standards (EIP-191, EIP-712, EIP-1271)

ethereumstandard-guide🏛️ Officialconfidence highhealth -2%
v1.0.0·Updated 3/26/2026

Overview

Ethereum signatures are fundamental to authentication, meta-transactions, gasless approvals, and smart contract wallet verification. This guide covers every signature type and how to handle them correctly.

Signature Types Comparison

StandardFormatUse casePrefix
Raw (legacy)eth_sign⚠️ Dangerous, avoidNone
EIP-191 personal_signpersonal_signSimple message auth\x19Ethereum Signed Message:\n{len}
EIP-712 typed dataeth_signTypedData_v4Structured data (permits, orders)\x19\x01
EIP-1271Contract-basedSmart wallet signaturesN/A
EIP-2098Compact 64 bytesReduced calldata sizeN/A

EIP-191 — Signed Data Standard

EIP-191 defines a format for signable data to prevent replays and cross-context attacks. The personal_sign method is the most common version.

Version Bytes

0x00 → Validator data (address + data)
0x01 → Structured data (EIP-712)
0x45"E" → personal_sign (\x19Ethereum Signed Message:\n)

personal_sign (Version 0x45)

import { ethers } from "ethers";

// Sign a plain message
const message = "Hello, Ethereum!";
const signature = await signer.signMessage(message);
// Internally: sign(keccak256("\x19Ethereum Signed Message:\n" + len + message))

// Verify on-chain
address recovered = ECDSA.recover(
    keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", Strings.toString(bytes(message).length), message)),
    signature
);

// Or using ethers
const recovered = ethers.verifyMessage(message, signature);

Solidity Verification

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

contract Verifier {
    using ECDSA for bytes32;

    function verify(
        string calldata message,
        bytes calldata signature,
        address expectedSigner
    ) external pure returns (bool) {
        bytes32 messageHash = keccak256(bytes(message));
        bytes32 ethSignedHash = MessageHashUtils.toEthSignedMessageHash(messageHash);
        address recovered = ethSignedHash.recover(signature);
        return recovered == expectedSigner;
    }
}

EIP-712 — Typed Structured Data Hashing and Signing

EIP-712 is the gold standard for signing structured data. Users see readable data in wallet UIs instead of raw hex.

Domain Separator

Every EIP-712 signature is scoped to a domain to prevent cross-app replays:

struct EIP712Domain {
    string name;              // Contract/app name
    string version;           // Version string
    uint256 chainId;          // EIP-155 chain ID
    address verifyingContract; // Contract address
    // bytes32 salt;          // Optional additional entropy
}

bytes32 constant EIP712DOMAIN_TYPEHASH = keccak256(
    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);

bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
    EIP712DOMAIN_TYPEHASH,
    keccak256("MyDApp"),
    keccak256("1"),
    block.chainid,
    address(this)
));

Struct Type Hash

// Define your struct
struct Order {
    address maker;
    address taker;
    uint256 makerAmount;
    uint256 takerAmount;
    uint256 nonce;
    uint256 deadline;
}

// Type hash = keccak256 of the type string
bytes32 constant ORDER_TYPEHASH = keccak256(
    "Order(address maker,address taker,uint256 makerAmount,uint256 takerAmount,uint256 nonce,uint256 deadline)"
);

// Hash a struct instance
function hashOrder(Order calldata order) internal pure returns (bytes32) {
    return keccak256(abi.encode(
        ORDER_TYPEHASH,
        order.maker,
        order.taker,
        order.makerAmount,
        order.takerAmount,
        order.nonce,
        order.deadline
    ));
}

// Final hash to sign: \x19\x01 + domainSeparator + structHash
function getOrderDigest(Order calldata order) public view returns (bytes32) {
    return keccak256(abi.encodePacked(
        "\x19\x01",
        DOMAIN_SEPARATOR,
        hashOrder(order)
    ));
}

Signing with ethers.js v6

import { ethers } from "ethers";

const domain = {
  name: "MyDApp",
  version: "1",
  chainId: 1,
  verifyingContract: "0x...",
};

const types = {
  Order: [
    { name: "maker", type: "address" },
    { name: "taker", type: "address" },
    { name: "makerAmount", type: "uint256" },
    { name: "takerAmount", type: "uint256" },
    { name: "nonce", type: "uint256" },
    { name: "deadline", type: "uint256" },
  ],
};

const order = {
  maker: await signer.getAddress(),
  taker: "0x...",
  makerAmount: ethers.parseEther("1.0"),
  takerAmount: ethers.parseUnits("2000", 6), // USDC
  nonce: 0n,
  deadline: BigInt(Math.floor(Date.now() / 1000) + 3600),
};

// Sign typed data
const signature = await signer.signTypedData(domain, types, order);

// Verify
const recovered = ethers.verifyTypedData(domain, types, order, signature);

Nested Struct Types

// Type string for nested structs: parent type first, then dependencies alphabetically
"Order(address maker,Token sellToken,Token buyToken)Token(address addr,uint256 amount)"

bytes32 constant TOKEN_TYPEHASH = keccak256("Token(address addr,uint256 amount)");
bytes32 constant ORDER_TYPEHASH = keccak256(
    "Order(address maker,Token sellToken,Token buyToken)Token(address addr,uint256 amount)"
);

Encoding Rules for Struct Hashing

TypeEncoding
address, uint256, bool, etc.abi.encode(value)
stringkeccak256(bytes(value))
byteskeccak256(value)
bytes32Direct (32 bytes)
Structkeccak256(abi.encode(TYPEHASH, ...fields))
Arraykeccak256(abi.encodePacked(encoded_elements))

Common AI mistake: Using abi.encodePacked instead of abi.encode for struct hashing. Always use abi.encode for EIP-712 struct encoding (fixed-size fields, no packing).

EIP-1271 — Contract Signature Verification

EIP-1271 allows smart contracts (like multisigs and smart wallets) to validate signatures.

interface IERC1271 {
    // Must return 0x1626ba7e (magic value) if signature is valid
    function isValidSignature(
        bytes32 hash,
        bytes memory signature
    ) external view returns (bytes4 magicValue);
}

bytes4 constant MAGIC_VALUE = 0x1626ba7e; // bytes4(keccak256("isValidSignature(bytes32,bytes)"))

Universal Signature Verification

When you don't know if the signer is an EOA or a smart contract:

import "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";

// Works for both EOA and smart contract signers
bool isValid = SignatureChecker.isValidSignatureNow(
    signerAddress,
    messageHash,
    signature
);
// ethers.js: verify EIP-1271 signature
async function isValidSignature(
  signerAddress: string,
  hash: string,
  signature: string,
  provider: ethers.Provider
): Promise<boolean> {
  const code = await provider.getCode(signerAddress);
  if (code === "0x") {
    // EOA: use ECDSA recovery
    return ethers.recoverAddress(hash, signature).toLowerCase() === signerAddress.toLowerCase();
  } else {
    // Contract: call isValidSignature
    const contract = new ethers.Contract(
      signerAddress,
      ["function isValidSignature(bytes32,bytes) view returns (bytes4)"],
      provider
    );
    try {
      const result = await contract.isValidSignature(hash, signature);
      return result === "0x1626ba7e";
    } catch {
      return false;
    }
  }
}

EIP-2098 — Compact Signature Representation

Reduces ECDSA signatures from 65 bytes (r, s, v) to 64 bytes by encoding v into the top bit of s.

// Standard signature: r (32) + s (32) + v (1) = 65 bytes
// Compact signature: r (32) + vs (32) = 64 bytes
// vs = s | (v-27) << 255  (top bit of vs encodes parity)

function toCompact(uint8 v, bytes32 r, bytes32 s) pure returns (bytes32 r_, bytes32 vs) {
    r_ = r;
    vs = bytes32(uint256(s) | uint256(v - 27) << 255);
}

function fromCompact(bytes32 r, bytes32 vs) pure returns (uint8 v, bytes32 r_, bytes32 s) {
    v = uint8(uint256(vs) >> 255) + 27;
    r_ = r;
    s = vs & 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
}

When to use EIP-2098: High-throughput contracts where many signatures are verified (e.g., orderbook DEXes), calldata cost reduction on L2.

Security Checklist

  • Always include chainId in EIP-712 domain to prevent cross-chain replays
  • Include nonce in signed messages to prevent replay attacks
  • Include deadline to make signatures expire
  • Use verifyingContract to bind signature to specific contract
  • Check v is 27 or 28 (not 0 or 1) for legacy compatibility
  • Handle both EOA and contract signatures (EIP-1271) for smart wallet support
  • Never use raw eth_sign — it signs arbitrary hashes without a prefix
  • Use ecrecover return value check: it returns address(0) on failure, not a revert