Sign in

Gasless approvals

Approving an ERC-20 has always cost a transaction. The owner has to call approve(spender, amount) on the token contract, which means they need ETH for gas. If someone receives USDC and has no ETH, they can't authorize anyone to spend it. ERC-2612 fixes this by letting the owner sign a message off-chain that says "I authorize this spender for this amount."

The problem permit solves

The classic approval flow is two transactions. The owner calls approve to set an allowance. The spender then calls transferFrom to move tokens. Both transactions cost gas. The owner pays for the first, the spender pays for the second.

This works fine when the owner already holds ETH. It breaks when they don't. Someone receiving stablecoins for the first time might have zero ETH in their wallet. To use the tokens, they first have to acquire ETH somehow just to pay for the approval. From the user's perspective, holding a valuable token they can't spend without paying for it in a different token feels broken.

It also makes dApp UX worse. A DEX swap on Uniswap, for example, requires the user to sign two transactions in a row: one approving the router to spend their input token, then the swap itself. Two wallet pop-ups, two waits for confirmations, two gas fees, for what feels like a single action.

Old approve flow needs two transactions. Permit collapses it into one. Classic approve + transferFrom TX 1 — Owner pays gas: token.approve(spender, 100) TX 2 — Spender pays gas: token.transferFrom(owner, ..., 100) Owner needs ETH for TX 1 No ETH = can't use their tokens permit + transferFrom Off-chain — Owner pays NOTHING: signs a message in their wallet TX 1 — Anyone can pay the gas: token.permit(...) + transferFrom(...) Owner needs no ETH at all Spender, relayer, dApp pays gas Why this matters in practice First-time users: They received tokens but have no ETH. Now they can spend without buying gas first. DEXes and DeFi protocols: Approval + swap was 2 wallet pop-ups. Now it's 1 signature, then submit both calls. Gasless dApps / meta-transactions: A relayer service pays gas on behalf of users, recouping cost via fees or sponsorship.

The core idea: separate the authorization (who agrees) from the execution (who submits the transaction). The owner signs a message saying "I authorize Bob to spend 100 USDC of mine." This signature can be produced offline at zero cost. Then anyone, the spender themselves or a relayer service or even a different user, can submit that signature on chain. The token contract verifies the signature and updates the allowance as if the owner had called approve directly.

EIP-712: signing structured data

To make this work, the owner has to sign something. A naive approach: sign keccak256("approve Bob 100") as raw bytes. There are two problems with that.

First, the wallet has no idea what the user is signing. All it sees is an opaque hash. The display reads "Sign message: 0x9a73f2c8..." and the user has no way to verify what that hash means. Malicious dApps have exploited this by tricking users into signing hashes that secretly authorize transfers to attacker-controlled addresses. The user thinks they're signing a login token; they're actually approving an unlimited token allowance.

Second, signatures are cross-application by default. A signature for "approve Bob 100" on the USDC contract on Ethereum mainnet would also be valid on a clone of USDC on Polygon, or on a different token contract with the same logic, or replayed in a different chain entirely. There's nothing in the bare signature that ties it to a specific contract, chain, or even application.

EIP-712 was the standard introduced in 2017 to fix both problems. It defines a way to sign typed structured data so that:

  1. Wallets know the structure of what's being signed and can display it in a human-readable form.
  2. The signature is bound to a specific domain, preventing cross-app and cross-chain replay.

The structure has two layers: a domain and a message.

EIP-712 wraps the message in domain + type so the wallet can display it What gets signed: Domain prevents cross-app and cross-chain replay name: "USD Coin" version: "1" chainId: 1 verifyingContract: 0xA0b8...EB48 Permit message the actual approval data owner: 0xAlice... spender: 0xBob... value: 100 USDC nonce: 3 deadline: 1735689600 hash Final digest keccak256( "\x19\x01" || domainHash || messageHash ) → signed with owner's key produces (v, r, s) What the wallet displays to the user: Sign request from: USD Coin Approve 0xBob... to spend 100 USDC from your account on chain 1, valid until 2025-01-01 Without EIP-712 the wallet would show only an opaque hex blob: "Sign 0x1901bf3e... ?" Users had no way to verify what they were approving.

