Back
메타트랜잭션 구현 및 검증 (EIP-2612, EIP-3009, Avalanche Mainnet)

메타트랜잭션 구현 및 검증 (EIP-2612, EIP-3009, Avalanche Mainnet)

Date
Nov 20, 2025
Published
Published

1. 개요

EIP-2612와 EIP-3009 표준을 적용하여 토큰 전송 및 dApp 상호작용에 대해 기존의 방법과 메타트랜잭션을 비교, 검증하기 위한 시나리오 테스트 진행

2. 아키텍처

notion image
  • 테스트 토큰: ERC20, EIP2612, EIP3009를 상속
    • MetaToken.sol
      // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; import "./extensions/EIP3009.sol"; contract MetaToken is ERC20, ERC20Permit, EIP3009 { constructor() ERC20("MetaToken", "MTK") ERC20Permit("MetaToken") { _mint(msg.sender, 1000000 * 10 ** decimals()); } }
  • EIP3009: 가스비 대납 transfer를 위한 확장
    • EIP3009.sol (JPYC 참고)
      // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; abstract contract EIP3009 is ERC20, EIP712 { bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"); mapping(address => mapping(bytes32 => uint256)) private _authorizationStates; event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); function authorizationState(address authorizer, bytes32 nonce) external view returns (bool) { return _authorizationStates[authorizer][nonce] == 1; } function transferWithAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s ) external { require(block.timestamp > validAfter, "EIP3009: authorization is not yet valid"); require(block.timestamp < validBefore, "EIP3009: authorization is expired"); require(_authorizationStates[from][nonce] == 0, "EIP3009: authorization is used or canceled"); bytes32 structHash = keccak256(abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce)); bytes32 digest = _hashTypedDataV4(structHash); address recoveredAddress = ECDSA.recover(digest, v, r, s); require(recoveredAddress == from, "EIP3009: invalid signature"); _authorizationStates[from][nonce] = 1; emit AuthorizationUsed(from, nonce); _transfer(from, to, value); } }
  • 테스트 dApp 컨트랙트: EIP-2612 wrapper 함수가 구현된 dApp 컨트랙트
    • Vault.sol
      // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; contract Vault { using SafeERC20 for IERC20; IERC20Permit public immutable token; mapping(address => uint256) public balances; constructor(address _token) { token = IERC20Permit(_token); } // 일반적인 deposit function deposit(uint256 amount) external { IERC20(address(token)).safeTransferFrom(msg.sender, address(this), amount); balances[msg.sender] += amount; } // 2612 deposit function depositWithPermit( address owner, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external { token.permit(owner, address(this), amount, deadline, v, r, s); IERC20(address(token)).safeTransferFrom(owner, address(this), amount); balances[owner] += amount; } }
  • Relayer: 가스비 대납을 위한 node.js 서버. 오프체인으로 사용자의 vrs를 전달받아 Vault 컨트랙트에 트랜잭션을 실행시키는 주체
 

3. 테스트 진행

테스트 환경: Avalanche Mainnet
토큰 컨트랙트(MTK): 0x8676beA77256AF5206f019b9Df20bF7D9DdE3AA9
dApp 컨트랙트: 0x12dBA5353a4aC67B000F68F01784D2c9F3c8029d
유저 지갑: 0xc8274758C7378E910e6413fa9722F908DbD568ff
릴레이어 지갑: 0x07BF76aEe31790dd648fAc0fcC2f3a1C82d42C43
notion image
 

3-1. 가스비 대납 검증 및 소모량 확인

