[케블리] #64. 업그레이드 가능한 스마트 컨트랙트란? (Upgradable Smart Contract)

in #kr6 years ago

Upgradable Smart Contract의 필요성

강력한 보안은 스마트 컨트랙트로 대표되는 탈중앙화 애플리케이션의 가장 큰 특징들입니다. 하지만 그간 저희는 참 많은 해킹 사건들을 보아왔습니다. 특히나 스마트 컨트랙트의 대표적 언어인 솔리디티는 아직 역사가 짧을 뿐더러 코드 자체도 인간이 작성하는 것이기 때문에 허점이 존재할 가능성을 완전히 배제할 수 없습니다.

그리고 실제로 이러한 허점들을 노린 다양한 해킹 사건이 존재했습니다. 대표적으로 암호화폐에 처음 투자한 제 지인에게 반나절만에 눈물의 손절을 안겨준 DAO 해킹 사건패리티 월렛 해킹 사건등이 있습니다. 그리고 최근에는 모두에게 친근한 국산 코인 아이콘(ICX)의 컨드랙트 코드에서 조차 어처구니 없는 실수가 있었죠.

간단히 문제가 있던 아이콘의 코드를 살펴보면

modifier onlyFromWallet {
    require(msg.sender != walletAddress);
    _;
}

function disableTokenTransfer() external onlyFromWallet {
    tokenTransfer = false;
    TokenTransfer();
}

1~4행의 modifier코드는 메시지를 보낸 사람이 walletAddress일때만 통과시키는 역할을 해야하는데, 굉장히 사소한 오타 하나 때문에 정확히 반대의 역할을 하게 된 것이었죠. 그래서 walletAddress이외의 모든 사람이 disableTokenTransfer 기능을 실행시킬 수 있었습니다. 다행이 치명적인 기능에서 일어난 실수는 아니었지만, 혹시 다른 중요한 기능에서도 이런 실수가 있었다면 정말 아찔할뻔 했습니다.

그래서 스마트컨트랙트를 배포할 때에는 신중에 신중을 가해야 하고, 때로는 비싼 돈을 내고 전문 업체들에게 코드 리뷰를 받기도 합니다(당장 구글링만 해봐도 smart contract audit 서비스들이 굉장히 많은데… 가격이 정말 ㅎㄷㄷ합니다). 하지만 코드를 작성하는건 결국 인간이기에 실수의 가능성을 배제할 수 없고, 또한 미래의 발생할 모든 문제점들을 100% 예측할수도 없습니다. 그래서 하나의 대안으로 떠오르는 것이 바로 업그레이드 가능한 스마트 컨트랙트 (Upgradable Smart Contract)입니다.

“잠깐, 스마트 컨트랙트를 업그레이드 하는게 진짜 가능해? 그리고 만약 스마트 컨트랙트가 마음대로 변경 가능하면 애초에 스마트 컨트랙트를 하는 의미가 없지 않아?”

Upgradable Smart Contract라는 단어를 처음 들으시면 이러한 의문이 떠오르지 않을까 생각됩니다. 왜냐하면 애초에 스마트 컨트랙트라는 것이 블록위에 올라가는 순간부터 배포자조차 코드를 마음대로 변경할 수 없기 때문에 우리 모두가 신뢰할 수 있는 건데 만약 이것이 작성자 마음대로 수정가능하다면 우리는 컨트랙트를 100% 신뢰할 수 없지 않을까요?

네! 아주 좋은 질문입니다 (짝짝짝)
일단 이러한 의문을 마음에 품고 그 전에 업그레이드 가능한 스마트 컨트랙트를 어떻게 구현하는지에 대해 간단히 살펴보도록 하겠습니다.

Upgradable Smart Contract 구현 방법

사실 업그레이드 가능한 스마트 컨트랙트는 다양한 방법으로 구현이 가능합니다. 그리고 그 방법도 나날이 발전하고 있습니다. 원리는 생각보다 간단한데, 바로 data와 logic을 각각 다른 컨트랙트로 분리하여 관리하는 것입니다. 예를들어 ERC20 토큰들은 컨트랙트 안에 토큰 이름, 발행량, 보유 토큰 수 등의 정보(data)들과 transfer, approve 등과 같이 토큰 전송에 관여하는 함수(logic)들이 모두 같은 컨트랙트안에 들어있습니다. 하지만 우리가 만약 컨트랙트에서 logic만을 분리시킬 수 있다면, 나중에 어떤 문제를 발견했을 때 문제가 있었던 logic만을 수정하여 새로 배포하는 방식으로 간단히 문제를 해결할 수 있게 되는 것입니다!!

이 글에서는 delegate call을 이용해서 매우 간단히 업데이트 가능한 토큰을 구현해보도록 하겠습니다. 솔리디티를 몇번 접해보셨더라도 이 delegate call이 생소할 수도 있는데, 한번 솔리디티공식 문서를 읽어보겠습니다.

