Skip to main content
Skill — Copyable reference implementation. Use as-is or customize. See Skills Philosophy.
EIP-712 enables human-readable message signing with domain separation. Instead of signing opaque hex blobs, users see structured data in their wallet prompts.

Domain Separator

The domain separator cryptographically binds signatures to specific contract + chain combinations, preventing replay attacks.
import * as EIP712 from '@voltaire/crypto/eip712';
import * as Address from '@voltaire/primitives/address';

const domain = {
  name: 'MyDApp',           // Application name
  version: '1',             // Version string
  chainId: 1n,              // Chain ID (bigint)
  verifyingContract: Address.from('0x742d35Cc6634C0532925a3b844Bc9e7595f251e3')
};

// Hash the domain separator
const domainHash = EIP712.Domain.hash(domain);
// Returns: Uint8Array(32) - keccak256 hash
Domain fields:
  • name: DApp name shown to user
  • version: Versioning for signature schema changes
  • chainId: Prevents cross-chain replay (mainnet=1, polygon=137)
  • verifyingContract: Contract that will verify the signature
  • salt (optional): Additional entropy for extra separation

Type Definitions

Define message structure using Solidity-like type definitions:
const types = {
  // Simple struct
  Person: [
    { name: 'name', type: 'string' },
    { name: 'wallet', type: 'address' }
  ],

  // Struct with nested reference
  Mail: [
    { name: 'from', type: 'Person' },      // References Person type
    { name: 'to', type: 'Person' },
    { name: 'contents', type: 'string' }
  ]
};

// Encode type for debugging
const typeString = EIP712.encodeType('Mail', types);
// "Mail(Person from,Person to,string contents)Person(string name,address wallet)"

// Get type hash
const typeHash = EIP712.hashType('Mail', types);
Supported types:
  • Integers: uint8 through uint256, int8 through int256
  • Address: address
  • Boolean: bool
  • Fixed bytes: bytes1 through bytes32
  • Dynamic: bytes, string
  • Arrays: type[], type[N]
  • Custom structs: Referenced by name

Hashing Typed Data

Complete typed data combines domain, types, primary type, and message:
import * as EIP712 from '@voltaire/crypto/eip712';
import * as Address from '@voltaire/primitives/address';

const typedData = {
  domain: {
    name: 'Ether Mail',
    version: '1',
    chainId: 1n,
    verifyingContract: Address.from('0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC')
  },
  types: {
    Person: [
      { name: 'name', type: 'string' },
      { name: 'wallet', type: 'address' }
    ],
    Mail: [
      { name: 'from', type: 'Person' },
      { name: 'to', type: 'Person' },
      { name: 'contents', type: 'string' }
    ]
  },
  primaryType: 'Mail',
  message: {
    from: {
      name: 'Alice',
      wallet: Address.from('0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826')
    },
    to: {
      name: 'Bob',
      wallet: Address.from('0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB')
    },
    contents: 'Hello, Bob!'
  }
};

// Hash for signing: keccak256("\x19\x01" || domainSeparator || hashStruct(message))
const hash = EIP712.hashTypedData(typedData);
// Returns: Uint8Array(32)

Signing Typed Data

Sign with a private key to produce ECDSA signature:
import * as EIP712 from '@voltaire/crypto/eip712';
import * as PrivateKey from '@voltaire/primitives/private-key';

// Your private key (32 bytes)
const privateKey = PrivateKey.from('0x...');

// Sign - produces { r, s, v }
const signature = EIP712.signTypedData(typedData, privateKey);

console.log(signature.r);  // Uint8Array(32)
console.log(signature.s);  // Uint8Array(32)
console.log(signature.v);  // 27 or 28
The signature can be submitted to:
  • Smart contracts via ecrecover
  • Relayers for meta-transactions
  • Order books for DEX orders

Verifying Typed Signatures

Recover the signer’s address and verify it matches expected:
import * as EIP712 from '@voltaire/crypto/eip712';
import * as Address from '@voltaire/primitives/address';

// Recover signer address from signature
const recoveredAddress = EIP712.recoverAddress(signature, typedData);

// Verify against expected signer
const expectedSigner = Address.from('0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826');
const isValid = EIP712.verifyTypedData(signature, typedData, expectedSigner);

if (isValid) {
  console.log('Signature is valid');
} else {
  console.log('Invalid signature or wrong signer');
}
Verification uses constant-time comparison to prevent timing attacks.

Complete Example: ERC-2612 Permit

Gasless token approvals using EIP-712:
import * as EIP712 from '@voltaire/crypto/eip712';
import * as Address from '@voltaire/primitives/address';
import * as PrivateKey from '@voltaire/primitives/private-key';

// Token holder's private key
const ownerKey = PrivateKey.from('0x...');

// Permit message for gasless approval
const permitData = {
  domain: {
    name: 'USD Coin',
    version: '2',
    chainId: 1n,
    verifyingContract: Address.from('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48')
  },
  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: Address.from('0xOwnerAddress...'),
    spender: Address.from('0xSpenderAddress...'),
    value: 1000000n,        // 1 USDC (6 decimals)
    nonce: 0n,              // Current nonce from contract
    deadline: 1735689600n   // Unix timestamp expiry
  }
};

// Sign the permit
const signature = EIP712.signTypedData(permitData, ownerKey);

// Submit to contract: permit(owner, spender, value, deadline, v, r, s)
// Relayer pays gas, owner's tokens get approved

Validation

Validate typed data structure before signing:
import * as EIP712 from '@voltaire/crypto/eip712';

try {
  EIP712.validate(typedData);
  console.log('Valid typed data structure');
} catch (error) {
  console.error('Invalid:', error.message);
}
Validation checks:
  • Primary type exists in types
  • All referenced types are defined
  • Message fields match type definitions
  • No circular type references

Error Handling

import * as EIP712 from '@voltaire/crypto/eip712';
import {
  Eip712Error,
  Eip712TypeNotFoundError,
  Eip712InvalidMessageError,
  Eip712EncodingError
} from '@voltaire/crypto/eip712';

try {
  const hash = EIP712.hashTypedData(typedData);
} catch (error) {
  if (error instanceof Eip712TypeNotFoundError) {
    // Referenced type not in types object
  } else if (error instanceof Eip712InvalidMessageError) {
    // Message doesn't match type definition
  } else if (error instanceof Eip712EncodingError) {
    // Value encoding failed (wrong type, size, etc.)
  }
}

Security Best Practices

  1. Always include deadlines - Prevent signature reuse after expiry
  2. Use nonces - Contract tracks nonces to prevent replay
  3. Validate before signing - Call validate() on user-provided data
  4. Verify recovered address - Never trust signatures without verification
  5. Include chainId - Prevents cross-chain replay attacks
// Good: Time-bounded with nonce
message: {
  action: 'approve',
  nonce: await contract.nonces(userAddress),
  deadline: BigInt(Math.floor(Date.now() / 1000) + 3600) // 1 hour
}

API Reference

FunctionDescription
hashTypedData(typedData)Hash complete typed data for signing
signTypedData(typedData, privateKey)Sign typed data, returns {r, s, v}
verifyTypedData(signature, typedData, address)Verify signature matches expected signer
recoverAddress(signature, typedData)Recover signer address from signature
Domain.hash(domain)Hash domain separator
encodeType(primaryType, types)Get canonical type encoding string
hashType(primaryType, types)Hash type encoding
hashStruct(primaryType, message, types)Hash struct data
encodeValue(type, value, types)Encode single value
validate(typedData)Validate typed data structure
format(typedData)Format for human-readable display