- Authors
What is EIP-712?
EIP-712 (Ethereum Improvement Proposal 712) is a standard that improves the usability of off-chain message signing.
Off-chain signing is a method where signatures are generated outside the blockchain to save gas fees, then verified on-chain later. EIP-712 uses structured data (Typed Data) so users can clearly confirm what they are signing.
Why is it Needed?
Currently, signed messages are displayed to users as hex strings. There's no context about the items that make up the message.
// ❌ Traditional method: hex string
"0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8"
// Cannot know what you're signing
Problems
- Data being signed is displayed only as a series of bytes
- Cannot verify message contents
- Malicious dApps can deceive users
EIP-712's Solution: Structured Data (Typed Data)
// ✅ Structured and clear format
// The meaning of each field is clear and machine-verifiable
{
to: "0x1234...",
amount: "100 ETH",
nonce: 5,
deadline: "2025-10-25 12:00:00"
}
The Core of EIP-712: Typed Data
Typed Data is structured data that includes type information:
- Each field's name and type are clearly defined (
address,uint256,string, etc.) - Developers can easily parse and verify data
- Users can confirm exact contents before signing
Off-chain Signing + On-chain Verification
The key of EIP-712 is that signatures are generated off-chain, and verification is performed on-chain.
Game item claim example:
- Game server generates "Give item X to player A" signature (off-chain, free)
- Player reviews and approves content in wallet
- Player submits signature to blockchain (on-chain, gas fee incurred)
- Contract verifies signature and grants item
For this to work, an understanding of digital signatures is needed.
How Digital Signatures Work
Blockchain signatures use private keys and public key cryptography.
Now let's see how EIP-712 structures these signatures.
Structure of EIP-712
3 Core Components
1. Domain - Where is this signature used?
Specifies which application and which chain the signature is used on.
const domain = {
name: 'GameItemClaim', // App name
version: '1', // Version
chainId: 1, // Ethereum Mainnet (1)
verifyingContract: '0x...', // Contract address that will verify this signature
}
Why is it needed?
- Replay attack prevention: Prevents the same signature from being reused on different apps or chains
- Example: Prevents item signatures from Game A being used in Game B
- Example: Prevents mainnet signatures from being used on testnet
2. Types - What is being signed?
Defines the structure (schema) of the data to be signed. It's a kind of data blueprint.
const types = {
ClaimItem: [
// Specify each field's name and type
{ name: 'player', type: 'address' }, // To whom
{ name: 'itemId', type: 'uint256' }, // Which item
{ name: 'quantity', type: 'uint256' }, // How many
{ name: 'nonce', type: 'uint256' }, // Replay prevention number
{ name: 'deadline', type: 'uint256' }, // Valid until when
],
}
Why is it needed?
- Consistent hashing: Ensures server and contract hash data the same way
- Type safety: Each field's type is clearly defined to prevent errors
- User confirmation: Wallet can show users data in a structured format
3. Value - What is the actual data?
The actual values matching the structure defined in Types.
const value = {
player: '0x1234...5678', // Player address
itemId: 42, // Legendary Sword (item ID 42)
quantity: 1, // 1 piece
nonce: 0, // This player's first signature
deadline: 1729843200, // 2024-10-25 12:00:00 (Unix timestamp)
}
Actual meaning: "Grant 1 item 42 to player 0x1234...5678. This is the 0th signature and is valid until October 25, 2024."
Using All Three Elements Together to Generate a Signature
These 3 elements must be used together. All are needed to create a single signature.
// ✅ Pass all three elements together
const signature = await wallet.signTypedData(
domain, // Where (which app, which chain)
types, // What (data structure)
value // Actual data
)
// Result: "0x1a2b3c4d..." (signature)
Why use all three?
- Domain: Limits the scope of the signature - cannot be reused elsewhere
- Types: Defines data structure - server and contract hash the same way
- Value: Actual content - specifically what is being approved
These three combine to create a unique, secure, and verifiable signature.
Verification in Smart Contract
// 1️⃣ Convert Value data to hash
// Encode data according to structure defined in Types and generate hash
bytes32 structHash = keccak256(abi.encode(
CLAIM_TYPEHASH, // "ClaimItem(address player,uint256 itemId,...)"
player, // 0x1234...5678
itemId, // 42
quantity, // 1
nonce, // 0
deadline // 1729843200
));
// Result: structHash = 0xabc123... (data hash)
// 2️⃣ Combine with Domain to generate final hash
// Generate final hash including domain information using EIP-712 standard method
bytes32 hash = _hashTypedDataV4(structHash);
// This function internally combines:
// - Domain separator (name, version, chainId, verifyingContract)
// - structHash
// to create a unique hash
// 3️⃣ Recover signer's address from signature
address signer = ecrecover(hash, signature);
// ecrecover analyzes the signature to find the signer's address
// Result: signer = 0x789... (address of person who signed)
// 4️⃣ Verify if recovered address is a trusted address
require(signer == gameServer, "Invalid signature");
// gameServer = 0x789... (pre-registered game server address)
// If match ✅ Valid signature! → Grant item
// If mismatch ❌ Rejected
Real-World Use Cases for EIP-712
1. Game Item Claims
Scenario: When a player defeats a boss, the server issues a signature, and the player claims the item on the blockchain.
Benefits:
- Server doesn't need to send items to every player
- Players claim when they want (gas fee savings)
- Complex game logic is handled on the server
2. Airdrops
Scenario: When a project distributes tokens to 10,000 people
Traditional method:
- ❌ 10,000 transactions required
- ❌ Enormous gas fees
EIP-712 method:
- ✅ Only issue signatures
- ✅ Users claim when they want
- ✅ Users bear the gas fees
3. Whitelist Minting
Scenario: Only specific users can mint NFTs at a special price
function mintWithSignature(
address to,
uint256 price,
bytes memory signature
) external payable {
// Signature verification → Whitelist check
require(verify(to, price, signature), "Not whitelisted");
require(msg.value == price, "Wrong price");
_mint(to, tokenId);
}
4. Gasless Transactions (Meta Transactions)
Scenario: Users can execute transactions without paying gas fees
- User: Generate signature (free)
- Relayer: Submit transaction (bears gas fee)
- Contract: Verify signature and execute
Game Item Claim System Example
System Flow
- Player: Defeats boss in game 🗡️
- Game Server: Confirms kill and issues signature with private key ✍️
- Player: Claims item on blockchain with signature 📝
- Smart Contract: Verifies signature with ecrecover() and grants item ✅
Server Implementation (Node.js)
const express = require('express')
const { ethers } = require('ethers')
const app = express()
const gameServerWallet = new ethers.Wallet(process.env.GAME_SERVER_PRIVATE_KEY)
// EIP-712 domain (must match contract)
const domain = {
name: 'GameItemClaim',
version: '1',
chainId: 1,
verifyingContract: '0x...', // Contract address
}
// EIP-712 types (must match contract)
const types = {
ClaimItem: [
{ name: 'player', type: 'address' },
{ name: 'itemId', type: 'uint256' },
{ name: 'quantity', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
}
// API: Issue reward signature on boss kill
app.post('/api/claim-boss-reward', async (req, res) => {
const { playerAddress, bossId } = req.body
try {
// 1. Verify boss kill in game DB
const killed = await checkBossKilled(playerAddress, bossId)
if (!killed) {
return res.status(403).json({ error: 'Boss not killed' })
}
// 2. Determine reward item
const reward = getBossReward(bossId)
// 3. Get current nonce from blockchain
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL)
const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, provider)
const nonce = await contract.nonces(playerAddress)
// 4. Set deadline (24 hours)
const deadline = Math.floor(Date.now() / 1000) + 86400
// 5. Data to sign
const value = {
player: playerAddress,
itemId: reward.itemId,
quantity: reward.quantity,
nonce: nonce.toString(),
deadline: deadline,
}
// 6. Generate EIP-712 signature
const signature = await gameServerWallet.signTypedData(domain, types, value)
// 7. Record in DB (prevent duplicate claims)
await markRewardClaimed(playerAddress, bossId)
// 8. Send signature to client
res.json({
player: playerAddress,
itemId: reward.itemId,
itemName: reward.name,
quantity: reward.quantity,
nonce: nonce.toString(),
deadline: deadline,
signature: signature,
})
} catch (error) {
console.error('Error generating signature:', error)
res.status(500).json({ error: 'Failed to generate signature' })
}
})
// Boss reward definitions
function getBossReward(bossId) {
const rewards = {
1: { itemId: 101, name: 'Iron Sword', quantity: 1 },
2: { itemId: 102, name: 'Steel Shield', quantity: 1 },
3: { itemId: 103, name: 'Legendary Sword', quantity: 1 },
}
return rewards[bossId]
}
app.listen(3000, () => {
console.log('Game server running on port 3000')
})
Smart Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract GameItemClaim is EIP712 {
using ECDSA for bytes32;
// Game server's public address (signer)
address public gameServer;
// Per-user nonce (replay prevention)
mapping(address => uint256) public nonces;
// Item ownership status
mapping(address => mapping(uint256 => uint256)) public itemBalances;
// EIP-712 type hash
bytes32 public constant CLAIM_TYPEHASH = keccak256(
"ClaimItem(address player,uint256 itemId,uint256 quantity,uint256 nonce,uint256 deadline)"
);
event ItemClaimed(address indexed player, uint256 itemId, uint256 quantity);
constructor(address _gameServer) EIP712("GameItemClaim", "1") {
gameServer = _gameServer;
}
function claimItem(
address player,
uint256 itemId,
uint256 quantity,
uint256 deadline,
bytes memory signature
) external {
// 1. Basic validation
require(msg.sender == player, "Not authorized");
require(block.timestamp <= deadline, "Signature expired");
// 2. Generate EIP-712 structured data hash
bytes32 structHash = keccak256(abi.encode(
CLAIM_TYPEHASH,
player,
itemId,
quantity,
nonces[player],
deadline
));
// 3. Final hash (including domain separator)
bytes32 hash = _hashTypedDataV4(structHash);
// 4. Verify signature
address signer = hash.recover(signature);
require(signer == gameServer, "Invalid signature");
// 5. Increment nonce (prevent same signature reuse)
nonces[player]++;
// 6. Grant item
itemBalances[player][itemId] += quantity;
emit ItemClaimed(player, itemId, quantity);
}
function getItemBalance(address player, uint256 itemId)
external
view
returns (uint256)
{
return itemBalances[player][itemId];
}
}
Client Implementation
import { ethers } from 'ethers'
// Claim reward after boss kill
async function claimBossReward(bossId) {
try {
// 1. Request signature from server
const response = await fetch('/api/claim-boss-reward', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playerAddress: account,
bossId: bossId,
}),
})
if (!response.ok) {
throw new Error('Failed to get signature')
}
const reward = await response.json()
console.log(`🎁 Reward: ${reward.itemName} x${reward.quantity}`)
// 2. Send transaction to blockchain
const provider = new ethers.BrowserProvider(window.ethereum)
const signer = await provider.getSigner()
const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, signer)
console.log('📝 Claiming on blockchain...')
const tx = await contract.claimItem(
reward.player,
reward.itemId,
reward.quantity,
reward.deadline,
reward.signature
)
console.log('⏳ Waiting for confirmation...')
await tx.wait()
console.log('✅ Item claimed successfully!')
// 3. Update UI
showSuccessMessage(`You received ${reward.itemName}!`)
} catch (error) {
console.error('Error claiming reward:', error)
showErrorMessage('Failed to claim reward')
}
}
// Usage example
document.getElementById('claim-btn').addEventListener('click', () => {
claimBossReward(3) // Claim reward for boss ID 3
})
Pros and Cons
✅ Pros
1. Gas Fee Optimization
- Server sending to every user individually ❌
- Users claim only when needed ✅
2. Flexible Logic
- Complex calculations handled on server
- Blockchain only performs verification
3. Scalability
- Various conditions can be checked on server
- Blockchain only records final state
4. User Experience
- Signature contents visible in readable format
- Users clearly know what they're signing
⚠️ Cons
1. Server Dependency
- Signatures cannot be issued if server is down
- Centralized element exists
2. Key Management Burden
- Serious damage if server private key is leaked
- Secure key management system needed
3. Signature Expiry Management
- Cannot use after deadline
- Re-issuance needed if users don't claim in time