Delegatecall / Callcode and Libraries
There exists a special variant of a message call, named delegatecall which is identical to a message call apart from the fact that the code at the target address is executed in the context of the calling contract and msg.sender and msg.value do not change their values.

This means that a contract can dynamically load code from a different address at runtime. Storage, current address and balance still refer to the calling contract, only the code is taken from the called address.

This makes it possible to implement the “library” feature in Solidity: Reusable library code that can be applied to a contract’s storage, e.g. in order to implement a complex data structure.

즉, delegate call은 원래 스마트 컨트랙트의 라이브러리 기능을 구현하기위해 만들어졌는데, 이를 이용하면 A라는 컨트랙트에서 B라는 컨트랙트에 있는 코드를 가져다 쓸 수 있습니다. 그것도 단순히 불러다가 쓰는 것이 아닌 마치 처음부터 그 코드가 A의 것이었던 것처럼 사용할 수 있는 것이죠!!

간단한 예제 코드를 살펴보겠습니다.
(코드 전문을 보시고 싶으신 분들은 다음 링크를 참고하세요)

contract StandardToken is ERC20 {
  using SafeMath for uint256;
  mapping(address => uint256) balances;
  uint256 totalSupply_;
  mapping (address => mapping (address => uint256)) internal allowed;

  function totalSupply() public view returns (uint256) {
    return totalSupply_;
  }

  function transfer(address _to, uint256 _value) public returns (bool) {
    require(_to == address(0));
    require(_value <= balances[msg.sender]);
    balances[msg.sender] = balances[msg.sender].sub(_value);
    balances[_to] = balances[_to].add(_value);
    emit Transfer(msg.sender, _to, _value);
    return true;
  }

  function balanceOf(address _owner) public view returns (uint256 balance) {
    return balances[_owner];
  }
}

contract MyToken is StandardToken {
  string public name;
  string public symbol;
  uint8 public decimals;

  constructor(string _name, string _symbol, uint8 _decimals, uint256 _initial_supply) public {
    name = _name;
    symbol = _symbol;
    decimals = _decimals;
    
    totalSupply_ = _initial_supply;
    balances[msg.sender] = _initial_supply;
  }
}

contract UpgradableToken is MyToken, Ownable {
  StandardToken public functionBase;
  
  constructor()
    MyToken("Upgradable Token", "UGT", 18, 10e28) public
  {
    functionBase = new StandardToken();
  }
  
  function setFunctionBase(address _base) onlyOwner public {
    require(_base != address(0) && functionBase != _base);
    functionBase = StandardToken(_base);
  }
  
  function transfer(address _to, uint256 _value) public returns (bool) {
    require(address(functionBase).delegatecall(0xa9059cbb, _to, _value));
    return true;
  }
}

위 토큰은 ERC20의 StandardToken일부와, Standart Token에 name, symbol,decimals, initial_supply를 입력해주는 MyToken, 그리고 MyToken을 업그레이드 가능하게 변경하여 배포하는 UpgradableToken의 세 부분으로 구성되어 있습니다. ERC20토큰을 접해보신적이 있다면 MyToken 까지는 상당히 익숙한 코드들일텐데, 그 아래 UpgradableToken 부분이 조금 생소하실 겁니다.

UpgradableToken 컨트랙트는 배포가되면 constructor 함수에 의해 "Upgradable Token"이라는 이름과 "UGT"라는 심볼을 가진 토큰을 배포하고, 이와 동시에 새로운 StandardToken 컨트랙트를 새로 배포하여 functionBase에 그 주소를 저장합니다. 그리고 transfer 함수에서는 delegate call을 이용하여 MyToken의 함수가 아닌 별도로 배포된 StandardToken의 함수를 이용하고 있습니다. 그렇기 때문에 혹시 나중에 StandardToken의 함수의 버그가 발견 되었을 시에 버그를 수정한 StandardToken을 다시 배포하고, setFunctionBase를 통해 새로운 주소를 입력해주면 기존 데이터에 영향을 주는 일 없이 새로운 함수를 이용할 수 있게 되는 것이죠!
(참고로 delegatecall의 첫번째 인자로 전달되는 0xa9059cbb는 transfer함수를 의미하는 해시값입니다)

사실 여기까지의 내용만으로는 아직 잘 이해가 가지 않을 수도 있습니다. 그래서 여기서부터는 이 코드를 가지고 리믹스에서 간단히 테스트를 해보면서 작동원리를 알아보겠습니다.


자 무사히 배포가 완료되었습니다.


이름, 심볼, 총 발행량 등 원래 의도했던 대로 이상없이 작동하는 것을 확인할 수 있습니다. 자 그렇다면 전송기능은 어떤지 테스트해보도록 하게습니다.


오잉? 전송을 눌렀더니 에러가 나네요…?!


그래서 코드를 다시 한번 살펴보니 Standard Token 전송 함수에 오타가 있었습니다. 자기 주소로 토큰 송금을 막는 require 함수는 _to != address(0) 이렇게 써졌어야 했는데 아예 반대로(_to == address(0)) 작성이 되어 버렸습니다.

