ERC Signature Standards (EIP-191, EIP-712, EIP-1271)
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
| Standard | Format | Use case | Prefix |
|---|---|---|---|
| Raw (legacy) | eth_sign | ⚠️ Dangerous, avoid | None |
| EIP-191 personal_sign | personal_sign | Simple message auth | \x19Ethereum Signed Message:\n{len} |
| EIP-712 typed data | eth_signTypedData_v4 | Structured data (permits, orders) | \x19\x01 |
| EIP-1271 | Contract-based | Smart wallet signatures | N/A |
| EIP-2098 | Compact 64 bytes | Reduced calldata size | N/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
| Type | Encoding |
|---|---|
address, uint256, bool, etc. | abi.encode(value) |
string | keccak256(bytes(value)) |
bytes | keccak256(value) |
bytes32 | Direct (32 bytes) |
| Struct | keccak256(abi.encode(TYPEHASH, ...fields)) |
| Array | keccak256(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
chainIdin EIP-712 domain to prevent cross-chain replays - Include
noncein signed messages to prevent replay attacks - Include
deadlineto make signatures expire - Use
verifyingContractto bind signature to specific contract - Check
vis 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
ecrecoverreturn value check: it returns address(0) on failure, not a revert