Deep Dive Into ERC-1167 : 미니멀 프록시(Minimal Proxy)

Wonhyeok Choi
19 min readJul 27, 2023

--

Author

스마트 컨트랙트를 개발하다 보면, 동일한 코드의 컨트랙트를 다수 배포해야 하는 경우가 있다. 필자는 DAO의 기능이 구현된 컨트랙트를 사용자 친화적인 UI를 통해 쉽게 배포할 수 있는 DAO Tooling 서비스를 개발한 적이 있다. 이때, DAO를 원하는 조직 또는 구성원이 서비스를 이용할 때마다 똑같은 코드의 컨트랙트를 계속 배포했다.
Minimal Proxy Contract는 위와 같이 완전히 동일한 로직을 갖는 컨트랙트를 여러 번 배포해야 하는 경우, 배포 비용을 최적화하기 위해 등장한 표준이다.

What is Minimal Proxy?

Minimal Proxy는 컨트랙트 배포의 최적화를 위해 등장했다. 때문에 “Minimal Proxy” 말 그대로 “최소한의 기능만 구현된 프락시 컨트랙트”를 의미한다. 여기서 말하는 “최소한의 기능”“이미 체인 어딘가에 배포된 컨트랙트를 프록시 패턴을 통해 호출하는 기능”을 말한다.

프록시 패턴(Proxy Pattern)

프록시란 컴퓨터 프로그래밍에서 메인 서버와 이어주는 중간다리 역할을 의미한다. 이러한 IT 용어에서 파생된 프록시 패턴(Proxy Pattern)은 Solidity에서 활용하는 코딩 방법론 중 하나이며, 프록시 패턴이 적용된 컨트랙트를 프록시 컨트랙트(Proxy Contract)라고 한다.

출처 Openzeppelin Blog(링크)
  • Logic Contract : 실제 로직이 구현된 컨트렉트
  • Proxy Contract : Logic Contract의 코드를 실행하고 Storage의 역할을 하는 컨트렉트

그림처럼 사용자가 프록시 컨트랙트로 어떠한 함수를 호출하면, 프록시 컨트랙트는 로직 컨트랙트로 호출을 넘겨, 실제 로직이 구현된 로직 컨트랙트의 함수를 호출한다. 이때, 로직 컨트랙트는 함수만 호출하고, 컨트랙트에 저장해야 하는 데이터는 프록시 컨트랙트에 저장된다.

이러한 프록시 컨트랙트가 가능한 이유는 바로 Delegate Call 덕분이다. Solidity는 스마트 컨트랙트 내부에서 다른 컨트랙트를 호출할 때, call과 delegatecall 이 두 가지 기능 사용한다. call은 우리가 일반적으로 컨트랙트 함수를 호출할 때 사용하는 기능이지만, delegatecall은 특별한 기능을 갖고 있다.

Delegate call

출처 EIP-1822(링크)

delegate call를 사용하게 되면 다른 컨트랙트의 코드를 사용하지만, 실행환경(Context)은 기존의 delegate call을 실행한 프록시 컨트랙트에서 수행된다. 즉, 프록시 컨트랙트가 로직 컨트랙트를 호출할 때, `delegatecall`을 이용하게 되면 로직 컨트랙트의 코드를 사용하지만, Storage(테이저 저장 공간)는 프록시 컨트랙트를 사용하게 된다.

`delegatecall`을 활용한 프록시 패턴은 실행하는 위치가 그대로 유지되는 것이 핵심이며, Minimal Proxy의 최소한의 기능은 delegatecall을 활용한 프록시 패턴으로 구현되었다.

Minimal Proxy Factory

위 사진은 실제 서비스에서 사용되는 Minimal Proxy의 아키텍처이다. Minimal Proxy Factory는 factory pattern으로 구현되었으며, Minimal Proxy Contract를 배포하는 기능을 가진 컨트랙트이다.

위와 같은 아키텍처를 사용하는 경우, Factory Contract와 Logic Contract를 배포한다. 그리고 필요할 때마다 Factory에서 Minimal Proxy Contract를 복제하고, 프록시 패턴을 통해 Logic Contract에 구현된 함수를 사용한다.

‘복제’의 의미로 OpenZeppelin에서는 Minimal Proxy Factory를 Clone.sol로 정의하여 구현했다.

Factory Pattern

스마트 컨트렉트를 배포하기 위해 트랜잭션을 작성할 때, 트랜잭션 메시지의 필드 to에 Zero address를, data에 컨트랙트 코드를 컴파일 시킨 바이트 코드를 넣어서 보낸다. 그러면 EVM은 해당 트랜잭션의 바이트 코드를 통해 컨트랙트 배포한다.

