Proxy-patterns and Upgradable contract in Solidity

Proxy-patterns and Upgradable contract in Solidity

Proxy pattern

이더리움 스마트 컨트랙트의 가장 큰 단점은 컨트랙트가 배포된 이후에는 소스코드를 수정할 수 없다는것이다. 기존의 중앙화된 서비스들의 대부분은 지속적으로 새로운 기능이 추가되고, 발견되는 버그들의 픽스가 들어가는 등 업데이트가 되지만, 전통적인 이더리움 개발 패턴에서는 이런것들은 불가능하다.

프록시 패턴은 이러한 업그레이드를 ‘어느정도’ 가능하게 해준다. 키 컨셉은 프록시 컨트렉트가 유저로부터의 access point가 되고, 실제 구현한 logic이 담긴 컨트랙트는 프록시 컨트렉트가 참조한다다. 따라서 logic 컨트랙트에 새로운 기능을 추가하거나 변경하여 새로운 version2 를 만들었다면, 프록시 컨트렉트가 새로운 컨트렉트를 가르키도록 해주기만 하면 된다.

Drawing

솔리디티의 fallback function과 delegatecall 은 이러한 프록시 컨트랙을 가능하게 해준다.

  • fallback function: 스마트 컨트랙트 내에 존재하지 않는 함수를 호출하면 컨트랙트 내 구현된 fallback function이 대신 호출된다. fallback function은 컨트랙트 내 함수의 이름 없이 아래와 같이 구현한다.
contract myContract {
  function () {
    int a = 10;
  }
}
  • delegatecall: 솔리디티의 저수준 함수로 호출하려는 컨트랙트의 함수를 현재 컨트랙트의 환경에서 실행한다. 즉, 호출하는 함수에 의한 스토리지 변화는 현재 컨트랙트(프록시 컨트랙트)에서의 스토리지에 영향을 미치고 로직컨트렉트의 스토리지에는 영향을 미치지 않는다.

위와 같은 두 기능을 사용해서 프록시 컨트랙트에 다음과 같은 코드가 들어가있다면(실제로 OpenZeppelin이 제공하는 proxy), 이 코드는 어떤 function call이 들어오던 그것을 파라미터와 함께 logic 컨트랙트로 포워딩 해 줄 것이다.

assembly {
  let ptr := mload(0x40)

  // (1) copy incoming call data
  calldatacopy(ptr, 0, calldatasize)

  // (2) forward call to logic contract
  let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
  let size := returndatasize

  // (3) retrieve return data
  returndatacopy(ptr, 0, size)

  // (4) forward return data back to caller
  switch result
  case 0 { revert(ptr, size) }
  default { return(ptr, size) }
}

위의 저수준 코드가 어떻게 동작하는지는 Proxy PatternsProxy Upgrade Pattern 문서에 자세하게 설명되어 있다.

Writing Upgradable Contracts using OpenZeppelin

위와 같은 이유로, 프록시 기반의 upgraadable contract 에서는 constructor를 쓸 수 없다(contructor를 proxy를 통해 대신 불러줄 수 없으니). 따라서 constructor에서 해주어야 하는 것들을 regular function에서 대신 해주어야 한다.

contract MyContract {
    uint256 public x;

    function initialize(uint256 _x) public {
        x = _x;
	}
}

regular function은 contructor와 다르게 여러번 호출될 수 있기에 이를 막기 위해 OpenZeppelin은 initialier modifier 를 구현해 놓은 Initializable contract를 제공한다.

constructor은 호출될 때 상속 구조가 있으면, 해당 contract의 base contract의 constructor까지 호출해 준다. 하지만 이를 regular function으로 대체하였기 때문에 아래와 같이 명시적으로 base contract의 initialize function을 호출 해 주어야 한다.

// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract BaseContract is Initializable {
    uint256 public y;

    function initialize() public initializer {
        y = 42;
    }
}

contract MyContract is BaseContract {
    uint256 public x;

    function initialize(uint256 _x) public initializer {
        BaseContract.initialize(); // Do not forget this call!
        x = _x;
    }
}

Unstructured Stoage Proxies

프록시 구조를 통해 storage variable들은 proxy contrat에 저장이 된다. 하지만 procxy contract는 logic contract를 참조하기 위한 address 타입의 변수 _implementation를 추가적으로 가지고 있어야하는데, 만약 slot1에 이 variable이 저장된다면 다음과 같은 stroage collision 을 발생시킨다.

Drawing

OpenZeppelin의 “unstructuted stroage” 프록시 패턴에서는 이 _implementation 변수를 slot1에 저장하지 않고, Randomized 된 slot에 저장한다. 따라서 Implementation(logic contract)와 storage가 겹칠 가능성은 없다(slot은 2^256만큼 있으므로 수학적으로 매우 낮음).

Drawing

Storage Collisions Between Implementation Versions

_implementaion 변수와의 collision외에도 upgrade version 간의 collision도 발생할 수 있다. 이전 버전의 implementaion에서 사용하고 있는 slot에 새로운 변수를 추가하거나, 변수간의 위치를 변경한다면 collision이 발생한다. 따라서 새로운 stoage variable의 변경은 extension하는 방향으로 upgrade가 되어야 한다.

Drawing

Ref

  1. https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies
  2. https://docs.openzeppelin.com/learn/upgrading-smart-contracts
  3. https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable