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
The typed data object follows the EIP-712 specification:
The domain separator defining the context of the signature. The name of the dApp or protocol.
The version of the signing domain.
The chain ID where the signature is valid.
The address of the contract that will verify the signature.
Optional salt value for additional uniqueness.
Example: domain : {
name : 'My dApp' ,
version : '1' ,
chainId : 1 ,
verifyingContract : '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC'
}
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' }
]
}
The primary type to sign from the types definition. Example:
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:
Configuration for the signing confirmation modal UI. Custom title for the confirmation modal.
Description text shown in the modal.
Text for the confirm button.
Text for the cancel button.
Whether to show the confirmation modal. Set to false to sign without confirmation.
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.
Callback executed if signing fails or user cancels.
Returns
The hex-encoded signature produced by the wallet.
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
Always include domain separator
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' );
}
Include nonces for uniqueness
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!
}
Use standard type definitions
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
Signature verification fails
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