원인을 알았으니 이제 버그를 수정할 차례입니다!


우선 위에서 발견한 버그를 수정한 새로운 컨트랙트인 StandardToken2를 배포합니다(1). 그리고 기존 UpgradableToken 컨트랙트의 setFunctionBase함수를 통해 방금 배포한 StandardToken2의 주소값을 전달해 줍니다(2). 이렇게 되면 앞으로 UpgradableToken은 delegate call을 통해 StandardToken2의 함수를 이용하게 됩니다.


짜잔~ 이제는 토큰 전송 기능이 제대로 동작되는 것을 확인할 수 있습니다.

간단한 예제를 통해 업그레이드가 가능한 스마트 컨트랙트에 대해서 알아보았는데요, 사실 delegate call을 이용하는 방법 말고도 다양한 설계를 통해 업그레이드 가능한 컨트랙트를 만들 수 있습니다. 예를들어 key-value 페어로 이루어진 Eternal Storage 컨트랙트를 배포하여 데이터 저장소로 사용하는 방법이라던지, 이 방법을 조금더 발전시켜 발전시켜 Rocket Pool에서는 아래 그림과 같이 스토리지 컨트렉트가 허브의 역할을 하고 이 허브에 각기 다른 기능의 컨트랙트를 연결시켜서 필요한 부분만 업데이트 할 수 있는 Hub and Spokes 구조를 제시하였습니다.

https://cdn-images-1.medium.com/max/900/1*mUEoZyNhK5qF9gFzR9gYxQ.png
(다양한 Upgradable Smart Contract 구조에 대해 관심 있으신 분들은 아래 레퍼런스 링크글을 참고해주세요)

맺음말

이처럼 업그레이드 가능한 스마트 컨트랙트를 만드는 방법은 다양합니다. 그리고 이러한 구조를 통하면 향후 버그나 취약점 발견 시 손쉽게 문제를 수정할 수 있어 큰 사고를 미연에 방지할 수 있죠. 하지만 아직 넘어야할 산은 많습니다. 특히나 위에서 제시했던 “수정 가능한 스마트 컨트랙트를 신뢰할 수 있을까?” 를 해결하는 것이 가장 큰 이슈라고 생각합니다. 스마트 컨트랙트가 수정 가능할 경우 결국에는 수정 권한을 가진 단체를 신뢰할 수 있어야하는데 그렇다면 결국 이는 중앙화랑 다를바가 없지 않냐고 되물을 수도 있습니다.

맞습니다. 사실 이러한 단점이 존재하기때문에 아직까지 많은 기업들은 스마트 컨트랙트를 수정 가능하게 만들기 보다는 처음부터 문제가 일어나지 않도록 심혈을 기울여서 설계하고 때로는 비싼 돈을 주며 코드 리뷰를 받고 있다고 생각합니다. 하지만 아무리 노력을 기울여서 작성한 코드라고 해도 향후 예상치 못할 문제가 발생할 가능성은 항상 존재하지 않을까요? 그리고 스마트 컨트랙트라는 개념 자체가 아직 초기단계이기 때문에 아직 발전 가능성은 무궁무진하다고 생각합니다. 예를들어 멀티 시그를 활용한 업그레이드라던지, 혹은 보팅을 통해 유저가 업그레이드에 찬반을 할 수 있도록 설계하는 등의 방법들을 통해 어느정도 단점을 보완할 수도 있습니다. 혹은 앞으로 누군가가 생각지도 못한 참신한 방법을 들고 나올 수도 있구요!

과연 앞으로의 스마트 컨트랙트는 어떠한 방향으로 발전하게 될지 매우 기대가 되네요! 그럼 다음 글에서 뵙겠습니다~

[참고자료]
https://medium.com/quillhash/how-to-write-upgradable-smart-contracts-in-solidity-d8f1b95a0e9a

https://blog.indorse.io/ethereum-upgradeable-smart-contract-strategies-456350d0557c

https://hackernoon.com/upgradeable-smart-contracts-a7e9aef76fdd

https://blog.zeppelinos.org/proxy-patterns/

https://blog.colony.io/writing-upgradeable-contracts-in-solidity-6743f0eecc88

https://medium.com/rocket-pool/upgradable-solidity-contract-design-54789205276d

| 케블리 2기 한호성 |

Sort:  

스마트컨트랙트에 대해 많은 생각을 해보게 되네요... 감사합니다

기재 하신 내용, 좋은 방법이라고 생각 하네요,

만약에 발생 할수 있는, '개발 실수의 파장' 을 해결 하고자, 좋은 의도로 작성한 코드들이 간혹 '과연 컨트렉터가 신뢰를 받을 수 있을까?' 의 질의가 동반 하는 듯 합니다.

이러한 것은 컨트렉터가 어떤 방법으로 신뢰성을 줄 것인지, 생각해 보아야할 문제 라고 생각 합니다.