- Published on
EIP-712: 구조화된 데이터 서명 표준
- Authors

- Name
- Aaron
EIP-712란?
EIP-712(Ethereum Improvement Proposal 712)는 오프체인 메시지 서명(off-chain message signing)의 사용성을 개선하는 표준입니다.
오프체인 서명은 가스비를 절약하기 위해 블록체인 밖에서 서명을 생성하고, 나중에 온체인에서 검증하는 방식입니다. EIP-712는 구조화된 데이터(Typed Data)를 사용하여 사용자가 서명하는 내용을 명확히 확인할 수 있도록 합니다.
왜 필요한가?
현재 서명된 메시지는 hex 문자열 로 사용자에게 표시됩니다. 메시지를 구성하는 항목들에 대한 컨텍스트가 없습니다
// ❌ 기존 방식: hex 문자열
"0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8"
// 무엇에 서명하는지 알 수 없음
문제점
- 사용자가 서명하는 데이터가 일련의 바이트로만 표시됨
- 메시지 내용을 검증할 수 없음
- 악의적인 dApp이 사용자를 속일 수 있음
EIP-712의 해결책: 구조화된 데이터 (Typed Data)
// ✅ 구조화되고 명확한 형태
// 각 필드의 의미가 명확하고, 기계적으로 검증 가능
{
to: "0x1234...",
amount: "100 ETH",
nonce: 5,
deadline: "2025-10-25 12:00:00"
}
EIP-712의 핵심: Typed Data
Typed Data는 타입 정보를 포함하는 구조화된 데이터입니다:
- 각 필드의 이름과 타입이 명확히 정의됨 (
address,uint256,string등) - 개발자가 데이터를 쉽게 파싱하고 검증 가능
- 사용자가 서명 전에 정확한 내용을 확인 가능
오프체인 서명 + 온체인 검증
EIP-712의 핵심은 서명은 오프체인에서 생성하고, 검증은 온체인에서 수행한다는 것입니다.
게임 아이템 클레임 예시
- 게임 서버가 "플레이어 A에게 아이템 X 지급" 서명 생성 (오프체인, 무료)
- 플레이어가 지갑에서 내용 확인 후 승인
- 플레이어가 블록체인에 서명 제출 (온체인, 가스비 발생)
- 컨트랙트가 서명 검증 후 아이템 지급
이 방식이 작동하려면 디지털 서명에 대한 이해가 필요합니다.
디지털 서명의 작동 원리
블록체인 서명은 프라이빗 키와 공개키 암호화를 사용합니다.
이제 EIP-712가 이 서명을 어떻게 구조화하는지 알아봅시다.
EIP-712의 구조
3가지 핵심 구성 요소
1. Domain (도메인) - 어디서 사용하는 서명인가?
서명이 어떤 애플리케이션의 어떤 체인에서 사용되는지 명시합니다.
const domain = {
name: 'GameItemClaim', // 앱 이름
version: '1', // 버전
chainId: 1, // 이더리움 메인넷 (1)
verifyingContract: '0x...', // 이 서명을 검증할 컨트랙트 주소
}
왜 필요한가?
- 재사용 공격 방지: 같은 서명이 다른 앱이나 다른 체인에서 재사용되는 것을 막습니다
- 예시: A 게임의 아이템 서명이 B 게임에서 사용되는 것을 방지
- 예시: 메인넷 서명이 테스트넷에서 사용되는 것을 방지
2. Types (타입) - 무엇을 서명하는가?
서명할 데이터의 구조(스키마)를 정의합니다. 일종의 데이터 청사진입니다.
const types = {
ClaimItem: [
// 각 필드의 이름과 타입을 명시
{ name: 'player', type: 'address' }, // 누구에게
{ name: 'itemId', type: 'uint256' }, // 어떤 아이템을
{ name: 'quantity', type: 'uint256' }, // 몇 개
{ name: 'nonce', type: 'uint256' }, // 재사용 방지 번호
{ name: 'deadline', type: 'uint256' }, // 언제까지 유효한지
],
}
왜 필요한가?
- 일관된 해싱: 서버와 컨트랙트가 동일한 방식으로 데이터를 해싱하도록 보장
- 타입 안전성: 각 필드의 타입이 명확히 정의되어 오류 방지
- 사용자 확인: 지갑이 사용자에게 구조화된 형태로 데이터를 보여줄 수 있음
3. Value (값) - 실제 데이터는 무엇인가?
Types에서 정의한 구조에 맞는 실제 값입니다.
const value = {
player: '0x1234...5678', // 플레이어 주소
itemId: 42, // 전설의 검 (아이템 ID 42)
quantity: 1, // 1개
nonce: 0, // 이 플레이어의 첫 번째 서명
deadline: 1729843200, // 2024-10-25 12:00:00 (유닉스 타임스탬프)
}
실제 의미: "플레이어 0x1234...5678에게 아이템 42를 1개 지급합니다. 이는 0번째 서명이며, 2024년 10월 25일까지 유효합니다."
세 요소를 함께 사용하여 서명 생성
이 3가지는 반드시 함께 사용되어야 합니다. 하나의 서명을 만들기 위해 모두 필요합니다.
// ✅ 세 요소를 모두 함께 전달
const signature = await wallet.signTypedData(
domain, // 어디서 (어떤 앱, 어떤 체인)
types, // 무엇을 (데이터 구조)
value // 실제 데이터
)
// 결과: "0x1a2b3c4d..." (서명)
왜 세 개를 모두 사용하나?
- Domain: 서명의 범위(scope) 한정 - 다른 곳에서 재사용 불가
- Types: 데이터 구조 정의 - 서버와 컨트랙트가 같은 방식으로 해싱
- Value: 실제 내용 - 구체적으로 무엇을 승인하는지
이 세 가지가 결합되어 고유하고, 안전하며, 검증 가능한 서명을 만듭니다.
스마트 컨트랙트에서 검증
// 1️⃣ Value 데이터를 해시로 변환
// Types에 정의된 구조대로 데이터를 인코딩하고 해시 생성
bytes32 structHash = keccak256(abi.encode(
CLAIM_TYPEHASH, // "ClaimItem(address player,uint256 itemId,...)"
player, // 0x1234...5678
itemId, // 42
quantity, // 1
nonce, // 0
deadline // 1729843200
));
// 결과: structHash = 0xabc123... (데이터 해시)
// 2️⃣ Domain과 결합하여 최종 해시 생성
// EIP-712 표준 방식으로 도메인 정보를 포함한 최종 해시 생성
bytes32 hash = _hashTypedDataV4(structHash);
// 이 함수는 내부적으로:
// - 도메인 분리자(name, version, chainId, verifyingContract)
// - structHash
// 를 결합하여 고유한 해시를 만듭니다
// 3️⃣ 서명에서 서명자의 주소 복구
address signer = ecrecover(hash, signature);
// ecrecover가 서명을 분석하여 서명자의 주소를 알아냅니다
// 결과: signer = 0x789... (서명한 사람의 주소)
// 4️⃣ 복구된 주소가 신뢰하는 주소인지 검증
require(signer == gameServer, "Invalid signature");
// gameServer = 0x789... (사전에 등록된 게임 서버의 주소)
// 일치하면 ✅ 유효한 서명! → 아이템 지급
// 불일치하면 ❌ 거부됨
EIP-712의 실제 활용 사례
1. 게임 아이템 클레임
시나리오: 플레이어가 보스를 처치하면 서버가 서명을 발급하고, 플레이어가 블록체인에서 아이템을 수령합니다.
장점:
- 서버가 모든 플레이어에게 아이템을 전송할 필요 없음
- 플레이어가 원할 때 claim (가스비 절약)
- 복잡한 게임 로직은 서버에서 처리
2. 에어드랍 (Airdrop)
시나리오: 프로젝트가 1만 명에게 토큰을 나눠줄 때
기존 방식:
- ❌ 1만 번의 트랜잭션 필요
- ❌ 엄청난 가스비
EIP-712 방식:
- ✅ 서명만 발급
- ✅ 유저가 원할 때 claim
- ✅ 가스비는 유저가 부담
3. 화이트리스트 민팅
시나리오: 특정 유저만 NFT를 특별 가격에 민팅
function mintWithSignature(
address to,
uint256 price,
bytes memory signature
) external payable {
// 서명 검증 → 화이트리스트 확인
require(verify(to, price, signature), "Not whitelisted");
require(msg.value == price, "Wrong price");
_mint(to, tokenId);
}
4. 가스리스 트랜잭션 (Meta Transaction)
시나리오: 유저가 가스비를 내지 않아도 트랜잭션 실행
- 유저: 서명 생성 (무료)
- 릴레이어: 트랜잭션 전송 (가스비 부담)
- 컨트랙트: 서명 검증 후 실행
게임 아이템 클레임 시스템 예시
시스템 흐름
- 플레이어: 게임에서 보스 처치 🗡️
- 게임 서버: 처치 확인 후 프라이빗 키로 서명 발급 ✍️
- 플레이어: 서명으로 블록체인에서 아이템 수령 📝
- 스마트 컨트랙트: ecrecover()로 서명 검증 후 아이템 지급 ✅
서버 구현 (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 도메인 (컨트랙트와 동일해야 함)
const domain = {
name: 'GameItemClaim',
version: '1',
chainId: 1,
verifyingContract: '0x...', // 컨트랙트 주소
}
// EIP-712 타입 (컨트랙트와 동일해야 함)
const types = {
ClaimItem: [
{ name: 'player', type: 'address' },
{ name: 'itemId', type: 'uint256' },
{ name: 'quantity', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
}
// API: 보스 처치 시 보상 서명 발급
app.post('/api/claim-boss-reward', async (req, res) => {
const { playerAddress, bossId } = req.body
try {
// 1. 게임 DB에서 보스 처치 확인
const killed = await checkBossKilled(playerAddress, bossId)
if (!killed) {
return res.status(403).json({ error: 'Boss not killed' })
}
// 2. 보상 아이템 결정
const reward = getBossReward(bossId)
// 3. 블록체인에서 현재 논스 가져오기
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. 마감 시간 설정 (24시간)
const deadline = Math.floor(Date.now() / 1000) + 86400
// 5. 서명할 데이터
const value = {
player: playerAddress,
itemId: reward.itemId,
quantity: reward.quantity,
nonce: nonce.toString(),
deadline: deadline,
}
// 6. EIP-712 서명 생성
const signature = await gameServerWallet.signTypedData(domain, types, value)
// 7. DB에 기록 (중복 클레임 방지)
await markRewardClaimed(playerAddress, bossId)
// 8. 클라이언트에 서명 전달
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' })
}
})
// 보스별 보상 정의
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')
})
스마트 컨트랙트
// 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;
// 게임 서버의 공개 주소 (서명자)
address public gameServer;
// 사용자별 논스 (재사용 방지)
mapping(address => uint256) public nonces;
// 아이템 소유 현황
mapping(address => mapping(uint256 => uint256)) public itemBalances;
// EIP-712 타입 해시
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. 기본 검증
require(msg.sender == player, "Not authorized");
require(block.timestamp <= deadline, "Signature expired");
// 2. EIP-712 구조화된 데이터 해시 생성
bytes32 structHash = keccak256(abi.encode(
CLAIM_TYPEHASH,
player,
itemId,
quantity,
nonces[player],
deadline
));
// 3. 최종 해시 (도메인 분리자 포함)
bytes32 hash = _hashTypedDataV4(structHash);
// 4. 서명 검증
address signer = hash.recover(signature);
require(signer == gameServer, "Invalid signature");
// 5. 논스 증가 (같은 서명 재사용 방지)
nonces[player]++;
// 6. 아이템 지급
itemBalances[player][itemId] += quantity;
emit ItemClaimed(player, itemId, quantity);
}
function getItemBalance(address player, uint256 itemId)
external
view
returns (uint256)
{
return itemBalances[player][itemId];
}
}
클라이언트 구현
import { ethers } from 'ethers'
// 보스 처치 후 보상 수령
async function claimBossReward(bossId) {
try {
// 1. 서버에서 서명 요청
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. 블록체인에 트랜잭션 전송
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. UI 업데이트
showSuccessMessage(`You received ${reward.itemName}!`)
} catch (error) {
console.error('Error claiming reward:', error)
showErrorMessage('Failed to claim reward')
}
}
// 사용 예시
document.getElementById('claim-btn').addEventListener('click', () => {
claimBossReward(3) // 보스 ID 3의 보상 수령
})
장점과 단점
✅ 장점
1. 가스비 최적화
- 서버가 모든 유저에게 일일이 전송 ❌
- 유저가 필요할 때만 claim ✅
2. 유연한 로직
- 복잡한 계산은 서버에서 처리
- 블록체인은 검증만 수행
3. 확장성
- 서버에서 다양한 조건 체크 가능
- 블록체인은 최종 상태만 기록
4. 사용자 경험
- 읽을 수 있는 형태로 서명 내용 확인
- 무엇에 서명하는지 명확히 알 수 있음
⚠️ 단점
1. 서버 의존성
- 서버가 다운되면 서명 발급 불가
- 중앙화된 요소 존재
2. 키 관리 부담
- 서버 프라이빗 키 유출 시 심각한 피해
- 안전한 키 관리 시스템 필요
3. 서명 만료 관리
- deadline 이후에는 사용 불가
- 사용자가 제때 claim하지 않으면 재발급 필요