Post 18
Signing Without Gas: EIP-712, Permit2, and Meta-Transactions
An off-chain signature costs nothing, confirms instantly, and can authorise a transfer, an approval, an order, or a whole batch. Here is how to build gasless approvals and intents on the front end, and where the sharp edges are.

Every wallet prompt is a moment the user can bail. The classic ERC-20 flow has two of them — approve, then act — and the first one costs gas to grant a permission the user does not even understand. A signed message replaces that: no gas, instant, and the wallet shows structured, human-readable data instead of a hex blob.
EIP-712: structured data the wallet can render
EIP-712 defines a way to hash typed structured data so a wallet can show the user exactly what they are signing — field names and values, not 0x1901.... Every gasless pattern is built on it.
1import { useSignTypedData } from "wagmi";2 3const { signTypedDataAsync } = useSignTypedData();4 5const signature = await signTypedDataAsync({6 domain: {7 name: "MyDEX",8 version: "1",9 chainId: 8453,10 verifyingContract: SETTLEMENT_CONTRACT,11 },12 types: {13 Order: [14 { name: "maker", type: "address" },15 { name: "sell", type: "address" },16 { name: "buy", type: "address" },17 { name: "sellAmount", type: "uint256" },18 { name: "buyAmount", type: "uint256" },19 { name: "expiry", type: "uint256" },20 { name: "nonce", type: "uint256" },21 ],22 },23 primaryType: "Order",24 message: {25 maker: user, sell: USDC, buy: WETH,26 sellAmount, buyAmount, expiry, nonce,27 },28});29// the user signs a readable order; a solver/relayer submits it on-chainERC-2612 permit: gasless approval, per token
Tokens that implement ERC-2612 (permit) let you approve via a signature. The signature travels with your transaction and the contract calls permit first, then does its thing — one on-chain transaction, one signature, zero separate approval.
1const nonce = await client.readContract({2 address: token, abi: erc2612Abi, functionName: "nonces", args: [owner],3});4const name = await client.readContract({ address: token, abi: erc2612Abi, functionName: "name" });5 6const signature = await walletClient.signTypedData({7 account: owner,8 domain: { name, version: "1", chainId, verifyingContract: token },9 types: {10 Permit: [11 { name: "owner", type: "address" },12 { name: "spender", type: "address" },13 { name: "value", type: "uint256" },14 { name: "nonce", type: "uint256" },15 { name: "deadline", type: "uint256" },16 ],17 },18 primaryType: "Permit",19 message: { owner, spender, value, nonce, deadline },20});21 22const { r, s, v } = parseSignature(signature);23// pass r, s, v, deadline into the contract call that needs the allowanceNot every token supports permit, and some (looking at you, USDC on some chains) use a non-standard version string or domain. Always read name, nonces, and — where present — version from the token rather than hardcoding. Feature-detect and fall back to a normal approve when permit is absent.
Permit2: gasless approval for tokens that do not support permit
Uniswap's Permit2 is a single canonical contract that holds allowances on behalf of every integrating protocol. The user does one traditional approve to Permit2 ever, then all subsequent approvals — for any protocol — are signatures.
1import { SignatureTransfer } from "@uniswap/permit2-sdk";2 3const permit = {4 permitted: { token: USDC, amount },5 spender: MY_PROTOCOL,6 nonce, // unordered nonce7 deadline: BigInt(Math.floor(Date.now() / 1000) + 1800),8};9 10const { domain, types, values } = SignatureTransfer.getPermitData(11 permit, PERMIT2_ADDRESS, chainId,12);13 14const signature = await walletClient.signTypedData({15 account: user, domain, types, primaryType: "PermitTransferFrom", message: values,16});17// your contract calls permit2.permitTransferFrom(permit, transferDetails, user, signature)Meta-transactions: the user signs, someone else pays
The general pattern: the user signs an EIP-712 message describing an action, a relayer wraps it in a real transaction and pays the gas, and the target contract recovers the signer and acts as if it were msg.sender. ERC-2771 (_msgSender() via a trusted forwarder) is the common on-chain side.
1const request = {2 from: user,3 to: TARGET_CONTRACT,4 value: 0n,5 gas: 200_000n,6 nonce: await forwarder.read.getNonce([user]),7 data: encodeFunctionData({ abi, functionName: "claim", args: [tokenId] }),8};9 10const signature = await walletClient.signTypedData({11 account: user,12 domain: forwarderDomain,13 types: forwardRequestTypes,14 primaryType: "ForwardRequest",15 message: request,16});17 18await fetch("/api/relay", {19 method: "POST",20 body: JSON.stringify({ request, signature }),21});22// your /api/relay route (or Gelato / OpenZeppelin Defender) submits itThe sharp edges
- Phishing risk is real. A signature can authorise a token transfer. Wallets now warn on suspicious typed data — do not design flows that train users to sign things blindly.
- Nonces and replay. Every scheme needs a nonce (ordered or unordered) and a deadline. Show the deadline to the user; expired signatures fail confusingly otherwise.
- Smart accounts sign differently. A contract wallet's signature is verified with EIP-1271, not
ecrecover. If your relayer or contract verifies signatures, support both. - The relayer is a trust and liveness dependency. If it is down, the gasless path is down. Offer a 'pay gas yourself' fallback.
A signature is a promise the user makes for free. The whole art is making the promise legible — so they know what they agreed to — and making the fallback obvious when the gasless rail fails.
Found this useful? Pass it on.
Want a custom write-up for your team? Get in touch.
Building something like this?
If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.