제목: ERC2771

누군가가 트랜잭션을 보낼 때, 가스 요금을 지불해야 한다. 이는 해당 계정에 이더를 조금 예치해놔야 하는 것을 의미하고, 이는 생태계 확장에 있어서 굉장히 큰 걸림돌이 된다.

그렇기에 이를 막기 위해서 relayer라는 제 3자(혹은 CA)가 대신 가스비를 지불할 수 있게끔 만든 것이 ERC-2771이다.

보통 어떻게 진행되냐면.

EIP-712에서 사용하는 서명값 ecrecover을 통해서 쩐주가 아니라 돈 받는 사람 혼자서 온체인과 상호작용해서 트랜잭션을 가져오는 방식을 취한다.

Untitled

주체는 4개고, ERC에서 표준으로 명명한 건 opt부분뿐임.

transaction signer: 이게 우리고요… 트랜잭션에 서명해서 gas relay로 보내면 됨

gas relay: 여기서 우리 트랜잭션에 가스를 추가해서 trusted Forwarder에게 보냄

trusted forwarder: 여기서 ecrecover써서 서명이 제대로 되어있는지 확인함. 이쯤되면 서명방정식 익히는 게ㄹㅇ 중요할 듯 진짜 개쌉 만능이네 ㅋㅋ;;

Recipient Contract: tf가 검증한 컨트랙트를 3가지를 추가로 검증한 다음에 execute시키는데, 3가지는 다음과 같다.

-Forwarder가 trusted되어 있는지. 쉽게 말해서 자기랑 연결된 forwarder가 tx를 전달해준건지를 검사한다.

-Transaction Signer address를 calldata 시작점으로부터 20바이트 읽어들여 해당 16진수를 트랜잭션 발송자인 sender로 사용한다.

-만약 위 두개의 조건을 충족시키지 못했을 경우, 해당 메시지는 그냥 단순 트랜잭션이라는 뜻이므로 sender를 msg.sender로 지정한다. (한마디로 tx 쏜 사람 말하는 거.) 왜 이런 제한조건이 붙었냐면,,,recipient 컨트랙트를 개발자가 따로 호출해서 변수를 수정하거나 해야 할 수도 있으니까,,, 이런 경우 이건 가스비 대신 낸거 확인 좀 해달라는 사용자의 메시지하고는 구분되어서 실행해야 하니까 ㅇㅇ

CF)

calldata에서 20바이트를 읽어오는 건 calldataload opcode기능을 사용한다. 해당 고수준 언어에 대응되는 저수준 언어의 동작과, calldata나 storage, memory같은 data저장공간 관리에 대한 내용은 다른 문서에서 다룰 예정이다.) 굳이 이 이야기를 꺼낸 이유는 밑에 구현된 코드에서 ASSEMBLY가 사용되기 때문이다;;;

recipient 컨트랙트 코드 보면 trusted forwarder주소는 immutable변수에다가 저장해놓거든요? 그래서 못바꿈. ㅇㅇ

오픈제플린이 구현해준 ERC-2771 탬플릿을 보면, ERC-2771 Forwarder에서

function isTrustedForwarder(address forwarder) external view returns(bool);

이 구문이 있는 이유는 간단하다. 해당 컨트랙트가 TrustedForwarder라는 것을 알려야 하는 상황이 실제 프로젝트에서 만들 때 필요하기 때문.

EIP-2771에서 가져온 문서의 번역본이다. 읽으면 바로 이해가 갈듯.

수신자 계약이 이 계약이 네이티브 메타 트랜잭션을 지원한다는 것을 아는 특정 프런트엔드에서 사용되지 않는 한 , 사용자에게 계약과 상호 작용하기 위해 메타 트랜잭션을 사용할 수 있는 선택권을 제공할 수 없습니다. 따라서 수신자가 메타 트랜잭션을 지원한다는 것을 세상에 알릴 수 있는 메커니즘이 필요합니다.

