Proxy pattern
이더리움 스마트 컨트랙트의 가장 큰 단점은 컨트랙트가 배포된 이후에는 소스코드를 수정할 수 없다는것이다. 기존의 중앙화된 서비스들의 대부분은 지속적으로 새로운 기능이 추가되고, 발견되는 버그들의 픽스가 들어가는 등 업데이트가 되지만, 전통적인 이더리움 개발 패턴에서는 이런것들은 불가능하다.
프록시 패턴은 이러한 업그레이드를 ‘어느정도’ 가능하게 해준다. 키 컨셉은 프록시 컨트렉트가 유저로부터의 access point가 되고, 실제 구현한 logic이 담긴 컨트랙트는 프록시 컨트렉트가 참조한다다. 따라서 logic 컨트랙트에 새로운 기능을 추가하거나 변경하여 새로운 version2 를 만들었다면, 프록시 컨트렉트가 새로운 컨트렉트를 가르키도록 해주기만 하면 된다.
솔리디티의 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 Patterns과 Proxy 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 을 발생시킨다.
OpenZeppelin의 “unstructuted stroage” 프록시 패턴에서는 이 _implementation
변수를 slot1에 저장하지 않고, Randomized 된 slot에 저장한다. 따라서 Implementation(logic contract)와 storage가 겹칠 가능성은 없다(slot은 2^256만큼 있으므로 수학적으로 매우 낮음).
Storage Collisions Between Implementation Versions
_implementaion
변수와의 collision외에도 upgrade version 간의 collision도 발생할 수 있다. 이전 버전의 implementaion에서 사용하고 있는 slot에 새로운 변수를 추가하거나, 변수간의 위치를 변경한다면 collision이 발생한다. 따라서 새로운 stoage variable의 변경은 extension하는 방향으로 upgrade가 되어야 한다.
Ref
- https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies
- https://docs.openzeppelin.com/learn/upgrading-smart-contracts
- https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable