EIP-712 Typed Signatures Integration

We design and develop full-cycle blockchain solutions: from smart contract architecture to launching DeFi protocols, NFT marketplaces and crypto exchanges. Security audits, tokenomics, integration with existing infrastructure.
Showing 1 of 1 servicesAll 1306 services
EIP-712 Typed Signatures Integration
Medium
from 1 business day to 3 business days
FAQ
Blockchain Development Services
Blockchain Development Stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1218
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    853
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1047
  • image_logo-advance_0.png
    B2B Advance company logo design
    561
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    823

EIP-712 Integration (Typed Signatures)

The user sees a "Sign" request in MetaMask with a hex string like 0x7f8e3a.... What they're signing is unclear. EIP-712 changes this: the wallet shows structured data with field names and values, the user understands what they're approving. This matters not just for UX — it's critical for security, because phishing through fake signature requests has become a standard attack.

What EIP-712 Technically Is

EIP-712 defines a standard for hashing typed structured data. Instead of signing arbitrary bytes — sign a structure with types and field values.

The hash is built by the formula:

hashToSign = keccak256(
    "\x19\x01" || domainSeparator || hashStruct(message)
)

Domain separator — a unique contract identifier preventing replay attacks between different applications and chains:

bytes32 private immutable DOMAIN_SEPARATOR;

constructor() {
    DOMAIN_SEPARATOR = keccak256(abi.encode(
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
        keccak256(bytes("MyProtocol")),
        keccak256(bytes("1")),
        block.chainid,
        address(this)
    ));
}

block.chainid in DOMAIN_SEPARATOR guarantees that a signature for Ethereum mainnet cannot be reused on Polygon.

Practical Example: Permit Without Approve Transaction

The most common EIP-712 use case is the permit function in ERC-20 tokens (EIP-2612). A user signs permission off-chain, a third party sends that signature to the contract and immediately spends tokens. No separate approve transaction — saving one on-chain transaction and gas.

// Permit message structure
struct Permit {
    address owner;
    address spender;
    uint256 value;
    uint256 nonce;     // against replay
    uint256 deadline;  // expiration
}

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

function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    require(block.timestamp <= deadline, "Permit expired");
    
    bytes32 structHash = keccak256(abi.encode(
        PERMIT_TYPEHASH,
        owner,
        spender,
        value,
        nonces[owner]++,  // increment after use
        deadline
    ));
    
    bytes32 hash = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
    
    address signer = ecrecover(hash, v, r, s);
    require(signer != address(0) && signer == owner, "Invalid signature");
    
    _approve(owner, spender, value);
}

Nonce is mandatory. Without nonce, one signature can be used multiple times (replay attack). After permit execution, nonce increments — the old signature becomes invalid.

Client Side: Signature Generation

On frontend via viem:

import { signTypedData } from "viem/actions";

const domain = {
  name: "MyProtocol",
  version: "1",
  chainId: 1,
  verifyingContract: contractAddress,
} 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 nonce = await publicClient.readContract({
  address: tokenAddress,
  abi: tokenAbi,
  functionName: "nonces",
  args: [userAddress],
});

const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); // +1 hour

const signature = await walletClient.signTypedData({
  account: userAddress,
  domain,
  types,
  primaryType: "Permit",
  message: {
    owner: userAddress,
    spender: contractAddress,
    value: parseUnits("100", 18),
    nonce,
    deadline,
  },
});

// Parse signature into v, r, s
const { v, r, s } = parseSignature(signature);

The signature is then sent to backend or directly to the smart contract with the next transaction.

Common Integration Mistakes

TYPEHASH mismatch. If the contract TYPEHASH includes one set of fields while the client signs a different set — ecrecover returns a random address. Check: the string in keccak256("Permit(...)") must exactly match the order and names of fields in the structure.

Hardcoded chainId instead of block.chainid. If DOMAIN_SEPARATOR hardcoded chainId as a constant at deployment — when the contract moves to another chain (or when test net is forked), all signatures become invalid. Use block.chainid or cache with verification:

function _domainSeparator() internal view returns (bytes32) {
    if (block.chainid == _CACHED_CHAIN_ID) {
        return _CACHED_DOMAIN_SEPARATOR;
    }
    return _buildDomainSeparator(); // recalculate on chainId change
}

Forgetting about EIP-55 addresses. ecrecover returns a lowercase address. Comparison with checksummed address via == in Solidity works correctly (EVM compares bytes), but in JavaScript it doesn't. In tests, check address.toLowerCase().

Deadline in the past. Users with slow internet sign while confirmation is pending — the transaction arrives after deadline. Recommended deadline value is at least 30 minutes from the time of signing, not 60 seconds.

OpenZeppelin EIP-712

For most projects, there's no need to implement EIP-712 from scratch. OpenZeppelin provides an EIP712 base contract:

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

contract MyContract is EIP712 {
    constructor() EIP712("MyProtocol", "1") {}
    
    function verify(
        address signer,
        MyStruct calldata data,
        bytes calldata signature
    ) public view returns (bool) {
        bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
            MY_STRUCT_TYPEHASH,
            data.field1,
            data.field2
        )));
        
        return ECDSA.recover(digest, signature) == signer;
    }
}

_hashTypedDataV4 automatically applies the "\x19\x01" prefix and DOMAIN_SEPARATOR.

When EIP-712 Is Necessary

  • Gasless transactions (meta-transactions via ERC-2771)
  • ERC-20 permit (EIP-2612) — approve without transaction
  • Order-based protocols (DEX orders, NFT listings on OpenSea/Blur)
  • DAO voting off-chain (Snapshot.org uses EIP-712)
  • Multisig coordination (Safe uses EIP-712 for signatures)
  • Any place where verifiable off-chain authorization is needed

Integration timeline — 1-3 days depending on structure complexity and number of message types. Usually this is part of a broader task, not a separate project.