ERC-2771 메타 트랜잭션 수신 프로토콜 (Secure Protocol for Native Meta Transactions)

Final · 계정·지갑 · 2020-07-01 작성

ETH가 없는 계정도 제3자가 가스를 대신 내는 메타 트랜잭션으로 컨트랙트를 쓸 수 있도록, 컨트랙트가 신뢰하는 포워더를 통해 요청을 받고 원래 서명자를 알아내는 방법을 정합니다.

왜 필요한가

이더리움에서 컨트랙트를 호출하려면 보내는 계정에 가스비로 쓸 ETH가 있어야 합니다. ETH가 없는 사용자를 위해 제3자가 가스를 대신 내 주는 방식을 메타 트랜잭션(meta transaction)이라고 부릅니다. 사용자는 요청에 서명만 하고 가스 중계자(Gas Relay)가 그 요청을 실제 트랜잭션으로 올립니다. 컨트랙트가 보는 호출자(msg.sender)가 가스를 낸 중계자라는 점이 문제입니다. 메타 트랜잭션을 모르는 컨트랙트는 실제로 서명한 사용자가 누구인지 알 수 없고 중계자가 요청을 위조하거나 바꾸거나 되풀이해도 막을 수 없습니다. 표준 인터페이스가 없으면 지갑이나 앱이 어떤 컨트랙트가 메타 트랜잭션을 받는지, 어떤 포워더를 믿는지 알아낼 방법도 없습니다. 결국 컨트랙트마다 요청을 안전하게 받는 로직을 따로 만들어야 합니다.

구현해야 하는 것

심층 설명

네 가지 역할

이 표준은 네 역할을 구분합니다. 트랜잭션 서명자(Transaction Signer)는 요청에 서명해 가스 중계자에게 보내는 주체입니다. 가스 중계자(Gas Relay)는 체인 밖에서 서명된 요청을 받아 가스를 내고 신뢰 포워더를 거치는 유효한 트랜잭션으로 만듭니다. 신뢰 포워더(Trusted Forwarder)는 수신 컨트랙트가 믿는 컨트랙트로, 요청을 전달하기 전에 서명과 논스를 올바르게 검증할 책임을 집니다. 수신 컨트랙트(Recipient)는 신뢰 포워더를 통해 메타 트랜잭션을 받아들이는 컨트랙트입니다. 가스 중계자는 신뢰받지 않는 제3자이고 신뢰는 포워더 컨트랙트에만 둡니다. 이 표준은 프로토콜 변경 없이 컨트랙트 수준에서만 동작합니다.

서명자 주소를 호출 데이터 끝에 덧붙입니다

신뢰 포워더가 수신 컨트랙트를 호출할 때는 트랜잭션 서명자의 주소 20바이트를 호출 데이터(call data) 맨 끝에 반드시(MUST) 덧붙여야 합니다. 원문은 예시로 to.call.value(value)(abi.encodePacked(data, from)) 형태를 보여 줍니다. 원래 호출 데이터 data 뒤에 서명자 주소 from을 패킹해 이어 붙이는 것입니다. 함수 선택자나 인자 인코딩을 바꾸지 않고 뒤에만 붙이기 때문에, 수신 컨트랙트의 기존 함수 시그니처는 그대로 유지됩니다. 수신 컨트랙트는 호출 데이터 길이가 20바이트 이상이고 호출자가 신뢰 포워더일 때만 마지막 20바이트를 발신자로 해석합니다.

수신 컨트랙트가 서명자를 꺼내는 세 단계

수신 컨트랙트는 세 단계로 원래 발신자를 정합니다. 첫째, 호출한 포워더가 신뢰하는 포워더인지 확인합니다. 신뢰를 어떻게 관리할지는 이 표준의 범위 밖입니다. 둘째, 신뢰하는 포워더라면 호출 데이터의 마지막 20바이트를 꺼내 msg.sender 대신 원래 발신자(sender)로 사용합니다. 셋째, msg.sender가 신뢰 포워더가 아니거나 msg.data가 20바이트보다 짧으면 msg.sender를 그대로 발신자로 씁니다. 따라서 일반 사용자가 직접 호출해도 평소처럼 동작합니다. 신뢰 확인을 빼먹으면 아무 컨트랙트든 주소를 덧붙여 호출해 발신자를 위조할 수 있으므로, 이 확인은 반드시(MUST) 해야 합니다.

지원 여부를 알리는 isTrustedForwarder

특정 프런트엔드가 아닌 일반 지갑은 어떤 컨트랙트가 메타 트랜잭션을 받는지 미리 알 수 없습니다. 또 수신 컨트랙트는 트랜잭션 묶음 처리나 서로 다른 서명 형식처럼 기능이 다른 여러 포워더를 믿을 수도 있어서 지갑이 어떤 포워더가 신뢰받는지 알아낼 길이 필요합니다. 그래서 수신 컨트랙트는 isTrustedForwarder(address forwarder) 함수를 반드시(MUST) 구현해야 합니다. 신뢰하는 포워더면 true, 아니면 false를 돌려주고 어떤 경우에도 실패(revert)로 끝나면 안 됩니다. true를 돌려준 포워더의 요청은 내부에서도 받아들여야 합니다. 이 함수는 체인 위에서 호출될 수 있으므로 가스 제한을 두어야 하며 50,000 가스를 넘지 않는 것이 좋습니다(SHOULD NOT).

msg.sender만으로는 부족한 이유

msg.sender는 누가 트랜잭션에 서명했는지 컨트랙트가 확인하는 값이고 그 무결성은 EVM이 보장합니다. 하지만 메타 트랜잭션에서는 실제 트랜잭션을 보낸 쪽이 가스 중계자이므로, 메타 트랜잭션을 모르는 컨트랙트는 요청이 서명자가 아니라 중계자에게서 온 것으로 봅니다. 안전한 프로토콜은 중계자가 서명자의 요청을 위조하거나 수정하거나 복제하지 못하게 해야 합니다. 이 표준은 그 검증 책임을 포워더에 두고 수신 컨트랙트에는 가장 단순한 인터페이스만 요구합니다. 인터페이스가 포워더 내부 구현에 의존하지 않기 때문에, 수신 컨트랙트는 코드를 바꾸지 않고도 여러 포워더를 지원할 수 있습니다.

참조 구현 읽기

원문의 RecipientExample은 생성자에서 받은 포워더 주소를 immutable 변수 _trustedForwarder에 저장하고 isTrustedForwarder는 인자가 이 주소와 같은지만 비교합니다. _msgSender()는 기본값으로 msg.sender를 쓰다가, msg.data.length가 20 이상이고 isTrustedForwarder(msg.sender)가 true일 때만 어셈블리로 주소를 꺼냅니다. calldataload(sub(calldatasize(),20))으로 호출 데이터 끝 20바이트가 포함된 32바이트 워드를 읽고 shr(96, ...)으로 오른쪽 96비트를 밀어 20바이트 주소만 남깁니다. 업무 함수인 purchaseItem은 msg.sender 대신 _msgSender()가 돌려준 주소를 구매자로 사용합니다. 발신자가 필요한 자리에서 msg.sender 대신 _msgSender()를 쓰는 것이 이 패턴의 핵심입니다.

쓸 때 주의할 점

ERC 원문 · ← ERCs