The domain identifies what application and what context this signature belongs to. It contains the protocol name, version, chain ID, and the verifying contract address. A signature from USDC mainnet won't validate against USDC on a testnet, because the chain IDs differ. A signature meant for one token won't validate against another, because the verifying contract addresses differ.

The message carries the actual content: who's authorizing whom for how much, plus a nonce and a deadline.

The final digest that gets signed combines both layers. The exact construction:

digest = keccak256(0x1901 || domainSeparator || messageHash)

The 0x1901 prefix is a magic byte sequence defined in the standard. It guarantees this digest can't collide with the digest of a regular Ethereum transaction, which uses a different prefix. The signer doesn't have to know any of this, their wallet builds the digest from the typed data and signs it.

ERC-2612 specifics

ERC-2612 is the standard that applies EIP-712 to ERC-20 approvals. It defines exactly what the message structure looks like and exactly what function signature the token must expose to accept it.

The permit message has these fields:

Permit(
  address owner,
  address spender,
  uint256 value,
  uint256 nonce,
  uint256 deadline
)

And the function the token has to add:

solidity
function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

The token also has to expose two helpers required by the standard:

solidity
function nonces(address owner) external view returns (uint256);
function DOMAIN_SEPARATOR() external view returns (bytes32);

nonces(owner) returns the next valid nonce for that owner. Every successful permit increments this counter, which is how the contract prevents a signature from being submitted twice. DOMAIN_SEPARATOR() returns the hashed domain (the contract's identity) so off-chain code can build the right digest without having to know the token's name and version separately.

How the on-chain side works

The implementation of permit does four things in sequence: check the deadline, rebuild the digest the owner signed, recover the signer's address from the signature, and verify the recovered signer is the claimed owner. If all four pass, the contract calls its own internal approval logic.

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract MyTokenPermit is ERC20, EIP712 {
    mapping(address => uint256) public nonces;

    bytes32 private constant PERMIT_TYPEHASH = keccak256(
        "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
    );

    error PermitExpired();
    error InvalidSigner();

    constructor(string memory name_)
        ERC20(name_, "MTP")
        EIP712(name_, "1")
    {}

    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        if (block.timestamp > deadline) revert PermitExpired();

        bytes32 structHash = keccak256(
            abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)
        );
        bytes32 digest = _hashTypedDataV4(structHash);

        address signer = ECDSA.recover(digest, v, r, s);
        if (signer != owner) revert InvalidSigner();

        _approve(owner, spender, value);
    }
}

A few specific things to notice.

The PERMIT_TYPEHASH is a precomputed hash of the message type definition. It encodes "this is a Permit struct with these field types." The exact string format matters down to the last character including no spaces between fields. This is part of the EIP-712 spec.

nonces[owner]++ is doing two things in one expression: returning the current nonce and incrementing it for next time. Reading and incrementing in one shot prevents a class of bugs where the wrong nonce gets folded into the digest.

_hashTypedDataV4(structHash) is OpenZeppelin's helper that combines the struct hash with the contract's domain separator according to the \x19\x01 || ... formula. The EIP712 base contract handles caching the domain separator at deployment time so this stays cheap on repeated reads.

ECDSA.recover is signature verification. Given a digest and the signature components (v, r, s), it returns the address that signed the digest. If the recovered address matches the claimed owner, the signature is valid. The standard ecrecover opcode does this, but ECDSA.recover from OpenZeppelin adds malleability protection, rejecting non-canonical s values that would let an attacker generate two valid signatures from one.

If all checks pass, the function calls _approve(owner, spender, value), which is the same internal helper that the regular approve function calls. From the rest of the contract's perspective, this is now an authorized allowance, indistinguishable from one set by a direct approve call.

The full lifecycle

Owner signs off-chain. Anyone submits on-chain. The chain checks the signature. — off-chain, no gas, no chain interaction — 1 Owner builds and signs the EIP-712 message wallet shows the preview (spender, value, deadline) and returns signature (v, r, s) 2 Owner sends the signed message to a relayer the relayer can be the spender, the dApp's backend, or any third party with ETH for gas — on chain, in one transaction — 3 Relayer submits a transaction calling permit(...) relayer pays gas, owner's signature travels as call data 4 Token contract verifies the signature recover signer, check it matches owner, check deadline, bump nonce, set allowance 5 Same transaction calls transferFrom allowance is consumed, tokens move from owner to recipient Done. Owner paid zero gas. Relayer collected fee or sponsored the user.

