가스비 대납을 위한 메타 트랜잭션(Meta-Transactions)
ERC-2770 & ERC-2771

Intro
현재 Web3 생태계는 Web2 사용자가 쉽게 참여할 수 있도록, 번거로운 진입 장벽을 개선하기 위해 노력하고 있다. 이런 취지로 Web2 사용자가 쉽게 Dapp을 이용할 수 있게 가스비를 대납해주는 방법 또한 관심이 높아지고 있다. 이런 관심 속에서 메타 트랜잭션(Meta-Transactions)라는 개념이 등장한다.
현재 Web3 생태계는 Web2 사용자가 쉽게 참여할 수 있도록, 번거로운 진입 장벽을 개선하기 위해 노력하고 있다. 이런 취지로 Web2 사용자가 쉽게 Dapp을 이용할 수 있게 가스비를 대납해주는 방법 또한 관심이 높아지고 있다. 이런 관심 속에서 메타 트랜잭션(Meta-Transactions)라는 개념이 등장한다.
우리가 Dapp의 서비스를 이용하며 On Chain에 어떤 동작을 호출할때, 정해진 가스비가 포함된 트랜잭션(Transaction)을 블록체인 네트워크로 전송한다. 여기서 사용된 트랜잭션을 제3자가 가스비를 대신 지불할 수 있도록 하는 솔루션으로 확장한 개념이 메타 트랜잭션이다.
이더리움은 사용자가 가스비 대납을 요청하는 메타 트랜잭션과, 외부에서 개인키로 서명한 요청을 온체인에서 검증하여 _가스비를 대납해주는 스마트컨트랙트_의 표준을 갖추고 있다. 이러한 표준과 _메타 트랜잭션을 위한 보안 프로토콜(Secure Protocol for Native Meta Transactions)_은 각각 ERC-2770과 ERC-2771에 정의되어 있으며, 현재 이더리움 기반 애플리케이션의 개발 표준으로 활용되고 있으며, 본 아티클은 해당 내용에 대해 자세하게 다뤄볼 예정이다.
트랜잭션(Transactions)
메타 트랜잭션을 알아 보기전에, 먼저 일반적인 트랜잭션에 포함되는 데이터에 대해 알아보자.
- 논스(nonce) : EOA에 개별적으로 사용되는 일련번호
- 가스 가격(gas price) : 발신자가 지급하는 가스의 가격(wei)
- 가스 한도(gas limit) : 트랜잭션을 위해 사용을 허용하는 가스의 최대량
- 수신자(to) : 전송하는 이더를 받는 주소 또는 data를 실행시키는 컨트랙트 주소
- 값(value) : 목적지에 보낼 이더의 양
- 데이터(data) : 실행하고자 하는 트랜잭션의 Calldata
- v,r,s : EOA의 ECDSA 디지털 서명
이더리움은 위와 같은 트랜잭션의 데이터를 통해 블록체인의 상태(state)를 변경한다. 이때, 데이터 형식에 따라 블록체인은 크게 이더 전송, 스마트 컨트랙트 함수 호출, 이더 전송 + 스마트 컨트랙트 함수 호출 3가지 요청을 받아 동작한다. (스마트컨트랙트 배포 요청은 제외)
1.이더 전송
이더를 받는 사람의 주소를 수신자(to)에, 전송하고자 하는 ether 금액을 value에 넣으면 된다. 여기서 data는 스마트컨트랙트를 호출하기 위한 필드이다. 지금은 이더 전송만 하기에 data에 “0x00”을 넣어주면 된다.
2. 스마트 컨트랙트 함수 호출
호출하고자하는 스마트 컨트랙트의 주소를 수신자(to)에, 호출하고자하는 함수의 Calldata를 데이터(data) 필드에 넣으면 된다. 이더는 전송하지 않으니, value에 0을 넣어주면 된다.
3. 이더 전송 + 스마트 컨트랙트 함수 호출
호출하고자 하는 스마트 컨트랙트의 주소를 수신자(to)에, 호출하고자하는 함수의 Calldata를 데이터(data) 필드, 전송하고자 하는 ether 금액을 value에 넣으면 된다. 참고로, 함수 호출과 이더 전송을 같이 하는 경우는 EOA가 아닌 스마트컨트랙트 주소로 이더를 전송하는 것이다.
메타 트랜잭션(Meta-Transactions)
메타 트랜잭션은 위에서 설명했듯이 일반적인 트랜잭션에서 확장된 개념이다. 본격적으로 메타 트랜잭션에 대해 알아보자.
- address from : 메타 트랜잭션을 작성한 사람의 지갑 주소
- address to : 전송하는 이더를 받는 주소 또는 메타 트랜잭션의 data를 전달받고 호출될 컨트랙트 주소
- uint256 values : 같이 전달하는 이더
- uint256 gas : 메타 트랜잭션 호출 시 사용할 가스양
- uint256 nonce : forwarder 컨트랙트에 기록된 nonce
- bytes data : 실행하고자 하는 트랜잭션의 Calldata
메타 트랜잭션은 On Chain 동작은 일반 트랜잭션과 동일하게 데이터 형식에 따라 이더 전송, 스마트 컨트랙트 함수 호출, 이더 전송 + 스마트 컨트랙트 함수 호출 3가지로 나눠진다.
여기에 추가로 사용자는 본인 지갑의 개인키로 서명한 데이터를 같이 전달하여 제3자가 가스비를 대신 지불할 수 있도록 승인해줘야한다. 메타 트랜잭션을 디지털 서명을 해야하는 이유는 3가지가 있다.
- 개인키 소유자가 대납자에게 메타 트랜잭션 “승인했음”을 증명
- 승인에 대한 부인 방지(non-repudiation)
- 서명된 메타 트랜잭션은 그 누구도 수정할수 없음을 증명
여기서 가장 핵심은 “3. 서명된 메타 트랜잭션은 그 누구도 수정할수 없음 증명”이다. 서명 없이 메타 트랜잭션만으로 제 3자에게 가스비를 대납을 요청하면, 제 3자는 해당 메타 트랜잭션을 조작하여 본인에게 이익이 되는 트랜잭션으로 수정 할 수 있다. 이런 이유로 ERC-2770는 메타 트랜잭션과 EIP-712로 서명된 서명값을 같이 받는다. 그리고 서명 데이터와 메타 트랜잭션을 비교하여, 메타 트랜잭션을 작성한 사람이 서명한게 검증되면 비소로 트랜잭션을 실행된다.
ERC-2770 : Forwarder
Openzeppelin(v4.9.0)에서 구현한 ERC-2770의 MinimalForwarder.sol 코드는 아래와 같다.
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.9.0)
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract MinimalForwarder is EIP712 {
struct ForwardRequest {
address from;
address to;
uint256 value;
uint256 gas;
uint256 nonce;
bytes data;
}
bytes32 private constant _TYPEHASH =
keccak256("ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)");
mapping(address => uint256) private _nonces;
constructor() EIP712("MinimalForwarder", "0.0.1") {}
function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) {
address signer = _hashTypedDataV4(
keccak256(abi.encode(_TYPEHASH, req.from, req.to, req.value, req.gas, req.nonce, keccak256(req.data)))
).recover(signature);
return _nonces[req.from] == req.nonce && signer == req.from;
}
function execute(
ForwardRequest calldata req,
bytes calldata signature
) public payable returns (bool, bytes memory) {
require(verify(req, signature), "MinimalForwarder: signature does not match request");
_nonces[req.from] = req.nonce + 1;
(bool success, bytes memory returndata) = req.to.call{gas: req.gas, value: req.value}(
abi.encodePacked(req.data, req.from)
);
if (gasleft() <= req.gas / 63) {
assembly {
invalid()
}
}
return (success, returndata);
}
}
지금까지 설명한 메타 트랜잭션은 스마트 컨트랙트 내에서 call() 함수로 트랜잭션을 실행하기 위한 데이터 구조이다. struct ForwardRequest 구조체의 데이터가 메타 트랜잭션의 데이터가 되며, execute() 함수의 req 인자로 받게 된다. 그리고 제 3자에게 대납을 승인하기 위한 서명 signature도 같이 보낸다.
execute() 로직을 보면 verify(req, signature) 함수를 통해 메타 데이터와 서명값을 비교하여 메타 트랜잭션의 _req.from_이 서명한 데이터가 맞는지 검증한다. 그리고 검증에 성공하면 call() 함수를 통해 메타 트랜잭션에 작성한대로 실행되다.
로직의 마지막에
if (gasleft() <= req.gas / 63)조건문이 보이는데, 이건 EIP-150에서 정의된 63/64 Rule이다. 가스비 대납와 별개의 내용으로 이번 포스트에서 별도 다루진 않는다. 해당 주제에 대한 글은 아래 포스트로 따로 작성했으니 참고하면 좋을 것 같다.
Untitled
ERC-2770의 한계
지금까지 메타 트랜잭션을 안전하게 제 3자에게 요청할 수 있는 ERC-2770에 대해 알아봤다. 하지만 ERC-2770은 한가지 한계가 존재한다. 이더리움은 트랜잭션 요청자 주소를 msg.sender 라는 환경변수(context)에 저장한다. 만약 제 3자가 가스비 대납을 위해 사용자의 트랜잭션을 대신 실행해준다면 msg.sender에는 사용자가 아닌 대납자의 지갑 주소가 들어가게 된다. 이게 어떤 한계를 부르는지 ERC20의 transfer 함수를 보면 이해가 될 것이다.
function transfer(address to, uint256 amount) public virtual override returns (bool) {
address owner = msg.sender;
_transfer(owner, to, amount);
return true;
}ERC20의 transfer 함수는 msg.sender를 사용하여 호출자가 토큰의 주인인지 아닌지를 구분한다. 만약, 사용자가 요청한 메타 트랜잭션이 ERC20 토큰 전송이라면, msg.sender에 대납을 요청한 토큰 주인의 지갑주소가 아닌 대신 가스비를 내주는 대납자의 지갑주소가 들어가며 대납자의 토큰이 전송되는 일이 벌어질 것이다.
ERC20 외에, 많은 Dapp은 msg.sender을 통해 지갑 주인만 자산에 접근할 수 있도록 보안 처리를 하고 있다. 가스비 대납은 분명 사용성을 편리하게 만들어 주지만, 이런 구조라면 활용성이 거의 없을 정도이다.
ERC-2771
이더리움 공식 표준에 나와있는 ERC-2771의 타이틀을 보면 “메타 트랜잭션을 위한 보안 프로토콜(Secure Protocol for Native Meta Transactions)”라고 나와있다. 프로토콜이란 IT용어에서 컴퓨터와 컴퓨터 사이에서 데이터의 교환 방식을 정의하는 규칙 체계를 의미한다. 이런 의미에서 ERC-2771이 말하는 보안 프로토콜은 메타 트랜잭션을 통해 스마트 컨트랙트와 통신할때 발생하는 한계를 보완하기 위해 정의한 규칙 체계를 의미한다. 여기서 핵심은 메타 트랜잭션의 한계를 보완한다는 부분이다. ERC-2771에서 어떤 규칙(프로토콜)을 정의했는지 알아보기전에, 메타 트랜잭션의 한계에 대해 먼저 알아보자.
ERC-2771 프로토콜
ERC-2771은 위에서 설명한 메타트랜잭션의 한계를 해결하기 위해, 만약 스마트컨트랙트를 호출한 트랜잭션이 메타 트랜잭션인 경우, 함수를 호출한 Calldata의 마지막 20 bytes를 msg.sender 대신 사용하자는 규칙 체계를 제안한다. 오픈제플린에서 제공하는 ERC2771Context.sol를 통해 _msgSender()를 알아보자.
function _msgSender() internal view virtual override returns (address sender) {
if (isTrustedForwarder(msg.sender)) {
// The assembly code is more direct than the Solidity version using `abi.decode`.
/// @solidity memory-safe-assembly
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
return super._msgSender();
}
}assembly로 작성된 _msgSender()의 내부 로직은 트랜잭션에 담긴 msg.data의 마지막 20 bytes를 return한다. 먼저 assembly에 작성된 Opcode를 알아보자.
sub(calldatasize(), 20)
calldatasize()는 현재 트랜잭션의 msg.data의 길이(length)를 반환한다. 이때 길이는 bytes단위로 1 bytes에 1 length이다 (ex 0x112233 ⇒ 3 length). sub(a,b)는 a - b 결과를 리턴하는 간단한 연산을 수행한다. 여기까지 봤을 때, 위 코드의 sub(calldatasize(), 20)는 msg.data.length - 20 bytes를 리턴한다.
calldataload( … )
calldataload(i)는 msg.data의 offset i ~ 32bytes 데이터를 리턴한다. 만약, i ~ 32bytes 범위 내에 데이터가 없는 경우 Zero Padding이 적용되어 0으로 채워진다.
우리는 sub(calldatasize(), 20)가 msg.data.length - 20 bytes 라는걸 알고 있다. 그렇다면 calldataload(sub(calldatasize(), 20))는 msg.data의 마지막 20 bytes 데이터 + 0으로 가득찬 12 bytes를 갖고온다.
만약, calldata의 마지막 20 bytes가 주소0xd644352A429F3fF3d21128820DcBC53e063685b1 라면, calldataload(sub(calldatasize(), 20))의 결과는 Zero Padding이 적용되어0xd644352A429F3fF3d21128820DcBC53e063685b1000000000000000000000000가 된다.
shr(96, … )
shr(shift,value)는 비트연산자의 shift right( value >> shift )를 수행한다. _msgSender()에서 shift 값으로 96을 넣은 이유는 오른쪽에 붙은 Zero Padding을 왼쪽으로 96bits(=12 bytes)옮기기 위함이다. 때문에 위 예시를 다시 사용하자면, 0xd644352A429F3fF3d21128820DcBC53e063685b1000000000000000000000000는 0x000000000000000000000000d644352A429F3fF3d21128820DcBC53e063685b1가 된다. 해당 데이터는 address(0x000000000000000000000000d644352A429F3fF3d21128820DcBC53e063685b1)로 변환되어 최종적으로 마지막 returns 데이터 address sender는 0xd644352A429F3fF3d21128820DcBC53e063685b1가 된다.
assembly코드라 상당히 어려운 내용처럼 보이지만, 결국 Calldata의 마지막 20 bytes를 address 타입으로 추출하는 로직이라고 한 줄 정리할 수 있다. 이런 로직을 구현한 이유는 결국 msg.sender 대신 Calldata의 마지막 20 bytes를 사용하자고 ERC-2771에서 제안한 프로토콜을 구현하기 위함이다.
ERC-2771 보안
ERC-2771 표준에서 제안한 내용은 메타 트랜잭션을 위한 “보안” 프로토콜이다. 이렇게 보안(security)라는 키워드가 들어간 이유는 ERC-2771에서 제시한 프로토콜은 충분히 조작될 수 있기 때문이다. 아래와 같이 트랜잭션 데이터를 만들어서 전송한다고 가정해보자.
const tx = {
from: "...",
to: , "smart contract 주소",
nonce: "...",
data: `0x......${address}`,
}
await provider.sendTransaction(tx)ERC-2771의 규칙 체계가 적용된 스마트컨트렉트를 호출하는 Calldata의 마지막에 지갑주소를 넣어서 전송하게되면, 실제 트랜잭션을 발생시킨 지갑주소는 msg.sender가 아닌 마지막 20 bytes가 되어 호출자를 조작하는 스푸닝 공격(Spoofing Attack)이 발생하게 된다. 이러한 문제를 해결하기 위해 신뢰할 수 있는 컨트랙트(TrustedForwarder)의 트랜잭션만 ERC-2771 프로토콜이 적용될 수 있도록 분기 처리를 해준다.
abstract contract ERC2771Context is Context {
address private immutable _trustedForwarder;
constructor(address trustedForwarder) {
_trustedForwarder = trustedForwarder;
}
function isTrustedForwarder(address forwarder) public view virtual returns (bool) {
return forwarder == _trustedForwarder;
}
function _msgSender() internal view virtual override returns (address sender) {
if (isTrustedForwarder(msg.sender)) {
// The assembly code is more direct than the Solidity version using `abi.decode`.
/// @solidity memory-safe-assembly
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
return msg.sender;
}
}
}위에 오픈제플린에서 개발한 ERC2771Context.sol 전체 코드를 보면 생성자(constructor)로 _trustedForwarder 주소를 받아 저장한다. 그리고 _msgSender() 함수 첫줄에서 msg.sender가 신뢰할 수 있는 컨트랙트(TrustedForwarder)인 경우를 검사하여 ERC2771 프로토콜 로직을 실행할지, 아니면 그냥 msg.sender를 쓸지 결정하게 된다. 이런식으로 ERC-2771 프로토콜은 프로젝트에서 정한 컨트랙트로 부터 받는 트랜잭션을 메타 데이터라 정의하는 보안 프로토콜을 사용하게 된다.
신뢰할 수 있는 컨트랙트(TrustedForwarder) : ERC-2770
ERC-2771이 말하는 신뢰할 수 있는 컨트랙트(TrustedForwarder)는 바로 메타 트랜잭션을 실행시키는 ERC-2770가 구현된 컨트랙트를 뜻한다.
// ERC2770 일부
...
(bool success, bytes memory returndata) = req.to.call{gas: req.gas, value: req.value}(
abi.encodePacked(req.data, req.from)
);
...ERC-2770에서 가스비 대납을 요청하는 메타 트랜잭션을 호출시키는 코드는 위와 같이 solidity의 call()함수를 사용한다. 여기서 call(a)의 인자(args)로 받는 Calldata를 보면 대납을 요청한 사용자의 지갑주소가 abi.encodePacked()를 통해 Calldata의 마지막 20 bytes에 붙게 된다.
ERC-2770의 자세한 내용 및 코드 설명은 “ERC-2770 : 가스비 대납을 위한 메타 트랜잭션(Meta-Transactions)” 에서 다뤘으니 참고하면 좋을 것 같다.
이처럼 ERC2771의 불안정한 프로토콜을 실현하기 위해, 신뢰할 수 있는 컨트랙트(ERC-2770)의 로직에 메타 트랜잭션의 호출자 주소를 강제로 넣게 만들어 마지막 20 bytes를 조작할 수 없게 만들었다.
ERC-2770과 ERC-2771은 서로의 불안정한 부분을 보완하여 제 3자가 가스비 대납해 줄수 있는 솔루션을 구현했다.
마무리
현재 Web3 생태계는 Web2 사용자가 쉽게 참여할 수 있도록, 번거로운 진입 장벽을 개선하기 위해 노력하고 있다. 이런 취지에서 가스비 대납은 서비스 런칭 초반에 많은 유저들을 유입시키고 사용자 경험을 개선하는 솔루션으로 주목받고 있다. 메타 트랜잭션 외에도, 앞으로 더 많은 혁신과 발전이 하여 블록체인 서비스가 더욱 번창하길 기대해 본다.