1. 개요2. 아키텍처3. 테스트 진행3-1. 가스비 대납 검증 및 소모량 확인3-1. 2612, 3009 사용 시 vrs 보안 검증4. 결론4-1. 기존 방식 vs 메타트랜잭션 비교4-2. 가스비 비교 (Avalanche Mainnet 실측)4-3. 보안 검증 결과
1. 개요
EIP-2612와 EIP-3009 표준을 적용하여 토큰 전송 및 dApp 상호작용에 대해 기존의 방법과 메타트랜잭션을 비교, 검증하기 위한 시나리오 테스트 진행
2. 아키텍처

- 테스트 토큰: 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

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; });
결과

- 전송 테스트
- transfer
snowtrace.io
(transfer)snowtrace.io
- 사용자가 트랜잭션을 직접 실행하므로 가스비 지불
- transfer + 3009
snowtrace.io
(transferWithAuthorization)snowtrace.io
- 사용자는 서명만 생성, Relayer가 가스비를 대납. 결과적으로 사용자의 가스비 소모는 0
- 예치 테스트
- approve + deposit
snowtrace.io
(approve)snowtrace.io
snowtrace.io
(deposit)snowtrace.io
- 승인(Approve)과 예치(Deposit) 2회의 트랜잭션으로 인해 높은 가스비 지불
- permit + deposit
snowtrace.io
(depositWithPermit)snowtrace.io
- 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; });
결과

- 데이터 위변조 테스트
- 사용자가 10MTK를 전송하는 것에 서명, Relayer가 1000MTK를 전송하는 것으로 변조
- 컨트랙트는 EIP-712 표준으로 생성된 해시 + vrs로 사용자의 주소를 역산하므로 공격자가 의도적으로 데이터를 변경하더라도 엉뚱한 주소가 도출된다.
- 서명값 조작 테스트
- 공격자가 데이터는 건드리지 않고, s의 값을 변형하여 유효한 서명인 것처럼 제출
- 2612, 3009가 상속받는 ECDSA 모듈이 서명값의 표준을 체크하여 내부적으로 트랜잭션을 revert 처리한다.
- 재전송 테스트
- 공격자가 이미 처리된 정상적인 서명 데이터를 다시 제출하여 이중 출금을 시도
- 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 기반 중복 검증 |