하지만, Factory Pattern을 활용하면 Solidity를 통해 컨트랙트 내부에서도 컨트랙트를 배포할 수 있는 기능을 만들 수 있다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract Car {
address public owner;


constructor(address _owner) payable {
owner = _owner;
}
}

contract CarFactory {
Car[] public cars;

function create(address _owner) public {
Car car = new Car(_owner);
cars.push(car);
}
}

Factory Pattern는 다양한 방법으로 구현할 수 있지만, 가장 간단하고 쉽게 이해할 수 있는 방법은 Solidity new를 사용하는 것이다. 예시 처럼, CarFactory의 create() 함수 안에 new 연산자를 사용하면 Car 컨트랙트를 바이트 코드로 컴파일하여 배포할 수 있다. 이처럼 스마트 컨트랙트 내에서 다른 컨트랙트 배포하는 로직을 Factory Pattern이라고 한다.

Minimal Proxy Factory에는 더욱 최적화된 로직을 구현하기 위해 inline-assembly의 create 또는 create2를 사용한다. 해당 내용은 여기서 다루진 않지만, OpenZeppelin에 만든 Clone.sol를 참고하자.

Minimal Proxy : Simple Code

지금까지 Proxy Pattern과 Factory Pattern에 대해 알아봤다. 지금부터는 Minimal Proxy의 기능에 대해 알아보자.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract MinimalProxy {
address constant logicContract = "0xbebebebebebebebebebebebebebebebebebebebe";

function delegate() external returns (bytes memory) {
(bool success, bytes memory data) = logicContract.delegatecall(msg.data);
require(success);

return data;
}
}

Minimal Proxy를 이해하기 쉽게 Solidity 코드로 구현하면 위와 같다. 위에서 언급한 대로 최소한의 기능만 구현하기 위해, delegate() 함수 안에 delegatecall을 활용한 프록시 패턴 딱 하나만 구현되어 있다. 모든 실행을 고정된 로직 컨트랙트로 delegatecall을 하고, 그 결과를 반환하는 것이 전부이다.

하지만 Minimal Proxy는 극단적으로 코드를 최적화하기 위해 동일한 기능을 수행하는 EVM 바이트 코드로 구현되어있다. ERC-1167에서 정의한 실제 Minimal Proxy 코드는 아래와 같다.

3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

📌 Solidity로 구현된 예시 코드를 컴파일한다고 해서 위와 같은 바이트코드로 변환되지 않는다. 예시 코드는 단지 바이트코드로 되어있는 코드를 이해하기 쉽게 구현한 예시일 뿐이다.

Solidity는 사람이 보고 이해할 수 있는 high-level 언어지만, 코드를 실행하는 EVM은 Solidity를 읽지 못한다. 때문에, 먼저 Solidity 코드를 컴파일하여 바이트 코드로 변환한 후, EVM 통해 코드를 실행(Runtime) 시킨다. 이처럼 바이트 코드는 프로그램을 위해 탄생한 low-level 언어이기에, 사람은 봐도 쉽게 이해할 수 없다.

하지만 바이트 코드는 이더리움에서 정의한 Opcode에 의해 동작하며, 각각의 Opcode는 ethervm.io에 설명과 함께 잘 정의되어 있다. 이를 보고 바이트코드를 번역하면서 차근차근 하나씩 깊게 이해해 보자.

Deep Dive into Minimal Proxy

지금부터 나오는 내용은 EVM의 Opcode 동작 방식과 스마트 컨트랙트의 Stack & Memory에 대한 지식이 부족하면 이해하기 어렵다. 하지만 Opcode 부분은 넘어가고, 각각의 동작 과정에 적어둔 설명만 봐도 Minimal Proxy에 대해 충분히 이해할 수 있으니 참고하여 보면 좋을 것 같다.

Creation Code vs Runtime Code

ERC-1167에서 정의한 Minimal Proxy 컨트랙트 코드는 두가지 역할로 나누어 진다.

  • Creation Code : 컨트랙트 배포를 수행하는 코드
  • Runtime Code : 컨트랙트 배포 후 사용되는 런타임 바이트코드

🔍 Creation Code
Creation Code는 Init Code라고도 불리며, Runtime 바이트코드를 메모리에 복사하여 리턴한다. Solidity의 생성자 함수(constructor)도 여기서 호출된다.

출처 ethereum docs (링크)

스마트컨트랙트 주소에는 위 이미지와 같이 4가지 필드가 존재한다. 리턴된 Runtime 바이트코드는 컨트랙트의 code hash라는 공간에 저장되는데, 실제 스마트 컨트렉트를 동작하는 기능이 저장되어 있다.

