Skip to main content
Sign structured data with a user’s embedded Ethereum wallet using EIP-712. Typed data signing provides better UX and security by displaying human-readable structured information instead of raw hex strings.
This method uses Ethereum’s eth_signTypedData_v4 RPC method, implementing EIP-712. For simple message signing, see Sign Message.

Overview

EIP-712 (Typed Structured Data Hashing and Signing) enables users to sign structured, human-readable data instead of opaque hex strings. This improves both security and user experience by:
  • Displaying readable data: Users see what they’re actually signing
  • Preventing phishing: Clear structure makes malicious requests more obvious
  • Type safety: Structured format reduces errors
  • Better UX: Wallets can format data nicely in signing modals

Common Use Cases

  • Token permits: Gasless token approvals (ERC-20 Permit)
  • Meta-transactions: Sign transactions for relayers to submit
  • NFT approvals: Approve NFT transfers without gas
  • Governance voting: Structured voting data
  • Delegation: Delegate voting power or permissions

React SDK

  • Basic Usage
  • With Callbacks
  • With UI Customization
To sign typed data, use the signTypedData method from the useSignTypedData hook:
import { useMoonKey } from '@moon-key/react-auth';
import { useSignTypedData } from '@moon-key/react-auth/ethereum';

function SignTypedDataButton() {
  const { user } = useMoonKey();
  const { signTypedData } = useSignTypedData();

  const handleSign = async () => {
    if (!user?.wallet) {
      alert('No wallet found');
      return;
    }

    // Define the EIP-712 typed data
    const typedData = {
      domain: {
        name: 'My dApp',
        version: '1',
        chainId: 1,
        verifyingContract: '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: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826'
        },
        to: {
          name: 'Bob',
          wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'
        },
        contents: 'Hello, Bob!'
      }
    };

    try {
      const result = await signTypedData(typedData);
      console.log('Typed data signed:', result.signature);
      alert('Signed successfully!');
    } catch (error) {
      console.error('Signing failed:', error);
    }
  };

  return (
    <button onClick={handleSign}>
      Sign Typed Data
    </button>
  );
}

EIP-712 Structure

Typed Data Format

The typed data object follows the EIP-712 specification:
domain
object
required
The domain separator defining the context of the signature.Example:
domain: {
  name: 'My dApp',
  version: '1',
  chainId: 1,
  verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC'
}
types
object
required
The type definitions for the structured data. Must include EIP712Domain and your custom types.Example:
types: {
  Person: [
    { name: 'name', type: 'string' },
    { name: 'wallet', type: 'address' }
  ],
  Mail: [
    { name: 'from', type: 'Person' },
    { name: 'to', type: 'Person' },
    { name: 'contents', type: 'string' }
  ]
}
primaryType
string
required
The primary type to sign from the types definition.Example:
primaryType: 'Mail'
message
object
required
The actual data to sign, matching the structure of the primaryType.Example:
message: {
  from: {
    name: 'Alice',
    wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826'
  },
  to: {
    name: 'Bob',
    wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'
  },
  contents: 'Hello, Bob!'
}

Supported Types

EIP-712 supports various Solidity types:
  • Basic types: uint8 through uint256, int8 through int256, bool, address, bytes1 through bytes32, bytes, string
  • Arrays: uint256[], string[], etc.
  • Custom types: References to other defined types

Options Object

The second parameter is an optional configuration object:
uiConfig
object
Configuration for the signing confirmation modal UI.
wallet
Wallet
Specific wallet to use for signing. If not provided, uses the user’s default wallet.

Hook Callbacks

onSuccess
(result: { signature: string }) => void
Callback executed after successful signing. Receives the signature.
onError
(error: Error) => void
Callback executed if signing fails or user cancels.

Returns

signature
string
The hex-encoded signature produced by the wallet.
encoding
'hex'
The encoding format of the signature.

Complete Examples

ERC-20 Permit (Gasless Approval)

'use client';
import { useMoonKey } from '@moon-key/react-auth';
import { useSignTypedData } from '@moon-key/react-auth/ethereum';
import { useState } from 'react';

export default function ERC20Permit() {
  const { user } = useMoonKey();
  const [isApproving, setIsApproving] = useState(false);

  const { signTypedData } = useSignTypedData({
    onSuccess: async ({ signature }) => {
      console.log('Permit signature:', signature);

      // Submit permit to backend or contract
      try {
        await fetch('/api/submit-permit', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            owner: user?.wallet?.address,
            signature,
            tokenAddress: '0xTokenAddress...'
          })
        });

        alert('Approval successful (no gas used)!');
      } catch (error) {
        console.error('Permit submission failed:', error);
      } finally {
        setIsApproving(false);
      }
    },
    onError: (error) => {
      console.error('Permit failed:', error);
      setIsApproving(false);
    }
  });

  const handlePermit = async () => {
    if (!user?.wallet) return;

    setIsApproving(true);

    const tokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; // USDC
    const spenderAddress = '0xSpenderContractAddress...';
    const value = '1000000000'; // 1000 USDC (6 decimals)
    const nonce = 0; // Get from token contract
    const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now

    const typedData = {
      domain: {
        name: 'USD Coin',
        version: '2',
        chainId: 1,
        verifyingContract: tokenAddress
      },
      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: user.wallet.address,
        spender: spenderAddress,
        value,
        nonce,
        deadline
      }
    };

    await signTypedData(typedData, {
      uiConfig: {
        title: 'Approve USDC',
        description: 'Sign to approve USDC spending (no gas required)',
        confirmButtonText: 'Approve'
      }
    });
  };

  return (
    <button
      onClick={handlePermit}
      disabled={isApproving || !user?.wallet}
      className="permit-button"
    >
      {isApproving ? 'Approving...' : 'Approve USDC (Gasless)'}
    </button>
  );
}

UI Customization

Customize the signing modal:
await signTypedData(typedData, {
  uiConfig: {
    title: 'Sign Permit',
    description: 'Approve token spending without gas fees',
    confirmButtonText: 'Sign Permit',
    cancelButtonText: 'Cancel'
  }
});

Hide Modal

await signTypedData(typedData, {
  uiConfig: {
    showWalletUI: false
  }
});
Hiding the modal removes the user’s ability to review the structured data before signing. Only use this for trusted operations or after obtaining explicit user consent.

Best Practices

The domain separator prevents signature replay across chains and contracts:
// ✅ Good - Includes full domain
domain: {
  name: 'My dApp',
  version: '1',
  chainId: 1,
  verifyingContract: '0xContractAddress...'
}

// ❌ Bad - Missing critical fields
domain: {
  name: 'My dApp'
}
Always include expiration to prevent old signatures from being reused:
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour

message: {
  // ... other fields
  deadline
}

// Verify expiration on backend
if (Date.now() / 1000 > deadline) {
  throw new Error('Signature expired');
}
Use nonces to prevent replay attacks:
// Get nonce from contract or database
const nonce = await getNonce(userAddress);

message: {
  // ... other fields
  nonce
}

// Increment nonce after use to prevent replay
await incrementNonce(userAddress);
Always verify signatures server-side:
// ❌ Bad - Only client-side verification
const isValid = await verifyTypedData({ ... });
if (isValid) {
  proceedWithAction(); // Insecure!
}

// ✅ Good - Backend verification
const response = await fetch('/api/verify-signature', {
  method: 'POST',
  body: JSON.stringify({ signature, typedData })
});
const { valid } = await response.json();
if (valid) {
  proceedWithAction(); // Secure!
}
Follow established standards (ERC-20 Permit, EIP-2612):
// ✅ Use standard Permit type
types: {
  Permit: [
    { name: 'owner', type: 'address' },
    { name: 'spender', type: 'address' },
    { name: 'value', type: 'uint256' },
    { name: 'nonce', type: 'uint256' },
    { name: 'deadline', type: 'uint256' }
  ]
}
Provide clear error messages for common issues:
const { signTypedData } = useSignTypedData({
  onError: (error: any) => {
    if (error.code === 'USER_REJECTED') {
      // User cancelled - don't show error
      return;
    } else if (error.message?.includes('expired')) {
      alert('Permit has expired. Please try again.');
    } else if (error.message?.includes('nonce')) {
      alert('Invalid nonce. Please refresh and try again.');
    } else {
      alert('Failed to sign: ' + error.message);
    }
  }
});

Troubleshooting

Common causes:
  • Type mismatch: Ensure types match exactly between signing and verification
  • Wrong domain: Domain must match exactly (including chainId)
  • Incorrect primaryType: Must reference a type in the types object
  • Message structure mismatch: Message must match primaryType structure
// Ensure exact match
const typedData = { domain, types, primaryType, message };

// Sign
const { signature } = await signTypedData(typedData);

// Verify with SAME typedData
const isValid = await verifyTypedData({
  address: signerAddress,
  ...typedData,
  signature
});
Ensure all types are properly defined:
// ✅ Correct - All referenced types defined
types: {
  Person: [
    { name: 'name', type: 'string' },
    { name: 'wallet', type: 'address' }
  ],
  Mail: [
    { name: 'from', type: 'Person' }, // Person is defined
    { name: 'to', type: 'Person' },
    { name: 'contents', type: 'string' }
  ]
}

// ❌ Wrong - Person not defined
types: {
  Mail: [
    { name: 'from', type: 'Person' }, // Error: Person undefined
    { name: 'contents', type: 'string' }
  ]
}
Ensure chainId matches the network:
// Get current chain from wallet
const chainId = await walletClient.getChainId();

// Use in domain
domain: {
  name: 'My dApp',
  version: '1',
  chainId // Must match current network
}
Configure callbacks on hook initialization:
// ✅ Correct
const { signTypedData } = useSignTypedData({
  onSuccess: ({ signature }) => {
    console.log('Success:', signature);
  },
  onError: (error) => {
    console.log('Error:', error);
  }
});

// Then call it
await signTypedData(typedData);

Next Steps