The shape of an end-to-end permit flow:

The owner's wallet builds the EIP-712 typed data structure with the domain (read from the token's DOMAIN_SEPARATOR()) and the message fields. The wallet shows the user a human-readable preview. The user clicks approve. The wallet returns a signature, broken into the three components v, r, and s.

The owner sends the message and signature to a relayer. The relayer might be the spender themselves, dApp, a dedicated meta-transaction service, or any third party with both an interest in the transfer happening and ETH to pay for gas. The communication channel is arbitrary: HTTP, WebSocket, even a QR code if the owner is fully offline.

The relayer submits a transaction that calls permit(owner, spender, value, deadline, v, r, s). The token contract performs the four checks: deadline, rebuild digest, recover signer, signer matches owner, and updates the allowance. Almost always, the relayer follows this in the same transaction with a transferFrom call that actually moves the tokens. Two function calls, one transaction.

From the owner's perspective, they signed one message in their wallet and never paid gas. From the chain's perspective, an allowance was set and consumed, equivalent to having received a normal approve followed by transferFrom.

Signing from the frontend

In a viem-based frontend, building and signing the typed data looks like this:

typescript
const domain = {
    name: "My Token",
    version: "1",
    chainId: 1,
    verifyingContract: tokenAddress,
} as const;

const types = {
    Permit: [
        { name: "owner",    type: "address" },
        { name: "spender",  type: "address" },
        { name: "value",    type: "uint256" },
        { name: "nonce",    type: "uint256" },
        { name: "deadline", type: "uint256" },
    ],
} as const;

const message = {
    owner: ownerAddress,
    spender: spenderAddress,
    value: 100n * 10n ** 18n,
    nonce: await tokenContract.read.nonces([ownerAddress]),
    deadline: BigInt(Math.floor(Date.now() / 1000) + 3600),
} as const;

const signature = await walletClient.signTypedData({
    account: ownerAddress,
    domain,
    types,
    primaryType: "Permit",
    message,
});

// Split into v, r, s for the contract call
const r = `0x${signature.slice(2, 66)}` as `0x${string}`;
const s = `0x${signature.slice(66, 130)}` as `0x${string}`;
const v = parseInt(signature.slice(130, 132), 16);

Every field in domain must match exactly what the contract uses, byte for byte. A wrong name, wrong version, or wrong chain ID will produce a signature that the contract will reject as invalid, with no helpful error message about what went wrong. This is the most common source of "my permit doesn't work" bugs.

Most modern wallet libraries include a signTypedData helper that takes the typed data structure and produces the signature. Don't try to hash the data manually and call a generic sign method; the helper handles the \x19\x01 prefixing, the canonical JSON encoding, and the domain separator calculation correctly.

Security considerations

The deadline matters. Without it, a signature lasts forever. A user who signed a permit a year ago for some forgotten dApp shouldn't suddenly be vulnerable when that dApp resurfaces and submits the old signature. Set deadlines short by default. A swap should give itself 30 minutes, not 30 days.

Permit signatures can be front-run. Since the signature is just bytes, anyone who sees it can submit it themselves. They can't change who the allowance goes to but they can submit the permit before the user's intended transaction, which then fails because the nonce has already been consumed. The result: the user's approval went through but their intended follow-up did not. They're now exposed to anyone with that allowance until the spender uses or revokes it. Production code should handle this case, usually by checking if the allowance already matches before submitting permit.

The nonce prevents replay within one contract but does not prevent the same logical permit from being signed twice with different nonces. If a user signs permit-with-nonce-3, the dApp submits it, and the user later signs another permit-with-nonce-4 for the same spender and amount, the second one is also valid. Wallets that batch operations need to track nonces carefully to avoid signing conflicting permits.

Approval scams predate permit but are amplified by it. The "infinite approval" pattern (signing permit for type(uint256).max so subsequent operations don't need new permits) is convenient but means any vulnerability in the spender drains all the user's tokens of that type. Many real-world wallet drainer attacks have used social engineering to trick users into signing permits for max value to malicious contracts. EIP-712's readable preview helps, but doesn't help if users approve without reading.