🔍 Runtime Code
Runtime Code는 실제 스마트 컨트랙트를 동작하는 기능이 바이트코드 형태로 변환된 것이다. Solidity로 구현된 함수가 바이트 코드 형태로 저장되어 있고, 사용자가 컨트랙트로 트랜잭션을 보내면 code hash에 있는 Runtime 바이트 코드를 통해 동작하게 된다.

contract Caller {
function call(address implement) public view returns(bytes memory result) {
result = implement.code;
}
}

실제 Minimal Proxy Contract의 code hash를 조회하면 Logic Contract 주소(bebebebebebebebebebebebebebebebebebebebe)를 제외하고 ERC-1167에서 정의한 바이트 코드와 동일하다.

Deep Dive into Runtime Code

지금부터는 동작의 순서대로 바이트코드를 해석할 것이다. EIP-1167의 Opcode는 EVM의 Stack과 Memory만 활용한다. 동작 순서대로 Stack과 Memory에 저장되는 구조를 그리면서 보면 쉽게 이해할 수 있다.

만약 바이트 코드가 EVM의 Stack과 Memory에 그려지지 않는다면, Openzeppelin에서 작성한 글을 같이 보면서 읽으면 좋을 것 같다. 여기선 나름 동작의 순서대로 Stack과 Memory의 상태를 테이블로 잘 표현했다.

다시 말하지만 EVM의 Opcode 동작 방식과 스마트 컨트랙트의 Stack/Memory를 잘 모른다면 설명 부분만 보고 각 순서에서 어떤 동작을 하는지만 이해하자.

Creation Code는 잠시 두고, 먼저 사용자가 Minimal Proxy 컨트랙트로 트랜잭션을 보내면 동작하는 Runtime Code에 대해 알아보자.

# STEP 1 . Get the call data : 363d3d37

설명 : 사용자가 Minimal Proxy 컨트랙트로 트랜잭션을 보냈을 때, 가장 처음 동작하는 부분이다. 여기서 call data란 호출(call)에 필요한 데이터(data)로 사용자가 컨트랙트 함수를 실행하기 위해 트랜잭션에 포함시킨 데이터를 의미한다. solidity에서 msg.data로 calldata를 읽을 수 있지만, Minimal Proxy에서는 역시 최적화를 위해 바이트코드로 구현했다. 이 과정을 통해 사용자가 전송한 call data를 메모리에 저장한다.

  1. CALLDATASIZE [36] : msg.data(call data)의 length(길이)를 bytes형태로 반환한다.
  2. RETURNDATASIZE [3d] : 가장 마지막 리턴 데이터 길이를 bytes형태로 반환한다.
    📌 리턴된 데이터가 없는 경우 0을 반환한다.
  3. RETURNDATASIZE [3d] : 2. 와 동일
  4. CALLDATACOPY(t, f, s)[37] : msg.dataf번째 위치에서 s개의 바이트를 읽어 메모리의 t번째 위치에 저장한다.

# STEP 2. Delegating the call : 3d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af4

설명 : 이번 과정에서는 call data를 Logic Contract로 delegate call 동작하는 부분이다.

  1. RETURNDATASIZE [3d] : 가장 마지막 리턴 데이터 길이를 bytes형태로 반환한다.
  2. RETURNDATASIZE [3d] : 1.와 동일
  3. RETURNDATASIZE [3d] : 1. 와 동일
  4. CALLDATASIZE [36] : msg.data(call data)의 length(길이)를 bytes형태로 반환한다.
  5. RETURNDATASIZE [3d] : 1.와 동일
  6. PUSH20 bebe… [73 bebe…bebe] : 스택 맨 위(top)에 Logic Contract 주소(bebe…bebe) 20바이트를 추가한다.
  7. GAS[5a] : 남아있는 가스를 반환한다.
  8. DELEGATECALL(g, a, in, insize, out, outsize) [f4] : context(msg.sender,msg.value,this) 변화 없이 가스 g를 소모하여 다른 컨트랙트 a의 함수를 호출한다. **in**은 msg.data값의 메모리상의 위치, **insize**는 해당 인풋의 길이를 말하며, 메모리의 in ~ in + insize — 1 위치의 값(msg.data)를 사용한다. 만약 위 함수가 값을 반환(return)한다면 메모리의 out ~ out + outsize — 1 에 해당 반환값을 저장한다.
    📌 DELEGATECALL은 동작 후, SUCCESS라는 이름의 값을 스택의 top에 추가한다. 여기서 SUCCESS가 0이면 실패, 0이 아니면 성공하게된 것이다.

# STEP 3. Get the result of an external call : 3d82803e

설명 : 이번 과정에서는 # STEP 2 과정을 통해 호출한 delegate call의 결과값을 얻어 메모리에 저장한다.

  1. RETURNDATASIZE [3d] : 가장 마지막 리턴 데이터 길이를 bytes형태로 반환한다.
  2. DUP3 [82] : 스택의 3번째 값을 복사해 스택의 top에 추가한다.
  3. DUP1 [80] : 스택의 1번째 값을 복사해 스택의 top에 추가한다.
  4. RETURNDATACOPY(t, f, s) [3e] : 가장 마지막 리턴 값의 f번째 위치에서 s개의 바이트를 읽어 메모리의 t번째 위치에 저장한다.
    📌 여기서 가장 마지막 리턴 값은 delegate call의 결과값이다.

# STEP 4. Final stage: return or revert : 903d91602b57fd5bf3

설명 : 이번 과정에서는 메모리에 저장된 delegate call의 결과값을 보고 반환(return)할것인지, 리버트(revert)할 것인지 결정을 하게 된다.

우리는 2–8 DELEGATECALL을 통해 스택에 SUCCESS라는 변수를 남겼다.

• if SUCCESS = 0 :
DELEGATECALL은 실패한게 되고, 사용자의 트랜잭션을 revert해야한다.
• if SUCCESS != 0 :
DELEGATECALL은 성공한게 되고, 결과값을 return해야한다.

  1. SWAP1 [90] : 스택의 1번째 값과 스택의 top의 값을 교환 한다.
  2. RETURNDATASIZE [3d] : 가장 마지막 리턴 데이터 길이를 bytes형태로 반환한다.
    📌 여기서 가장 마지막 리턴 값은 delegate call의 결과값이다.
  3. SWAP2 [91] : 스택의 2번째 값과 스택의 top의 값을 교환 한다.
  4. PUSH1 2b [60 2b] : 스택 맨 위(top)에 2b(43) 1바이트를 추가한다.
  5. JUMPI (destination, condition) [57] : 조건 condition 에 따라 다음 Opcode를 실행할지, destination 에 해당하는 다음 Opcode로 점프하여 실행할지 결정한다.
  6. REVERT(offset,length) [fd] : 메모리의 offset ~ offset + length 위치에 저장된 데이터와 함께 Revert를 반환한다.
  7. JUMPDEST [5b] : JUMPI의 condition이 TRUE시 동작 순서를 해당 위치로 점프한다.
    📌 단, JUMPI의 destination이 JUMPDEST의 Opcode 포지션 값(2b)일 경우이다.
  8. RETURN(offset,length) [f3] : 메모리의 offset ~ offset + length 위치에 저장된 데이터를 반환하고 runtime(코드 실행)을 종료한다.

Deep Dive into Creation Code

위에서 말한거 처럼 Creation Code는 스마트컨트랙트 배포를 동작하기 위한 바이트 코드이다. Runtime Code를 메모리에 저장하고, 해당 컨트랙트의 code hush에 저장한다. 마찬가지로 동작 순서대로 Stack과 Memory에 저장되는 구조를 그리면서 이해해보자.

# Creation Code : 3d602d80600a3d3981f3

  1. RETURNDATASIZE [3d] : 가장 마지막 리턴 데이터 길이를 bytes형태로 반환한다.
  2. PUSH1 2d [60 2d] : 스택 맨 위(top)에 2d(45) 1바이트를 추가한다.
    📌 지금까지 메모리에 저장된 Runtime Code의 바이트 수는 45이다.
  3. DUP1 [80] : 스택의 1번째 값을 복사해 스택의 top에 추가한다.
  4. PUSH1 0a [60 0a] : 스택 맨 위(top)에 0a(10) 1바이트를 추가한다.
  5. RETURNDATASIZE[3d] : 1.와 동일
  6. CODECOPY(destOffset, offset, length) [39] : 현재 환경에서 실행 중인 컨트랙트의 바이트코드의 offset ~ offset+length만큼을 메모리의 destOffset 위치에 복사한다.
  7. DUP2 [81] : 스택의 2번째 값을 복사해 스택의 top에 추가한다.
  8. RETURN(offset,length) [f3] : 메모리의 offset ~ offset + length 위치에 저장된 데이터를 반환하고 runtime(코드 실행)을 종료한다.

마무리

지금까지 ERC-1167에서 정의한 Minimal Proxy에 대해서 알아봤다. 간단한 동작만 이해한다면 어렵지 않게 이해할 수 있지만, 바이트코드라는 low-level 언어 측면으로 이해하려면 알아야 하는 지식이 굉장히 많다. 그래도 차근차근 순서대로 동작하는 Opcode를 찾아 보면서 이해할 수 있었다. 그 과정을 글로 표현하는 게 쉽지는 않았기에, 내가 이해한 모든 지식이 독자분들에게 제대로 전달되었을지 걱정된다. 궁금한 점에 대해 댓글을 달아주면 성심성의껏 답변을 드리겠다는 약속을 하며 이만 글을 마치겠다.

--

--