로직과 더불어 스토리지에 데이터를 담는 하나의 스마트 컨트랙트가 있을때 버그가 발견되면 블록체인 상에서는 수정이 불가능 하므로 해당 컨트랙트를 수정 후 재배포 해야만한다.
이때 데이터 저장소가 손실될 뿐만 아니라 해당 스마트 컨트랙트의 주소도 손실된다.
이러한 이유로 로직을 담는 컨트랙트와 데이터를 담는 스토리지로 나누어 개발하는 패턴이 제안되게 되었다.
데이터와 로직을 나누는 방식에는 대표적으로 Eternal Storage 및 Proxy가 있다.
pragma solidity 0.8.1;
contract EternalStorage {
mapping(bytes32 => uint) UIntStorage;
function getUIntValue(bytes32 record) public view returns (uint) {
return UIntStorage[record];
}
function setUIntValue(bytes32 record, uint value) public {
UIntStorage[record] = value;
}
mapping(bytes32 => bool) BooleanStorage;
function getBooleanValue(bytes32 record) public view returns (bool) {
return BooleanStorage[record];
}
function setBooleanValue(bytes32 record, bool value) public {
BooleanStorage[record] = value;
}
}
library ballotLib {
function getNumberOfVotes(address _eternalStorage) public view returns (uint256) {
return EternalStorage(_eternalStorage).getUIntValue(keccak256('votes'));
}
function getUserHasVoted(address _eternalStorage) public view returns(bool) {
return EternalStorage(_eternalStorage).getBooleanValue(keccak256(abi.encodePacked("voted", msg.sender)));
}
function setUserHasVoted(address _eternalStorage) public {
EternalStorage(_eternalStorage).setBooleanValue(keccak256(abi.encodePacked("voted", msg.sender)), true);
}
function setVoteCount(address _eternalStorage, uint _voteCount) public {
EternalStorage(_eternalStorage).setUIntValue(keccak256("votes"), _voteCount);
}
}
contract Ballot {
using ballotLib for address;
address eternalStorage;
constructor(address _eternalStorage) {
eternalStorage = _eternalStorage;
}
function getNumberOfVotes() public view returns(uint) {
return eternalStorage.getNumberOfVotes();
}
function vote() public {
require(eternalStorage.getUserHasVoted() == false, "ERR_USER_ALREADY_VOTED");
eternalStorage.setUserHasVoted();
eternalStorage.setVoteCount(eternalStorage.getNumberOfVotes() + 1);
}
}
위 코드와 같이 스토리지를 담당하는 EternalStorage 컨트랙트와
로직을 담당하는 ballotLib 라이브러리
그리고 Ballot 컨트랙트로 구성한 상태라면
컨트랙트에 로직 버그가 발견될 경우 로직 라이브러리만 다시 배포하면 되므로 스토리지를 계속 사용할 수 있게된다.
Eternal Storage 방식의 단점
프록시 패턴 또한 데이터 스토리지와 비지니스 로직을 분리시킬 수 있다.
프록시 패턴은 지속적인 개선 과정을 거쳤고 다양한 상황에 부합하는 다양한 Proxy 패턴이 존재한다.
프록시 컨트랙트 아키텍쳐를 이용하면, 재배포시에 논리적으로 업그레이드된 것처럼 사용할 수 있다.
프록시 패턴을 생성할때 논리를 담당하는 컨트랙트의 주소를 프록시 컨트랙트의 생성 인자로 전달하고
사용자는 프록시 컨트랙트의 논리 함수를 호출하는 식이다.
프록시 컨트랙트에는 논리 함수가 없기 때문에 fallback() 함수(호출된 함수가 현재 컨트랙트에 존재하지 않을때의 처리를 할 수 있음)를 구현하여 생성 인자로 전달된 논리 컨트랙트로 논리 함수를 delegateCall 하게된다.
만약 논리 수정이 필요할 경우 프록시 컨트랙트의 논리 컨트랙트 주소 값을 변경해주는 방식이다.
DelegateCall은 어떤 스마트 컨트랙트(caller, 호출 컨트랙트)가 다른 스마트 컨트랙트(callee, 타겟 컨트랙트)를 호출하는 상황에서, caller의 컨텍스트를 유지하며 호출하는 방식.
예를 들어 EOA에서 컨트랙트 A를 call한 상황이라면, A가 타겟 컨트랙트인 B를 부를 때 B의 함수는 msg.sender가 A인 상황에서 실행된다. 반면 A가 B의 함수를 delegatecall했다면 msg.sender는 EOA가 된다.
DelegateCall을 사용하는 대표적인 예제는 라이브러리 컨트랙트이다.
라이브러리 컨트랙트는 자주 쓰는 유용한 함수의 모음으로 다른 컨트랙트에서 해당 함수를 다시 구현하지 않고 불러 쓸 수 있게 한다. 이 때, 호출 컨트랙트 쪽에서 delegatecall을 통해 호출함으로써 컨텍스트의 전환 없이 라이브러리에 있는 함수들을 이용할 수 있다.
또한 Delegatecall을 사용하면 논리 로직에서 정의된 변수에 저장되는 값들은 논리 로직 컨트랙트가 아닌 프록시 컨트랙트의 슬롯에 저장된다.
따라서 비즈니스 로직과 데이터의 분리가 가능하다.
/**
* Base contract that all upgradeable contracts should use.
*
* Contracts implementing this interface are all called using delegatecall from
* a dispatcher. As a result, the _sizes and _dest variables are shared with the
* dispatcher contract, which allows the called contract to update these at will.
*
* _sizes is a map of function signatures to return value sizes. Due to EVM
* limitations, these need to be populated by the target contract, so the
* dispatcher knows how many bytes of data to return from called functions.
* Unfortunately, this makes variable-length return values impossible.
*
* _dest is the address of the contract currently implementing all the
* functionality of the composite contract. Contracts should update this by
* calling the internal function `replace`, which updates _dest and calls
* `initialize()` on the new contract.
*
* When upgrading a contract, restrictions on permissible changes to the set of
* storage variables must be observed. New variables may be added, but existing
* ones may not be deleted or replaced. Changing variable names is acceptable.
* Structs in arrays may not be modified, but structs in maps can be, following
* the same rules described above.
*/
contract Upgradeable {
mapping(bytes4=>uint32) _sizes;
address _dest;
/**
* This function is called using delegatecall from the dispatcher when the
* target contract is first initialized. It should use this opportunity to
* insert any return data sizes in _sizes, and perform any other upgrades
* necessary to change over from the old contract implementation (if any).
*
* Implementers of this function should either perform strictly harmless,
* idempotent operations like setting return sizes, or use some form of
* access control, to prevent outside callers.
*/
function initialize();
/**
* Performs a handover to a new implementing contract.
*/
function replace(address target) internal {
_dest = target;
target.delegatecall(bytes4(sha3("initialize()")));
}
}
/**
* The dispatcher is a minimal 'shim' that dispatches calls to a targeted
* contract. Calls are made using 'delegatecall', meaning all storage and value
* is kept on the dispatcher. As a result, when the target is updated, the new
* contract inherits all the stored data and value from the old contract.
*/
contract Dispatcher is Upgradeable {
function Dispatcher(address target) {
replace(target);
}
function initialize() {
// Should only be called by on target contracts, not on the dispatcher
throw;
}
function() {
bytes4 sig;
assembly { sig := calldataload(0) }
var len = _sizes[sig];
var target = _dest;
assembly {
// return _dest.delegatecall(msg.data)
calldatacopy(0x0, 0x0, calldatasize)
delegatecall(sub(gas, 10000), target, 0x0, calldatasize, 0, len)
return(0, len)
}
}
}
contract Example is Upgradeable {
uint _value;
function initialize() {
_sizes[bytes4(sha3("getUint()"))] = 32;
}
function getUint() returns (uint) {
return _value;
}
function setUint(uint value) {
_value = value;
}
}
위 첫 번째 프록시 코드는 2016년도에 작성된 오래된 코드로
테스팅이 가능하도록 0.8 버젼으로 수정된 코드는 아래와 같다.
pragma solidity 0.8.1;
abstract contract Upgradeable {
mapping(bytes4 => uint32) _sizes;
address _dest;
function initialize() virtual public;
function replace(address target) public {
_dest = target;
target.delegatecall(abi.encodeWithSelector(bytes4(keccak256("initialize()"))));
}
}
contract Dispatcher is Upgradeable {
constructor(address target) {
replace(target);
}
function initialize() override public {
// Should only be called by on target contracts, not on the dispatcher
assert(false);
}
fallback() external {
bytes4 sig;
assembly { sig := calldataload(0) } // reads data from the msg.data
uint len = _sizes[sig];
address target = _dest;
assembly {
// return _dest.delegatecall(msg.data)
calldatacopy(0x0, 0x0, calldatasize())
let result := delegatecall(sub(gas(), 10000), target, 0x0, calldatasize(), 0, len)
return(0, len) // we throw away any return data
}
}
}
contract Example is Upgradeable {
uint _value;
function initialize() override public {
_sizes[bytes4(keccak256("getUint()"))] = 32;
}
function getUint() public view returns (uint) {
return _value;
}
function setUint(uint value) public {
_value = value;
}
}
Example 컨트랙트는 로직 컨트랙트에 해당한다.
Example을 먼저 배포 후 배포된 주소를 복사하여 Dispatcher의 생성 인자로 사용하여 배포한다.
이후 Example 컨트랙의 함수들을 호출하면 데이터는 Dispatcher 생성자 인자로 전달한 값(논리 컨트랙트 주소)에 의한 데이터 스토리지 스코프 영역에서 처리된다.
만약 로직에 버그가 발견되어 Example을 수정 후 재배포하게 된다면, Dispatcher.replace()를 호출하여 로직의 주소를 변경하면 다시 새롭게 배포된 로직 컨트랙트의 새로운 데이터 스토리지 스코프가 형성되는것이다.
참고:
- assert() : gas를 다 소비한후, 특정한 조건에 부합하지 않으면 에러를 발생시킨다.
- revert(): 조건 없이 에러를 발생시키고, gas를 환불 시켜준다.
- require(): 특정한 조건에 부합하지 않으면 에러를 발생시키고, gas를 환불 시켜준다.
- calldatacopy(t, f, s): msg.data 의 f번째 위치에서 s개의 바이트를 읽어 메모리의 t번째 위치에 저장
- delegatecall(): call()과는 다르게 받는 측의 컨트랙트에서 msg.sender가 EOA 계정으로 캐치된다.
- “:=”: 어셈블리 코드의 대입 연산자
- abi
- **Application Binary Interface**
- ABI는 보통 두 프로그램 모듈의 인터페이스 역할을 하고 데이터를 기계 코드로 인코딩/디코딩 하기 위해 사용한다.
- ABI는 컨트랙트 내의 함수를 호출하거나 컨트랙트로부터 데이터를 얻는데 사용된다. 이더리움 스마트 컨트랙트는 이더리움 블록체인에 배포된 바이트코드다. 컨트랙트 내에 여러 개의 함수가 있을 수 있을 것이다. ABI는 컨트랙트 내의 어떤 함수를 호출할지를 지정하는데 필요하며, 함수가 데이터를 리턴한다는 것을 보장하기 위해 반드시 필요하다.
- fallback(): 대비책 함수로 호출된 함수가 없을때 작동한다. 콜백 함수의 개념과 비슷하다고 볼 수 있다.
- assembly: Solidity에서 EVM의 low level 연산을 수행할 수 있도록 도와준다. msg.data에 접근하거나, 특정 어카운트(컨트랙)의 code를 복사하거나 MLOAD, MSTORE, SLOAD, SSTORE를 통해 메모리 혹은 스토리지에 직접 값을 읽고 쓰는 것이 가능하다.
https://www.youtube.com/watch?v=YpEm9Ki0qLE
Writing upgradable contracts in Solidity
[철학자의 데브콘4 참관기] Trustless Smart Contract Upgradability
[USCF 시리즈 (1/4)] 왜 우리는 업그레이드 가능한 스마트 컨트랙트가 필요한가?
[USCF 시리즈 (2/4)] 업그레이드 가능한 스마트 컨트랙트를 위한 필요 조건과 배경지식
[USCF 시리즈 (3/4)] 어떻게 스마트 컨트랙트를 업그레이드 가능하도록 바꿀 수 있는가?
[USCF 시리즈 (4/4)] vvisp을 통해 DApp 업그레이드하기
http://gavwood.com/Paper.pdf
Ethereum Whitepaper | ethereum.org
Ethereum - Colony Blog
자동등록방지를 위해 보안절차를 거치고 있습니다.
Solidity - Solidity 0.5.10 documentation
Learn Solidity (0.5) - Hello World
Minimal Beacon Proxy