학습/EIP-712 서명

EIP-712 서명

EIP-712는 이더리움의 타입화된 구조적 데이터 해싱 및 서명 표준입니다. x402에서는 USDC의 transferWithAuthorization 함수를 호출하기 위한 서명을 생성하는 데 사용됩니다.

왜 EIP-712인가?

기존 서명 방식의 문제

eth_sign("0x9a8b7c6d5e...")

// 사용자가 보는 것:
// "서명하시겠습니까?"
// 0x9a8b7c6d5e4f3a2b...
//
// 무엇에 서명하는지 알 수 없음!

일반 해시 서명은 사용자에게 의미 없는 16진수만 보여줍니다. 악의적인 트랜잭션에 서명할 위험이 있습니다.

EIP-712의 해결책

eth_signTypedData_v4

// 사용자가 보는 것:
// Domain: USDC
// Action: Transfer With Authorization
// From: 0x390e4Ce3...
// To: 0x742d35Cc...
// Amount: 0.01 USDC
//
// 정확히 무엇에 서명하는지 확인 가능!

구조화된 데이터를 사람이 읽을 수 있는 형태로 보여줍니다. 서명 전에 정확한 내용을 확인할 수 있습니다.

EIP-712 구조

1. Domain Separator

서명이 어떤 컨트랙트/애플리케이션을 위한 것인지 식별합니다. 다른 앱의 서명을 재사용하는 것을 방지합니다.

// USDC의 EIP-712 Domain Separator
// 이 정보는 서명이 "어떤 컨트랙트를 위한 것인지" 명시합니다.
// 다른 앱에서 만든 서명을 재사용하는 크로스-프로토콜 공격을 방지합니다.
{
  name: "USD Coin",                          // USDC 컨트랙트의 공식 이름
                                             // "USDC"가 아닌 "USD Coin"이어야 함!
  version: "2",                              // USDC 컨트랙트 버전 (Circle이 정의)
  chainId: 84532,                            // 체인 ID (Base Sepolia 테스트넷)
                                             // Base Mainnet은 8453
  verifyingContract: "0x036CbD53..."         // USDC 컨트랙트 배포 주소
                                             // 반드시 해당 체인의 공식 주소 사용
}

주의: Domain의 name과 version은 반드시 USDC 컨트랙트에 정의된 값과 일치해야 합니다. 잘못된 값을 사용하면 서명이 무효화됩니다.

2. Type Definitions

서명할 데이터의 구조를 정의합니다. USDC의 transferWithAuthorization은 아래 타입을 사용합니다.

// EIP-712 타입 정의
// 서명할 데이터의 "스키마"를 정의합니다.
// USDC 컨트랙트가 기대하는 정확한 필드명과 타입을 사용해야 합니다.
const types = {
  // "TransferWithAuthorization" - USDC의 가스리스 전송 함수 이름
  TransferWithAuthorization: [
    { name: "from", type: "address" },       // 토큰을 보내는 지갑 주소
    { name: "to", type: "address" },         // 토큰을 받는 지갑 주소
    { name: "value", type: "uint256" },      // 전송할 금액 (6 decimals)
    { name: "validAfter", type: "uint256" }, // 서명 유효 시작 시간
    { name: "validBefore", type: "uint256" },// 서명 만료 시간
    { name: "nonce", type: "bytes32" }       // 중복 사용 방지용 고유 식별자
  ]
};

3. Message (실제 데이터)

서명할 실제 값입니다. 타입 정의에 맞춰 작성해야 합니다.

// 실제 서명할 메시지 데이터
// 이 값들이 사용자의 지갑에 표시되어 서명 전 확인할 수 있습니다.
const message = {
  from: "0x390e4Ce3...",     // 보내는 사람 주소 (서명자 본인의 지갑)
                             // 이 주소에서 USDC가 차감됩니다

  to: "0x742d35Cc...",       // 받는 사람 주소 (콘텐츠 제공자의 지갑)
                             // 이 주소로 USDC가 입금됩니다

  value: "10000",            // 전송할 금액 (단위: 마이크로 달러)
                             // USDC는 6 decimals이므로:
                             // 10000 = $0.01 (1센트)
                             // 1000000 = $1.00 (1달러)

  validAfter: 0,             // 서명 유효 시작 시간 (Unix timestamp)
                             // 0 = 즉시 유효 (제한 없음)
                             // 특정 시간 이후에만 실행되게 하려면 값 지정

  validBefore: 1768474060,   // 서명 만료 시간 (Unix timestamp)
                             // 이 시간이 지나면 서명이 무효화됩니다
                             // 보안을 위해 현재시간 + 60초 권장

  nonce: "0xabc123..."       // 32바이트 고유 식별자
                             // 같은 서명의 재사용(replay attack)을 방지
                             // crypto.randomBytes(32)로 생성 권장
};

코드 예제

import { ethers, Signer } from 'ethers';

/**
 * USDC transferWithAuthorization을 위한 EIP-712 서명을 생성합니다.
 * 이 서명은 가스비 없이 USDC를 전송할 수 있게 해줍니다.
 *
 * @param signer - ethers.js v6 Signer 인스턴스
 * @param to - 받는 사람 지갑 주소
 * @param amount - 전송할 금액 (6 decimals, "10000" = $0.01)
 * @param usdcAddress - 해당 체인의 USDC 컨트랙트 주소
 * @param chainId - 블록체인 네트워크 ID (Base Sepolia: 84532)
 */
async function signTransferWithAuthorization(
  signer: Signer,
  to: string,
  amount: string,
  usdcAddress: string,
  chainId: number
) {
  // ========================================
  // 1단계: Domain Separator 정의
  // ========================================
  // 서명이 USDC 컨트랙트용임을 증명합니다.
  // 이 값들은 USDC 컨트랙트에 하드코딩된 값과 일치해야 합니다.
  const domain = {
    name: "USD Coin",          // USDC v2 컨트랙트의 공식 이름
    version: "2",              // USDC 컨트랙트 버전
    chainId: chainId,          // 체인 ID (다른 체인 서명 재사용 방지)
    verifyingContract: usdcAddress  // USDC 컨트랙트 주소
  };

  // ========================================
  // 2단계: Type 정의
  // ========================================
  // USDC 컨트랙트의 transferWithAuthorization 함수가
  // 기대하는 정확한 파라미터 구조입니다.
  const types = {
    TransferWithAuthorization: [
      { name: "from", type: "address" },        // 보내는 사람
      { name: "to", type: "address" },          // 받는 사람
      { name: "value", type: "uint256" },       // 금액
      { name: "validAfter", type: "uint256" },  // 유효 시작
      { name: "validBefore", type: "uint256" }, // 유효 종료
      { name: "nonce", type: "bytes32" }        // 고유 ID
    ]
  };

  // ========================================
  // 3단계: 보안 파라미터 생성
  // ========================================
  // nonce: 32바이트 랜덤 값으로 replay attack 방지
  // ethers v6에서는 ethers.randomBytes() 사용
  const nonce = ethers.hexlify(ethers.randomBytes(32));

  // validBefore: 현재 시간 + 60초
  // 서명이 1분 후 만료되어 도용 위험 최소화
  const validBefore = Math.floor(Date.now() / 1000) + 60;

  // ========================================
  // 4단계: 메시지 구성
  // ========================================
  const signerAddress = await signer.getAddress();
  const message = {
    from: signerAddress,     // 서명자 = 토큰 보내는 사람
    to: to,                  // 파라미터로 받은 수신자
    value: amount,           // 파라미터로 받은 금액
    validAfter: 0,           // 즉시 유효 (시작 제한 없음)
    validBefore: validBefore,// 1분 후 만료
    nonce: nonce             // 랜덤 생성된 고유 ID
  };

  // ========================================
  // 5단계: EIP-712 서명 생성
  // ========================================
  // ethers v6에서는 signer.signTypedData() 사용
  // domain + types + message를 해시하고 서명합니다.
  // 결과: 65바이트 서명 (v, r, s 포함)
  const signature = await signer.signTypedData(domain, types, message);

  // 서명과 메시지 데이터를 함께 반환
  // 서버/퍼실리테이터가 이 정보로 온체인 트랜잭션 실행
  return {
    signature,           // 65바이트 서명 문자열
    authorization: message  // 서명된 메시지 (검증용)
  };
}

해시 계산 과정

1. Domain Separator Hash

domainSeparator = keccak256(encode(EIP712Domain, domain))

2. Struct Hash

structHash = keccak256(encode(typesHash, message))

3. Final Message Hash

messageHash = keccak256("\x19\x01" + domainSeparator + structHash)

4. Signature

(v, r, s) = sign(messageHash, privateKey)

보안 고려사항

Nonce 관리

각 서명에 고유한 nonce를 사용해야 합니다. 같은 nonce를 재사용하면 replay attack에 취약해집니다. 랜덤 바이트 생성을 권장합니다.

시간 제한

validBefore를 현재 시간 + 1분 정도로 설정하세요. 너무 긴 유효 기간은 서명 도용 위험을 높입니다.

금액 확인

서명 전에 반드시 value(금액)를 확인하세요. USDC는 6 decimals를 사용하므로 1000000 = $1.00, 10000 = $0.01입니다.