
…
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
- https://snowtrace.io/tx/0xbda1465039540ae3c1554a8bc91e19a8862a40727d835ee2537f9d37a46e372e?chainid=43114 (transfer)
- 사용자가 트랜잭션을 직접 실행하므로 가스비 지불
- transfer + 3009
- https://snowtrace.io/tx/0x613e3048c5d57d69409665efba8abdc8e9106de417a5b4b22aff4477ef0e2fff?chainid=43114 (transferWithAuthorization)
- 사용자는 서명만 생성, Relayer가 가스비를 대납. 결과적으로 사용자의 가스비 소모는 0
- transfer
- 예치 테스트
- approve + deposit
- https://snowtrace.io/tx/0x7a913bc8518e1c66d00de8ed08f4c28a6f23c72e775a8926ef0d55c2c55d7420?chainid=43114 (approve) https://snowtrace.io/tx/0xa64118d6b9d3f443168388df10ae805dff6122b65a042d1cb2fcf17f55d9443f?chainid=43114 (deposit)
- 승인(Approve)과 예치(Deposit) 2회의 트랜잭션으로 인해 높은 가스비 지불
- permit + deposit
- https://snowtrace.io/tx/0xd4f6ec4948f615318ef98eade2789d167d89f3cab2e891056cef20c9d46ed207?chainid=43114 (depositWithPermit)
- Permit 기능을 통해 단일 트랜잭션으로 처리되었으며, 사용자의 가스비 소모는 0이고 relayer도 단일 트랜잭션으로 인해 상대적으로 적은 가스비 지불
- approve + deposit
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 | snowtrace.io |
| deposit | 0.000633028 | snowtrace.io | |
| 합계 | 0.001210404 | ||
| 메타트랜잭션 | depositWithPermit | 0.00089006 (릴레이어 부담) | snowtrace.io |
| 사용자 부담 | 0 |
4-3. 보안 검증 결과
| 공격 유형 | 공격 시나리오 | 검증 결과 | 방어 메커니즘 |
|---|---|---|---|
| 데이터 위변조 | 전송 금액 변조 (10 MTK → 1000 MTK) | ✅ 차단 성공 | EIP-712 해시 + ECDSA 서명 검증 |
| 서명값 조작 | s 값 변경 (s → s+1) | ✅ 차단 성공 | ECDSA 모듈의 서명 표준 검증 |
| 재전송 공격 (Replay) | 동일 서명 데이터 재사용 | ✅ 차단 성공 | Nonce 기반 중복 검증 |