이는 특히 Web3 지갑 수준에서 메타 거래를 지원하는 데 중요합니다. 이러한 지갑은 사용자가 상호 작용하고자 하는 수신자 계약에 대해 반드시 아무것도 알지 못할 수도 있습니다.

수신자 는 다양한 인터페이스와 기능(예: 거래 일괄 처리, 다양한 메시지 서명 형식)을 갖춘 포워더를 신뢰할 수 있으므로 , 지갑에서 신뢰할 수 있는 포워더를 발견할 수 있도록 허용해야 합니다.

(약간 특정 기능을 구현한 컨트랙트들은 다 이렇게 이거 이 기능 구현되어있어요! 를 증명하는 함수를 하나씩 끼워넣는데, 이걸 위해서가 아닐까 싶다.)

istrustedforwarder는 가스 제한 5만 가스가 있다는데, 이거 사실 강제할 방법 없거든요? ㅋㅋ;;;;abstract contract이런거 쓰면 가능하기는 한데 보통 다른 이유들 때문에 인터페이스를 더 많이 사용해요. (자세한 내용은 밑의 CF에 적힌 인터페이스 vs abstract contract참조.)

사실 저희가 EIP만 볼 건 아니라서 ㅋㅋ;; 오픈제플린이 이거 탬플릿 가져다가 쓰라고 만들어놨으니까 한번 보죠.

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v5.0.1/contracts/metatx/ERC2771Forwarder.sol

사실 서명 탬플릿은 뭘 써도 상관은 없는데 eip712꺼 가져다가 쓰자고 권장하기도 했고 해서 얘들도 가져다가 쓴 거 같음.

들어가기 전에.

릴레이어가 포워드 요청을 제출하면 요청에 명시된 가스 금액의 최대 100%를 지불할 의향이 있어야 합니다. 이 계약은 이 가스에 대한 어떠한 종류의 보복도 구현하지 않으며 릴레이어가 서명자를 대신하여 실행 비용을 지불하는 대역 외 인센티브가 있다고 가정합니다. 종종 릴레이어는 사용자 인수 비용으로 간주되는 프로젝트에 의해 운영됩니다.

가스 비용을 지불하겠다고 제안함으로써 릴레이어는 공격자가 예상 대역 외 인센티브와 일치하지 않는 다른 목적으로 가스를 사용할 위험이 있습니다. 릴레이어를 운영하는 경우 대상 계약 및 기능 선택기를 허용 목록에 추가하는 것을 고려하세요. 특히 ERC-721 또는 ERC-1155 전송을 릴레이하는 경우 data임의의 코드를 실행하는 데 사용될 수 있으므로 필드 사용을 거부하는 것을 고려하세요.

릴레이어를 사용자가 확실하게 믿을 수 있는지를 생각해야 함.

