목록으로

사용자 가스비를 0원으로 만든 이야기

3

사용자 가스비를 0원으로 만든 이야기

2025년 11월 27일 — mint 기능 개발 (첫 블록체인 연동) 2025년 12월 초 — permitAndBurnFrom, TransferWithAuthorization 구현

문제: 사용자한테 가스비를 내라고 할 수 없다

블록체인에서 거래를 보내려면 수수료(가스비)가 필요하다. → 가스비(Gas Fee)란 블록체인 네트워크에서 거래를 처리해주는 대가로 내는 수수료다. 거래가 복잡할수록 가스비가 올라간다. 이더리움이면 ETH, 우리가 쓰는 Avalanche면 AVAX 코인이 있어야 한다.

근데 우리 서비스는 지역화폐 간편결제다. 사용자 지갑에는 BNKW 토큰만 있고, AVAX는 없다. "결제하려면 먼저 거래소에서 AVAX를 사세요"라고 할 수는 없으니까.

처음에는 단순하게 접근했다.

# 1차 시도: 사용자가 직접 burn 호출
사용자 지갑 → burn() 호출 → AVAX 없어서 실패!

해결 과정: burn → burnFrom → permitAndBurnFrom

2차 시도는 서버가 대신 호출하는 burnFrom이었다.

사용자가 approve(서버, 금액) → 서버가 burnFrom(사용자, 금액)

근데 이것도 문제가 있었다. approve를 호출하는 것 자체가 블록체인 거래라서, 또 가스비가 필요하다. → approve란 "내 토큰을 누군가가 대신 사용해도 좋다"라고 블록체인에 허가를 등록하는 함수다. burnFrom은 그 허가를 받은 사람이 토큰을 소각하는 함수다. 닭이 먼저냐 달걀이 먼저냐 같은 상황.

최종 해결: EIP-2612 Permit

→ EIP-2612는 이더리움 개선 제안(Ethereum Improvement Proposal) 중 하나로, 토큰 사용 허가(approve)를 블록체인 거래 없이 서명만으로 처리할 수 있게 해주는 표준이다. 덕분에 사용자가 가스비를 내지 않아도 된다.

EIP-2612는 "오프라인 서명"으로 approve를 대체하는 표준이다. 사용자가 블록체인에 거래를 보내지 않고, 서명만 만들면 된다. 서명은 그냥 데이터니까 가스비가 안 든다.

1. 서버가 사용자 개인키로 Permit 서명 생성 (오프라인, 가스비 0)
2. Issuer 지갑이 permit() + burnFrom() 실행 (Issuer가 가스비 부담)

실제 코드:

fun permitAndBurnFrom(ownerAddress: String, amount: BigDecimal, encryptedPrivateKey: String): String {
    // 사전 검증 5가지를 병렬로 조회
    // → CompletableFuture는 Java의 비동기 처리 도구다. 여러 작업을 동시에 시작하고 나중에 결과를 모아서 쓸 수 있다.
    val pausedFuture = CompletableFuture.supplyAsync { isPaused() }
    val frozenFuture = CompletableFuture.supplyAsync { isFrozen(ownerAddress) }
    val balanceFuture = CompletableFuture.supplyAsync { balanceOf(ownerAddress) }
    val nonceFuture = CompletableFuture.supplyAsync { getNonce(ownerAddress) }
    val blockTimestampFuture = CompletableFuture.supplyAsync { getBlockTimestamp() }

    // 검증 결과 확인
    if (joinUnwrap(pausedFuture)) throw IllegalStateException("컨트랙트 일시정지 상태")
    if (joinUnwrap(frozenFuture)) throw IllegalStateException("계정 동결 상태")
    if (joinUnwrap(balanceFuture) < amount) throw IllegalStateException("잔액 부족")

    // EIP-712 Permit 서명 생성 (사용자 개인키 사용)
    val (v, r, s) = generatePermitSignature(
        ownerAddress = ownerAddress,
        spenderAddress = issuerAddress,  // Issuer가 대리 실행
        amount = amountWei,
        nonce = nonce,
        deadline = blockTimestamp + 3600  // 1시간 유효
    )

    // Step 1: Issuer가 permit 실행 (allowance 부여)
    executeContractFunction(issuerCredentials, "permit", listOf(owner, spender, value, deadline, v, r, s))

    // Step 2: Issuer가 burnFrom 실행 (토큰 소각)
    val receipt = executeContractFunction(issuerCredentials, "burnFrom", listOf(owner, value))
    return receipt.transactionHash
}

EIP-712: 서명이 악용되지 않는 이유

→ EIP-712는 블록체인 서명에 "이 서명이 어디서, 무엇을 위해, 언제까지 유효한지" 같은 맥락 정보를 구조화해서 넣는 표준이다. 서명이 엉뚱한 곳에서 재사용되는 걸 막아준다.

단순히 "10,000원 써도 좋다"라는 서명이면, 누군가 그 서명을 가로채서 다른 데 쓸 수 있다. EIP-712는 서명에 구조화된 컨텍스트를 넣어서 이걸 방지한다.

val types = mapOf(
    "Permit" to listOf(
        mapOf("name" to "owner", "type" to "address"),     // 누구의 토큰인지
        mapOf("name" to "spender", "type" to "address"),   // 누가 사용 가능한지
        mapOf("name" to "value", "type" to "uint256"),     // 얼마까지
        mapOf("name" to "nonce", "type" to "uint256"),     // 일회용 번호 (재사용 불가)
        mapOf("name" to "deadline", "type" to "uint256")   // 언제까지 유효
    )
)

val domain = mapOf(
    "name" to tokenName,                   // 토큰 이름
    "version" to "1",
    "chainId" to chainId,                  // 이 체인에서만 유효
    "verifyingContract" to contractAddress  // 이 컨트랙트에서만 유효
)

서명에 체인 ID, 컨트랙트 주소, 일회용 nonce, 유효기간이 포함되어 있으므로, 다른 체인이나 다른 컨트랙트에서는 사용할 수 없고, 한 번 쓰면 재사용도 불가능하다.

결제도 마찬가지: ERC-3009 TransferWithAuthorization

→ ERC-3009는 토큰 전송도 EIP-2612처럼 서명 기반으로 처리하는 표준이다. 사용자가 서명만 하면 제3자(Relay)가 대신 거래를 제출하고 가스비를 부담한다.

결제(전송)도 같은 원리다. 사용자 서명 + Relay 지갑이 대신 제출.

// 32바이트 랜덤 nonce (일회용)
val nonce = ByteArray(32).apply { java.security.SecureRandom().nextBytes(this) }

val validAfter = blockTimestamp - 60   // 즉시 유효
val validBefore = blockTimestamp + 3600 // 1시간 유효

// 사용자 서명 생성 (from → to, amount, 유효기간, nonce)
val signature = generateEIP712Signature(from, to, amount, validAfter, validBefore, nonce, privateKey)

// Relay 지갑이 대신 실행 (가스비 부담)
executeContractFunction(relayCredentials, "transferWithAuthorization", listOf(from, to, amount, ...signature))

정리: 시스템 지갑 역할

지갑역할언제 가스비를 쓰나
Issuermint, permit+burnFrom충전, 환전 시
RelaytransferWithAuthorization, batchPayment결제, 전송 시
Admin인센티브 배포캐시백 지급 시

사용자는 가스비가 뭔지도 모르고 결제한다. 원래 그래야 하니까.

사용자 가스비를 0원으로 만든 이야기 | KYUDORI