metaTransaction.js
const { ethers } = require("hardhat"); const axios = require("axios"); const TOKEN_ADDRESS = "0x370DA190a3e39338c78f65E67cBD1D38e5592EbA"; const VAULT_ADDRESS = "0x22F1F84B385B5cDf4A9b9Ad097eb2114D81E09E8"; const RELAYER_API_URL = "http://localhost:3000"; async function main() { const [deployer, relayer, user] = await ethers.getSigners(); const metaTokenFactory = await ethers.getContractFactory("MetaToken"); const tokenContract = metaTokenFactory.attach(TOKEN_ADDRESS); const vaultFactory = await ethers.getContractFactory("Vault"); const vaultContract = vaultFactory.attach(VAULT_ADDRESS); // EIP-712 설정 (vrs) const { chainId } = await ethers.provider.getNetwork(); const typedDataDomain = { name: "MetaToken", version: "1", chainId, verifyingContract: TOKEN_ADDRESS, }; // 유저 지갑으로 메타토큰 전송 await ( await tokenContract .connect(deployer) .transfer(user.address, ethers.parseEther("1000")) ).wait(); async function getBalances() { const relayerBalance = await ethers.provider.getBalance(relayer.address); const userBalance = await ethers.provider.getBalance(user.address); return { relayerBalance, userBalance }; } // ----------------------------------------------------- // 전송 테스트 // ----------------------------------------------------- console.log(`\n[전송 테스트]`); const startBalances = await getBalances(); // 1. 일반 전송 (유저가 가스비 지불) console.log(`transfer`); const directTransferTx = await tokenContract .connect(user) .transfer(deployer.address, ethers.parseEther("10")); await directTransferTx.wait(); const afterDirectTransferBalances = await getBalances(); console.log( `유저가 지불한 가스비: ${ethers.formatEther( startBalances.userBalance - afterDirectTransferBalances.userBalance )} AVAX` ); // 2. 3009 활용한 전송 (릴레이어가 가스비 지불) console.log(`transfer + 3009`); const metaTransferAmount = ethers.parseEther("10"); const metaTransferNonce = ethers.hexlify(ethers.randomBytes(32)); const metaTransferMessage = { from: user.address, to: deployer.address, value: metaTransferAmount, validAfter: 0, validBefore: Math.floor(Date.now() / 1000) + 3600, nonce: metaTransferNonce, }; const metaTransferSignature = await user.signTypedData( typedDataDomain, { 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" }, ], }, metaTransferMessage ); const { v: metaTransferV, r: metaTransferR, s: metaTransferS, } = ethers.Signature.from(metaTransferSignature); // 릴레이어에게 vrs 전달 await axios.post(`${RELAYER_API_URL}/relay/transfer`, { from: user.address, to: deployer.address, value: metaTransferAmount.toString(), validAfter: 0, validBefore: metaTransferMessage.validBefore, nonce: metaTransferNonce, signature: { v: metaTransferV, r: metaTransferR, s: metaTransferS }, }); const afterMetaTransferBalances = await getBalances(); console.log( `유저가 지불한 가스비: ${ethers.formatEther( afterDirectTransferBalances.userBalance - afterMetaTransferBalances.userBalance )} AVAX` ); console.log( `릴레이어가 지불한 가스비: ${ethers.formatEther( afterDirectTransferBalances.relayerBalance - afterMetaTransferBalances.relayerBalance )} AVAX` ); console.log(`\n----------------------------------------`); // ----------------------------------------------------- // 예치 테스트 // ----------------------------------------------------- console.log(`[예치 테스트]`); const beforeDepositBalances = await getBalances(); // 1. 일반 예치 (approve 후 deposit) console.log(`deposit`); const standardDepositAmount = ethers.parseEther("10"); await ( await tokenContract .connect(user) .approve(VAULT_ADDRESS, standardDepositAmount) ).wait(); await ( await vaultContract.connect(user).deposit(standardDepositAmount) ).wait(); const afterStandardDepositBalances = await getBalances(); console.log( `유저가 지불한 가스비: ${ethers.formatEther( beforeDepositBalances.userBalance - afterStandardDepositBalances.userBalance )} AVAX` ); // 2. permit + deposit (릴레이어가 가스 지불) console.log(`permit + deposit`); const metaDepositAmount = ethers.parseEther("10"); const permitNonce = await tokenContract.nonces(user.address); const permitDeadline = Math.floor(Date.now() / 1000) + 3600; const permitSignature = await user.signTypedData( typedDataDomain, { Permit: [ { name: "owner", type: "address" }, { name: "spender", type: "address" }, { name: "value", type: "uint256" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" }, ], }, { owner: user.address, spender: VAULT_ADDRESS, value: metaDepositAmount, nonce: permitNonce, deadline: permitDeadline, } ); const { v: permitV, r: permitR, s: permitS, } = ethers.Signature.from(permitSignature); // 릴레이어에게 vrs 전달 await axios.post(`${RELAYER_API_URL}/relay/deposit`, { owner: user.address, amount: metaDepositAmount.toString(), deadline: permitDeadline, signature: { v: permitV, r: permitR, s: permitS }, }); const afterMetaDepositBalances = await getBalances(); console.log( `유저가 지불한 가스비: ${ethers.formatEther( afterStandardDepositBalances.userBalance - afterMetaDepositBalances.userBalance )} AVAX` ); console.log( `릴레이어가 지불한 가스비: ${ethers.formatEther( afterStandardDepositBalances.relayerBalance - afterMetaDepositBalances.relayerBalance )} AVAX` ); console.log(`\n----------------------------------------`); } main().catch((error) => { console.error(error); process.exitCode = 1; });
결과
notion image
  1. 전송 테스트
      • transfer
        • snowtrace.io (transfer)
        • 사용자가 트랜잭션을 직접 실행하므로 가스비 지불
      • transfer + 3009
        • snowtrace.io (transferWithAuthorization)
        • 사용자는 서명만 생성, Relayer가 가스비를 대납. 결과적으로 사용자의 가스비 소모는 0
  1. 예치 테스트
      • approve + deposit
        • snowtrace.io (approve) snowtrace.io (deposit)
        • 승인(Approve)과 예치(Deposit) 2회의 트랜잭션으로 인해 높은 가스비 지불
      • permit + deposit
        • snowtrace.io (depositWithPermit)
        • Permit 기능을 통해 단일 트랜잭션으로 처리되었으며, 사용자의 가스비 소모는 0이고 relayer도 단일 트랜잭션으로 인해 상대적으로 적은 가스비 지불

3-1. 2612, 3009 사용 시 vrs 보안 검증

vrsSecure.js
const { ethers } = require("hardhat"); const axios = require("axios"); const TOKEN_ADDRESS = "0x370DA190a3e39338c78f65E67cBD1D38e5592EbA"; const SERVER_URL = "http://localhost:3000"; async function main() { const [, relayer, user] = await ethers.getSigners(); // EIP-712 설정 (vrs) const { chainId } = await ethers.provider.getNetwork(); const typedDataDomain = { name: "MetaToken", version: "1", chainId, verifyingContract: TOKEN_ADDRESS, }; const transferWithAuthTypes = [ { name: "from", type: "address" }, { name: "to", type: "address" }, { name: "value", type: "uint256" }, { name: "validAfter", type: "uint256" }, { name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" }, ]; const signTransferAuth = async (message) => { const sig = await user.signTypedData( typedDataDomain, { TransferWithAuthorization: transferWithAuthTypes }, message ); return ethers.Signature.from(sig); }; const amount = ethers.parseEther("10"); const tamperAmount = ethers.parseEther("1000"); // ----------------------------------------------------- // 데이터 위변조 테스트 // ----------------------------------------------------- console.log(`[데이터 위변조 (value 변경)]`); const nonce1 = ethers.hexlify(ethers.randomBytes(32)); const msg1 = { from: user.address, to: relayer.address, value: amount, validAfter: 0, validBefore: Math.floor(Date.now() / 1000) + 3600, nonce: nonce1, }; const sig1 = await signTransferAuth(msg1); try { await axios.post(`${SERVER_URL}/relay/transfer`, { from: user.address, to: relayer.address, value: tamperAmount.toString(), // value 변경 (10 -> 1000) validAfter: 0, validBefore: msg1.validBefore, nonce: nonce1, signature: { v: sig1.v, r: sig1.r, s: sig1.s }, }); console.log(`실패: 위변조 요청 통과`); } catch { console.log(`성공: 위변조 요청 거절`); } console.log(`\n----------------------------------------`); // ----------------------------------------------------- // vrs 조작 테스트 // ----------------------------------------------------- console.log(`[vrs 조작 (s값 변경)]`); const nonce2 = ethers.hexlify(ethers.randomBytes(32)); const msg2 = { ...msg1, nonce: nonce2 }; const sig2 = await signTransferAuth(msg2); const tamperedS = "0x" + (BigInt(sig2.s) + 1n).toString(16); try { await axios.post(`${SERVER_URL}/relay/transfer`, { from: user.address, to: relayer.address, value: amount.toString(), validAfter: 0, validBefore: msg2.validBefore, nonce: nonce2, signature: { v: sig2.v, r: sig2.r, s: tamperedS }, }); console.log(`실패: vrs 조작 통과`); } catch { console.log(`성공: vrs 조작 거절`); } console.log(`\n----------------------------------------`); // ----------------------------------------------------- // Replay 테스트 // ----------------------------------------------------- console.log(`[재전송 공격 (Replay)]`); const nonce3 = ethers.hexlify(ethers.randomBytes(32)); const msg3 = { ...msg1, nonce: nonce3 }; const sig3 = await signTransferAuth(msg3); const payload = { from: user.address, to: relayer.address, value: amount.toString(), validAfter: 0, validBefore: msg3.validBefore, nonce: nonce3, signature: { v: sig3.v, r: sig3.r, s: sig3.s }, }; process.stdout.write("1차 전송"); try { await axios.post(`${SERVER_URL}/relay/transfer`, payload); console.log("OK"); } catch { console.log("실패"); return; } process.stdout.write("2차 전송"); try { await axios.post(`${SERVER_URL}/relay/transfer`, payload); console.log(`실패: replay 통과`); } catch { console.log(`성공: replay 거절`); } console.log(`\n----------------------------------------`); } main().catch((error) => { console.error(error); process.exitCode = 1; });
결과
notion image
  1. 데이터 위변조 테스트
      • 사용자가 10MTK를 전송하는 것에 서명, Relayer가 1000MTK를 전송하는 것으로 변조
      • 컨트랙트는 EIP-712 표준으로 생성된 해시 + vrs로 사용자의 주소를 역산하므로 공격자가 의도적으로 데이터를 변경하더라도 엉뚱한 주소가 도출된다.
  1. 서명값 조작 테스트
    1. 공격자가 데이터는 건드리지 않고, s의 값을 변형하여 유효한 서명인 것처럼 제출
    2. 2612, 3009가 상속받는 ECDSA 모듈이 서명값의 표준을 체크하여 내부적으로 트랜잭션을 revert 처리한다.
  1. 재전송 테스트
      • 공격자가 이미 처리된 정상적인 서명 데이터를 다시 제출하여 이중 출금을 시도
      • 2612, 3009는 서명 생성 시 사용한 nonce의 유효성을 체크하여 중복 nonce의 경우 내부적으로 revert 처리한다.
 

4. 결론

Avalanche 메인넷 환경에서 EIP-2612(Permit)와 EIP-3009(TransferWithAuthorization) 표준을 활용한 메타트랜잭션을 구현 및 검증하였다. 모든 트랜잭션에 대한 가스비 대납은 아니지만 기존의 방식과 비교했을 때 사용자에게 충분한 이점을 제공한다.
  • 가스비 절감 및 UX 개선
    • 사용자는 서명만 생성하고 릴레이어가 가스비를 대납함으로써 사용자의 가스비 부담이 0으로 감소하였다.
    • 기존의 approve + deposit 방식이 두 번의 트랜잭션을 필요로 하는 반면, permit + deposit 방식은 단일 트랜잭션으로 처리되어 전체 가스비가 절감되는 효과가 있다.
  • 보안성
    • 데이터 위변조, 서명값 조작, 재전송 공격 등 다양한 공격 시나리오에 대해 EIP-712 표준 기반의 서명과 ECDSA 검증을 통해 이미 보안성이 확보되어 있음을 확인하였다.
결과적으로 EIP-2612와 EIP-3009 표준을 활용한 메타트랜잭션도 사용성의 사용성과 보안성을 충분히 향상시킬 수 있으며 향후 더 나은 사용자 경험을 제공할 수 있을 것으로 기대된다.
 

4-1. 기존 방식 vs 메타트랜잭션 비교

구분
기존 방식 (approve + deposit)
메타트랜잭션 (permit + deposit)
트랜잭션 횟수
2회
1회
사용자 가스비
높음 (2회 지불)
0 (릴레이어 대납)
릴레이어 가스비
-
낮음 (단일 트랜잭션)
사용자 경험
2번의 트랜잭션 승인 필요
서명만 생성 (1회)
보안성
표준 ERC20
EIP-712 서명 + ECDSA 검증

4-2. 가스비 비교 (Avalanche Mainnet 실측)

시나리오
트랜잭션
가스비 (AVAX)
링크
기존 방식
approve
0.000577376
deposit
0.000633028
합계
0.001210404
메타트랜잭션
depositWithPermit
0.00089006 (릴레이어 부담)
사용자 부담
0

4-3. 보안 검증 결과

공격 유형
공격 시나리오
검증 결과
방어 메커니즘
데이터 위변조
전송 금액 변조 (10 MTK → 1000 MTK)
✅ 차단 성공
EIP-712 해시 + ECDSA 서명 검증
서명값 조작
s 값 변경 (s → s+1)
✅ 차단 성공
ECDSA 모듈의 서명 표준 검증
재전송 공격 (Replay)
동일 서명 데이터 재사용
✅ 차단 성공
Nonce 기반 중복 검증