contract ERC2771Forwarder is EIP712, Nonces {
    using ECDSA for bytes32;
 
    struct ForwardRequestData {
        address from;
        address to;
        uint256 value;
        uint256 gas;
        uint48 deadline;
        bytes data;
        bytes signature;
    }
 
    bytes32 internal constant _FORWARD_REQUEST_TYPEHASH =
        keccak256(
            "ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,uint48 deadline,bytes data)"
        );
        
     function verify(ForwardRequestData calldata request) public view virtual returns (bool) {
        (bool isTrustedForwarder, bool active, bool signerMatch, ) = _validate(request);
        return isTrustedForwarder && active && signerMatch;
    }
    function execute(ForwardRequestData calldata request) public payable virtual {
        // We make sure that msg.value and request.value match exactly.
        // If the request is invalid or the call reverts, this whole function
        // will revert, ensuring value isn't stuck.
        if (msg.value != request.value) {
            revert ERC2771ForwarderMismatchedValue(request.value, msg.value);
        }
 
        if (!_execute(request, true)) {
            revert Address.FailedInnerCall();
        }
    }
     function executeBatch(
        ForwardRequestData[] calldata requests,
        address payable refundReceiver
    ) public payable virtual {
        bool atomic = refundReceiver == address(0);
 
        uint256 requestsValue;
        uint256 refundValue;
 
        for (uint256 i; i < requests.length; ++i) {
            requestsValue += requests[i].value;
            bool success = _execute(requests[i], atomic);
            if (!success) {
                refundValue += requests[i].value;
            }
        }
 
        // The batch should revert if there's a mismatched msg.value provided
        // to avoid request value tampering
        if (requestsValue != msg.value) {
            revert ERC2771ForwarderMismatchedValue(requestsValue, msg.value);
        }
 
        // Some requests with value were invalid (possibly due to frontrunning).
        // To avoid leaving ETH in the contract this value is refunded.
        if (refundValue != 0) {
            // We know refundReceiver != address(0) && requestsValue == msg.value
            // meaning we can ensure refundValue is not taken from the original contract's balance
            // and refundReceiver is a known account.
            Address.sendValue(refundReceiver, refundValue);
        }
    }
 

인터페이스 vs abstract contract

EIP(Ethereum Improvement Proposal)가 주로 인터페이스로 구현되는 이유는 여러 가지가 있습니다. 이에 대해 자세히 설명드리겠습니다:

  1. 유연성:
    • 인터페이스는 구현 세부 사항을 강제하지 않습니다.
    • 개발자들이 자신의 요구사항에 맞게 자유롭게 구현할 수 있습니다.
  2. 최소한의 제약:
    • 인터페이스는 필요한 함수의 시그니처만 정의합니다.
    • 이는 다양한 구현 방식을 허용합니다.
  3. 상호 운용성:
    • 인터페이스를 통해 다른 컨트랙트들이 표준화된 방식으로 상호작용할 수 있습니다.
    • 이는 생태계 전반의 호환성을 증진시킵니다.
  4. 다중 상속 지원:
    • Solidity에서는 다중 상속이 제한적이지만, 여러 인터페이스를 구현하는 것은 가능합니다.
    • 이를 통해 여러 EIP 표준을 동시에 준수할 수 있습니다.
  5. 가벼운 구조:
    • 인터페이스는 구현 코드를 포함하지 않아 컨트랙트 크기를 최소화합니다.
    • 이는 배포 비용과 가스 사용량을 줄이는 데 도움이 됩니다.
  6. 명확성:
    • 인터페이스는 컨트랙트가 지원해야 하는 기능을 명확히 정의합니다.
    • 이는 개발자들이 표준을 쉽게 이해하고 구현하는 데 도움이 됩니다.
  7. 버전 관리의 용이성:
    • 인터페이스 변경은 기존 구현에 최소한의 영향을 미칩니다.
    • 새로운 버전의 표준을 도입하기 쉽습니다.
  8. 테스트 용이성:
    • 인터페이스를 기반으로 한 표준화된 테스트 suite를 만들 수 있습니다.
    • 이는 다양한 구현체의 호환성을 검증하는 데 유용합니다.
  9. 추상 컨트랙트의 단점 회피:
    • 추상 컨트랙트는 일부 구현을 포함할 수 있어, 특정 구현 방식을 강제할 수 있습니다.
    • 이는 때때로 원하지 않는 제약이 될 수 있습니다.
  10. 커뮤니티 합의:
    • 인터페이스는 기능의 ‘무엇’에 집중하고 ‘어떻게’는 개발자에게 맡깁니다.
    • 이는 보다 넓은 커뮤니티의 합의를 얻기 쉽게 만듭니다.

예를 들어, ERC-20 토큰 표준은 인터페이스로 정의되어 있습니다:

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address recipient, uint256 amount) external returns (bool);
    // ... 기타 함수들 ...
}
 

이러한 접근 방식은 이더리움 생태계의 다양성과 혁신을 촉진하면서도 필요한 표준화를 제공합니다. 물론 일부 EIP는 추상 컨트랙트나 구체적인 구현을 포함할 수 있지만, 대부분의 경우 인터페이스를 통한 정의가 선호됩니다.