Short answer. EIP-712 signs a hash of structured data: keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). The domain (name, version, chain ID, verifying contract) says which contract on which chain the signature is for; the message is a typed struct. Before signing, read four things: the verifyingContract and chainId (is this the contract you think, on the chain you are on?), the primaryType (an approval, an order, a login?), the spender or recipient, and the amounts and deadlines. An ERC-2612 Permit or a Uniswap Permit2 message with the maximum amount and a far deadline is an unlimited approval that anyone holding the signature can activate later. If your hardware wallet shows only a domain hash and a message hash, recompute both on another device and compare.
What the bytes are
EIP-712 (Final) defines three pieces:
encodeType: a struct type as a string,Mail(Person from,Person to,string contents), followed by every struct type it references, sorted by name:…Person(string name,address wallet). Its keccak256 is the type hash.hashStruct(s) = keccak256(typeHash ‖ encodeData(s)), where each member is encoded in 32 bytes: integers and addresses as ABI words,stringandbytesas the keccak256 of their contents, nested structs as their ownhashStruct, arrays as the keccak256 of their concatenated encodings.domainSeparator = hashStruct(EIP712Domain), over whichever ofname,version,chainId,verifyingContractandsaltthe application uses, in that order.
The signed digest is keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). The 0x19 prefix keeps it from ever being a valid transaction (RLP never starts with 0x19), and the 0x01 version byte separates it from personal_sign's 0x19 "Ethereum Signed Message" format of EIP-191. The signature is an ordinary secp256k1 ECDSA signature, 65 bytes with the recovery byte, and the signer's address is recovered from it.
The specification's own example, recomputed with viem from public data only (the typed data and the published v, r, s):
// The example from the EIP-712 specification (assets/eip-712/Example.js), checked with viem.
// Only public data is used: the typed data and the published signature (v, r, s).
import { hashDomain, hashStruct, hashTypedData, keccak256, recoverTypedDataAddress, serializeSignature, toHex } from "viem";
const types = {
Person: [
{ name: "name", type: "string" },
{ name: "wallet", type: "address" },
],
Mail: [
{ name: "from", type: "Person" },
{ name: "to", type: "Person" },
{ name: "contents", type: "string" },
],
};
const domain = { name: "Ether Mail", version: "1", chainId: 1, verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" };
const message = {
from: { name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" },
to: { name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" },
contents: "Hello, Bob!",
};
const encodeType = "Mail(Person from,Person to,string contents)Person(string name,address wallet)";
console.log(`typeHash(Mail) ${keccak256(toHex(encodeType))}`);
console.log(`hashStruct(Mail) ${hashStruct({ data: message, primaryType: "Mail", types })}`);
console.log(`domainSeparator ${hashDomain({ domain, types: { EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
] } })}`);
console.log(`digest ${hashTypedData({ domain, types, primaryType: "Mail", message })}`);
const signature = serializeSignature({
r: "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d",
s: "0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562",
v: 28n,
});
console.log(`signer ${await recoverTypedDataAddress({ domain, types, primaryType: "Mail", message, signature })}`);
typeHash(Mail) 0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2
hashStruct(Mail) 0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
domainSeparator 0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
digest 0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer 0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826
Every value matches the expectations in the EIP's Example.js, and the signature recovers the example's signer.
What the domain protects, and what it does not
The domain makes a signature valid for one contract on one chain. The same message signed for chain 1 does not verify for chain 8453, and a token's version change invalidates every outstanding signature. The specification says a wallet "should refuse signing" when chainId does not match the active chain; MetaMask does, with Provided chainId "1" must match the active chainId "137".
What the domain cannot do is tell you whether the contract is the real one. A phishing site can ask for a signature with name: "USD Coin" and its own contract address; only the verifyingContract, compared with an address you trust, settles that. For tokens with ERC-2612, verifyingContract is the token contract itself; for Permit2, it is the Permit2 contract; for marketplace orders, the exchange contract.
The formats that move tokens
ERC-2612 permit (ERC-2612, Final) is an approve by signature: Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline). Anyone who holds the signature can submit it before deadline; the token then sets allowance[owner][spender] = value and increments the owner's nonce. The standard itself notes that deadline "can be set to uint(-1) to create Permits that effectively never expire", and value of 2^256 − 1 is the conventional "unlimited". Nothing appears on chain until the signature is used, so a wallet's approval list does not show it.
Uniswap Permit2 is a single contract, at 0x000000000022D473030F116dDEE9F6B43aC78BA3 on every chain where it is deployed, that holds token approvals for many applications. A user approves Permit2 once on chain; after that, every application gets access by signature:
PermitSingle/PermitBatch(AllowanceTransfer) grant an allowance:PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce), aspender, and asigDeadlinefor the signature itself.amountof2^160 − 1is unlimited and is not decreased by transfers;expirationof2^48 − 1never expires, and0means "only in this block".PermitTransferFrom/PermitBatchTransferFrom(SignatureTransfer) authorize one transfer of up toamountbeforedeadline, with a one-time nonce. These are safer: nothing lingers.- The witness variants add an application struct, such as a UniswapX order, to the same signature.
Permit2's domain has name: "Permit2", chainId and verifyingContract, and no version. The type strings above are taken from Permit2's PermitHash.sol.
Reading a request before signing it
This module turns an eth_signTypedData_v4 request into a statement: who may spend what, until when, and what looks wrong. It recognizes ERC-2612 and the two most common Permit2 messages and says so plainly for anything else.
// Explains an eth_signTypedData_v4 request before anyone signs it: domain, digest, and for the
// common approval formats, who may spend what until when.
import { getTypesForEIP712Domain, hashDomain, hashStruct, hashTypedData, maxUint160, maxUint256, maxUint48 } from "viem";
const PERMIT2 = "0x000000000022d473030f116ddee9f6b43ac78ba3"; // Uniswap Permit2, same address on every chain it is deployed to
const amount = (v, max) => (BigInt(v) === max ? "UNLIMITED" : BigInt(v).toString());
const when = (t, now) => {
const s = BigInt(t);
if (s === maxUint256 || s === maxUint48) return "never";
const seconds = Number(s - BigInt(now));
const span = seconds < 2 * 86400 ? `${Math.round(seconds / 60)} minutes` : `${Math.round(seconds / 86400)} days`;
return `${new Date(Number(s) * 1000).toISOString().slice(0, 16)}Z, in ${span}`;
};
export function describe(request, { connectedChainId, now = Math.floor(Date.now() / 1000), trustedSpenders = [] }) {
const { domain, primaryType, message } = request;
const types = Object.fromEntries(Object.entries(request.types).filter(([name]) => name !== "EIP712Domain"));
const lines = [
`domain ${domain.name ?? "-"} v${domain.version ?? "-"}, chain ${domain.chainId}, contract ${domain.verifyingContract}`,
`type ${primaryType}`,
// The two hashes a hardware wallet shows when it cannot decode the message, and the signed digest.
`domain hash ${hashDomain({ domain, types: { EIP712Domain: getTypesForEIP712Domain({ domain }) } })}`,
`message hash ${hashStruct({ data: message, primaryType, types })}`,
`digest ${hashTypedData({ domain, types, primaryType, message })}`,
];
const flags = [];
if (domain.chainId !== undefined && Number(domain.chainId) !== connectedChainId) {
flags.push(`domain is for chain ${domain.chainId}, wallet is on ${connectedChainId}`);
}
const spenderCheck = (spender) => {
if (!trustedSpenders.some((s) => s.toLowerCase() === spender.toLowerCase())) flags.push(`spender ${spender} is not one you expect`);
};
if (primaryType === "Permit" && "spender" in message && "value" in message) {
// ERC-2612: verifyingContract is the token itself.
lines.push(`action token ${domain.verifyingContract} lets ${message.spender} spend ${amount(message.value, maxUint256)}, signature valid until ${when(message.deadline, now)}`);
if (BigInt(message.value) === maxUint256) flags.push("unlimited allowance");
spenderCheck(message.spender);
} else if (primaryType === "PermitSingle" || primaryType === "PermitTransferFrom") {
if (domain.name !== "Permit2" || domain.verifyingContract.toLowerCase() !== PERMIT2) {
flags.push(`claims to be a Permit2 message but the domain is ${domain.name} at ${domain.verifyingContract}`);
}
if (primaryType === "PermitSingle") {
const d = message.details;
lines.push(`action ${message.spender} may pull ${amount(d.amount, maxUint160)} of ${d.token} until ${when(d.expiration, now)}; signature valid until ${when(message.sigDeadline, now)}`);
if (BigInt(d.amount) === maxUint160) flags.push("unlimited allowance");
if (BigInt(d.expiration) === maxUint48) flags.push("allowance never expires");
} else {
lines.push(`action ${message.spender} may transfer ${amount(message.permitted.amount, maxUint256)} of ${message.permitted.token} once; valid until ${when(message.deadline, now)}`);
}
spenderCheck(message.spender);
} else {
lines.push("action not a known approval format: read every field before signing");
}
return [...lines, ...flags.map((f) => `RISK ${f}`)].join("\n");
}
Three requests a site might send, built with a key generated for the run: an unlimited three-year USDC permit to an address the user has never seen, an unlimited non-expiring Permit2 allowance to the same address, and the bounded Permit2 allowance a swap interface actually needs. Then the first one is signed, and the signature is tried under altered domains:
// Lab driver: three signature requests a site might send, explained; then sign one and check domain separation.
import { hashDomain, maxUint160, maxUint256, maxUint48, recoverTypedDataAddress } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { describe } from "./what-am-i-signing.mjs";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const ROUTER = "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af"; // Uniswap Universal Router 2.0 on Ethereum
const UNKNOWN = "0x5e1f0000000000000000000000000000000c0ffe"; // an address the user has never dealt with
const now = Math.floor(Date.now() / 1000);
const owner = privateKeyToAccount(generatePrivateKey()); // fresh key for this run
// The token's EIP-712 domain, checked against the value read from the USDC contract on chain.
const usdcDomain = { name: "USD Coin", version: "2", chainId: 1, verifyingContract: USDC };
console.log(`USDC domain separator matches the contract's DOMAIN_SEPARATOR(): ${
hashDomain({ domain: usdcDomain, types: { EIP712Domain: [
{ name: "name", type: "string" }, { name: "version", type: "string" },
{ name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" },
] } }) === "0x06c37168a7db5138defc7866392bb87a741f9b3d104deb5094588ce041cae335"
}\n`);
const requests = {
"1. ERC-2612 permit": {
domain: usdcDomain,
types: { Permit: [
{ name: "owner", type: "address" }, { name: "spender", type: "address" }, { name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" },
] },
primaryType: "Permit",
message: { owner: owner.address, spender: UNKNOWN, value: maxUint256, nonce: 0n, deadline: BigInt(now + 3 * 365 * 86400) },
},
"2. Permit2 allowance": {
domain: { name: "Permit2", chainId: 1, verifyingContract: "0x000000000022D473030F116dDEE9F6B43aC78BA3" },
types: {
PermitSingle: [{ name: "details", type: "PermitDetails" }, { name: "spender", type: "address" }, { name: "sigDeadline", type: "uint256" }],
PermitDetails: [{ name: "token", type: "address" }, { name: "amount", type: "uint160" }, { name: "expiration", type: "uint48" }, { name: "nonce", type: "uint48" }],
},
primaryType: "PermitSingle",
message: { details: { token: USDC, amount: maxUint160, expiration: maxUint48, nonce: 0n }, spender: UNKNOWN, sigDeadline: BigInt(now + 1800) },
},
"3. Permit2 swap, as a router sends it": {
domain: { name: "Permit2", chainId: 1, verifyingContract: "0x000000000022D473030F116dDEE9F6B43aC78BA3" },
types: {
PermitSingle: [{ name: "details", type: "PermitDetails" }, { name: "spender", type: "address" }, { name: "sigDeadline", type: "uint256" }],
PermitDetails: [{ name: "token", type: "address" }, { name: "amount", type: "uint160" }, { name: "expiration", type: "uint48" }, { name: "nonce", type: "uint48" }],
},
primaryType: "PermitSingle",
message: { details: { token: USDC, amount: 250_000_000n, expiration: BigInt(now + 30 * 86400), nonce: 3n }, spender: ROUTER, sigDeadline: BigInt(now + 1800) },
},
};
for (const [label, request] of Object.entries(requests)) {
console.log(`${label}\n${describe(request, { connectedChainId: 1, now, trustedSpenders: [ROUTER] })}\n`);
}
// Sign request 1, then see what the same signature proves under a different domain.
const permit = requests["1. ERC-2612 permit"];
const signature = await owner.signTypedData(permit);
console.log(`signature ${signature.slice(0, 20)}... (${(signature.length - 2) / 2} bytes)`);
for (const [label, domain] of [
["as signed", permit.domain],
["same message, chainId 8453", { ...permit.domain, chainId: 8453 }],
["same message, version \"1\"", { ...permit.domain, version: "1" }],
]) {
const signer = await recoverTypedDataAddress({ ...permit, domain, signature });
console.log(`${label.padEnd(30)} recovers ${signer === owner.address ? "the owner" : `another address, ${signer.slice(0, 10)}...`}`);
}
USDC domain separator matches the contract's DOMAIN_SEPARATOR(): true
1. ERC-2612 permit
domain USD Coin v2, chain 1, contract 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
type Permit
domain hash 0x06c37168a7db5138defc7866392bb87a741f9b3d104deb5094588ce041cae335
message hash 0xd31ad88aaf46e1d347583c08985fca18fdbd119103263d1d015ca3d30a3518f7
digest 0xbd5b314b039b713da90e540e7f341e381b00ca3b13a268e83697717d653f881e
action token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 lets 0x5e1f0000000000000000000000000000000c0ffe spend UNLIMITED, signature valid until 2029-10-19T13:01Z, in 1095 days
RISK unlimited allowance
RISK spender 0x5e1f0000000000000000000000000000000c0ffe is not one you expect
2. Permit2 allowance
domain Permit2 v-, chain 1, contract 0x000000000022D473030F116dDEE9F6B43aC78BA3
type PermitSingle
domain hash 0x866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28
message hash 0xb8b765bce2e69b8175af5e2dac9689673ce2f98eefb3be07ff08f80471ed8efe
digest 0x8fcfc0fb826d723eacfdbf5d39a4ee0a407bd9b93533c22bae7263eccccab894
action 0x5e1f0000000000000000000000000000000c0ffe may pull UNLIMITED of 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 until never; signature valid until 2026-10-20T13:31Z, in 30 minutes
RISK unlimited allowance
RISK allowance never expires
RISK spender 0x5e1f0000000000000000000000000000000c0ffe is not one you expect
3. Permit2 swap, as a router sends it
domain Permit2 v-, chain 1, contract 0x000000000022D473030F116dDEE9F6B43aC78BA3
type PermitSingle
domain hash 0x866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28
message hash 0x4c4dfafb80b5180321026fdb0db11175968834f982b49f812a02f5e780d17036
digest 0x7399baf230812abc18116a5a75c85e2d1fbf97f6e50e6b053e46f185f897f79f
action 0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af may pull 250000000 of 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 until 2026-11-19T13:01Z, in 30 days; signature valid until 2026-10-20T13:31Z, in 30 minutes
signature 0x163c834f3cae48501f... (65 bytes)
as signed recovers the owner
same message, chainId 8453 recovers another address, 0xB19b7dD9...
same message, version "1" recovers another address, 0xe46542B2...
Reading it:
- The USDC domain hash equals the value read from the USDC contract, and the Permit2 domain hash equals Permit2's mainnet
DOMAIN_SEPARATOR. A request whose domain hash differs from the contract's is for a different contract or chain, whatever itsnamesays. - Requests 1 and 2 hand over every USDC the owner will ever have to an unknown address: one for three years, one forever. Request 2's signature deadline is 30 minutes, which looks safe and is not: the deadline limits when the signature can be submitted, while the allowance it creates has no end.
- Request 3 is what a legitimate swap looks like: a known router, 250 USDC (USDC has 6 decimals, so
250000000is 250 units), 30 days. - The signature is bound to its domain. Under another chain ID or version it recovers a different address, so it is worthless there.
Blind signing on hardware wallets
When a hardware wallet cannot decode a message for the device screen, it signs blind. Ledger's Ethereum app has long had a mode that shows only the domain hash and the message hash (app-ethereum PR #327); full clear signing needs an ERC-7730 descriptor for the domain, and without one the device falls back to blind signing (Ledger documentation). The domain hash and message hash lines in the output above are exactly the values to compare: compute them on a second machine from the request JSON, and refuse if either differs from the device.
Multisig transactions: the Safe case
Safe (formerly Gnosis Safe) owners approve transactions by signing EIP-712 data as well: a SafeTx struct with the target to, value, data, an operation field (0 for a call, 1 for a delegatecall), gas parameters and a nonce, under a domain of chainId and the Safe's own address (EIP712Domain(uint256 chainId,address verifyingContract) since Safe 1.3.0, per Safe.sol). A delegatecall runs another contract's code with the Safe's own storage and balance, so a SafeTx with operation: 1 can replace the wallet's logic entirely.
That is what happened to the exchange Bybit on 21 February 2025. According to Sygnia's investigation, JavaScript served from Safe{Wallet}'s own web infrastructure had been modified to activate only for Bybit's cold wallet. The signers believed they were approving a routine transfer to a warm wallet; the transaction they signed used operation = 1 to delegate execution to a pre-deployed contract, which replaced the wallet's implementation and let the attackers drain over 400,000 ETH without further signatures (Bybit's timeline). The interface lied; the hashes would not have.
// What a Safe owner signs: SafeTx typed data. One changed field changes the message hash and the
// final hash, while the domain hash stays the same. Addresses are random, generated for the run.
import { hashDomain, hashStruct, hashTypedData, keccak256, parseEther, toHex, zeroAddress } from "viem";
import { generatePrivateKey, privateKeyToAddress } from "viem/accounts";
const SAFE_TX =
"SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)";
console.log(`SafeTx type hash equals SAFE_TX_TYPEHASH in Safe.sol: ${keccak256(toHex(SAFE_TX)) === "0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8"}`);
const types = {
SafeTx: [
{ name: "to", type: "address" }, { name: "value", type: "uint256" }, { name: "data", type: "bytes" },
{ name: "operation", type: "uint8" }, { name: "safeTxGas", type: "uint256" }, { name: "baseGas", type: "uint256" },
{ name: "gasPrice", type: "uint256" }, { name: "gasToken", type: "address" }, { name: "refundReceiver", type: "address" },
{ name: "nonce", type: "uint256" },
],
};
const random = () => privateKeyToAddress(generatePrivateKey());
const safe = random();
const warmWallet = random();
const otherContract = random();
const domain = { chainId: 1, verifyingContract: safe }; // Safe 1.3.0+: EIP712Domain(uint256 chainId,address verifyingContract)
const transfer = {
to: warmWallet, value: parseEther("30000"), data: "0x", operation: 0,
safeTxGas: 0n, baseGas: 0n, gasPrice: 0n, gasToken: zeroAddress, refundReceiver: zeroAddress, nonce: 42n,
};
for (const [label, message] of [
["transfer 30000 ETH (operation 0, call)", transfer],
["same fields, operation 1 (delegatecall)", { ...transfer, operation: 1 }],
["delegatecall to another contract, value 0", { ...transfer, to: otherContract, value: 0n, operation: 1 }],
]) {
console.log(`\n${label}`);
console.log(` domain hash ${hashDomain({ domain, types: { EIP712Domain: [{ name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" }] } })}`);
console.log(` message hash ${hashStruct({ data: message, primaryType: "SafeTx", types })}`);
console.log(` safeTxHash ${hashTypedData({ domain, types, primaryType: "SafeTx", message })}`);
}
SafeTx type hash equals SAFE_TX_TYPEHASH in Safe.sol: true
transfer 30000 ETH (operation 0, call)
domain hash 0x40902b4c26feeff637583796e45fb0895d3dc7c8f6426854e32b0a094b131796
message hash 0x2b9d9e60cc9312044229f6a8483dc86eaf49b8cc03ac37af3a44b1e573d28316
safeTxHash 0x46beeb0e67f4c0c3e3ec76e6ee8feabaad2f5df52ec7afe4bfc192d7592dd99e
same fields, operation 1 (delegatecall)
domain hash 0x40902b4c26feeff637583796e45fb0895d3dc7c8f6426854e32b0a094b131796
message hash 0xc9e7bcc6b496ee176fc021a8d440e3660fbea74ec2da65038e519b4220d5c8af
safeTxHash 0xaf671685ec110193631edb37aa05c813e9cd9e1a9fad061872d44c10e064d00e
delegatecall to another contract, value 0
domain hash 0x40902b4c26feeff637583796e45fb0895d3dc7c8f6426854e32b0a094b131796
message hash 0xcd1a4444a84bf0867dfe41eec2b961b83e187c8fc8262efa2c92e2f4343c0ba3
safeTxHash 0x7c298ccf189f829006f8503d0be25293e358a45884e08ce576f94d5eae0d0148
The domain hash is identical in all three cases: it only says "this Safe on this chain". Flipping the single operation field changes the message hash and therefore the final hash. A signer who recomputes safeTxHash from the transaction they intend, on a machine that does not run the wallet's web interface, and compares it with what the hardware device shows, catches exactly this kind of substitution. The Safe transaction hash tool does that computation locally.
Not every dangerous signature is EIP-712
Two other signing formats deserve the same scrutiny, because wallets show them differently:
personal_sign(EIP-191 version0x45) prefixes the message with"\x19Ethereum Signed Message:\n"and its length. It is meant for human-readable text such as a Sign-In with Ethereum message. A request topersonal_signa 32-byte hex string asks you to sign something you cannot read. Some legitimate tools do it, notably multisig wallets that fall back to signing a transaction hash on devices without typed-data support, and that is exactly when you must recompute the hash yourself.- EIP-7702 authorizations (EIP-7702, Final) are signed over
keccak(0x05 ‖ rlp([chain_id, address, nonce])), not EIP-712. One authorization sets the code of your account to a pointer ataddress, so that contract's code runs as your account from then on: it is a delegation of the whole account, not an approval of one token. Achain_idof 0 makes the authorization valid on every chain. Wallets are expected to show these specially; treat any request to sign one outside your wallet's own upgrade flow as hostile. The EIP-7702 decoder shows what an authorization or type-4 transaction delegates to.
Revoking what you signed
- A used ERC-2612 permit is an ordinary allowance: revoke with
approve(spender, 0)on the token. - An unused ERC-2612 permit stays valid until its deadline or until the owner's nonce moves. Using any permit for the same token increments the nonce and kills older unused ones, but there is no direct "cancel".
- Permit2 allowances can be revoked with
approve(token, spender, 0, 0)or in bulk withlockdown; unusedPermitSinglesignatures are invalidated byinvalidateNonces(token, spender, newNonce), and unused SignatureTransfer signatures byinvalidateUnorderedNonces. - The underlying approval to Permit2 itself remains until revoked on the token; that approval is what makes every Permit2 signature valuable.
For developers producing typed data
- Use
eth_signTypedData_v4, the version MetaMask recommends for typed data, and a library to build it: hand-assembled JSON is the source of errors such as ambiguous primary types or unused types in ethers and Invalid primary type in viem. - Include
chainIdandverifyingContractin your domain. Without them a signature for your app on one chain is valid on every other chain where the same contract logic accepts it. - Make every signed action replay-safe: a nonce or a used-signature set, and a deadline. EIP-712's security section is explicit that replay protection is the application's job.
- Ask for exactly what the action needs: the amount of this trade, an expiry in minutes or days. Interfaces that request unlimited, non-expiring approvals train users to sign them.
- Verify with recovery, then compare addresses, and for smart-contract wallets with ERC-1271
isValidSignatureinstead; see Sign-In with Ethereum for the server side.
Sources
- EIP-712: Typed structured data hashing and signing and its Example.js
- EIP-191: Signed Data Standard
- ERC-2612: Permit Extension for EIP-20 Signed Approvals
- Uniswap Permit2:
PermitHash.sol,AllowanceTransfer.sol,SignatureTransfer.sol,libraries/Allowance.sol - MetaMask: sign data
- Ledger: Clear Signing for wallets and ERC-7730 registry
- viem:
hashTypedData,hashDomain,hashStruct,recoverTypedDataAddress