Upgradeable Smart Contract Applications Using Proxy Patterns

devcon4 참관기

온더는 이더리움 블록체인의 확장성 솔루션 Plasma 체인을 연구개발하는 회사입니다. 온더의 비전은 이더리움 블록체인 기술의 사용성을 제고시키고, 암호경제와 현실경제를 연동시켜 지금보다 더 나은 세상을 만드는 것입니다.

프라하에서 열린 Ethereum devcon4에서는 정말 다양한 주제로 구성된 알찬 컨퍼런스였습니다. 그 중 저는 Zeppelin에서 소개한 zeppelinOS에 대해서 소개하고자 합니다. zeppelinOS는 cli 기반의 업그레이드 가능한 dApp을 작성할 수 있도록 도와줍니다. 이를 위해 zeppelinOS는 Proxy Pattern을 사용하며 그 중 Unstructured Storage를 이용한 아키텍쳐를 사용합니다. 앞으로의 설명은 zeppelinOS의 근간이 되는 Proxy Pattern과 이를 이용한 3가지 아키텍쳐에 대한 설명이 되겠습니다.

들어가기 전에

Ethereum의 트랜잭션과 배포된 컨트랙트 코드는 수정할 수 없다는 불변의 속성을 가지고 있습니다. 이러한 점 때문에 모든 노드가 트랜잭션의 유효성과 어카운트의 상태를 쉽게 확인할 수 있다는 장점을 가집니다. 하지만 한번 배포한 컨트랙트를 수정할 수 없다는 점은 큰 단점으로 작용하기도 합니다. 일반적인 서비스(Centeralized Application)는 버그가 발견되거나 새로운 기능을 제공하기 위해 빈번히 업데이트를 하여 서비스의 질을 높이는 반면 컨트랙트(Decentralized Application)는 수정이 불가능해 발견된 버그를 수정하거나 추가된 기능을 업데이트할 수 없습니다.

또한 DAO 해킹 사건이나 Parity MultiSig 해킹 사건 같은 경우도 잘못된 코드를 일찍 발견하여 업데이트를 했다면 발생하지 않았을 것입니다. 사실 개발자로서 서비스를 지속적으로 업데이트하는 것은 서비스와 소비자에게 있어서 매우 중요합니다.

Introducing Proxy Contract Architecture

이미 배포된 컨트랙트는 새로 업그레이드할 수 없습니다. 하지만 Proxy Contract 아키텍쳐를 이용하면 컨트랙트를 업그레이드할 수 있습니다. 즉 메인 로직 컨트랙트의 업데이트가 가능해지게 됩니다.

Proxy Contract 아키텍쳐는 모든 메시지 콜이 Proxy Contract를 거치게 됩니다. 이후 Proxy Contract는 요청받은 Message Call을 로직 컨트랙트로 리다이렉트시킵니다. Proxy Contract는 리다이렉트시킬 로직 컨트랙트의 주소를 가지고 있기 때문에, 업데이트를 할 때 새로운 컨트랙트를 배포한 뒤, Proxy Contract가 참조하고 있는 컨트랙트의 주소를 업데이트해야 합니다.

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

Proxy Contract

Proxy Contract가 어떻게 동작하는 지 이해하기 위해서는 우선 다음 두 가지 내용을 잘 이해하고 있어야 합니다.

  • fallback 함수
    : 만약 컨트랙트에 호출한 함수가 컨트랙트에 없을 경우 fallback 함수가 호출됩니다. Proxy Contract의 fallback 함수에는 다른 Logic Contract로 Message Call을 redirect를 시키기 위한 로직이 작성되어 있습니다.
  • delegatecall

A 컨트랙트의 setN 함수는 B 컨트랙트로 delegatecall을 요청합니다. 이 때 A 컨트랙트의 context에서 B 컨트랙트의 코드가 실행됩니다. 즉 B 컨트랙트의 setN 함수 호출로 인해 상태 변수 n이 set되는데, 이 때 n은 B 컨트랙트의 Storage가 아니라 A 컨트랙트의 Storage에서 변경됩니다.

아래의 컨트랙트는 OpenZeppelin에서 제공하는 Proxy Contract입니다. 아래의 코드를 이해하기 위해서는 assembly 코드에서 사용되고 있는 opcode에 대해 이해하고 있어야 합니다.

assembly 코드는 아래와 같습니다.

assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize)
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
let size := returndatasize
returndatacopy(ptr, 0, size)
    switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
  1. 우선 mload(0x40)를 호출해 free memory pointer의 주소를 가져옵니다. 0x40 주소의 메모리는 항상 free한 memory 주소를 가리키는 값을 가지고 있습니다. EVM의 메모리를 사용하기 위해서는 free한 메모리를 사용해야 하기 때문에 0x40 주소로부터 free memory 주소값을 가져오게 됩니다.
  2. 다음으로 calldatacopy 를 호출하는데 calldatacopy 는 3개의 파라미터를 가집니다. calldatacopy 의 첫 번째 파라미터는 memory의 position이고, 두 번째 파라미터는 calldata의 position입니다. 마지막 세 번째 파라미터는 복사할 bytes의 길이를 나타냅니다. 세 번째 파라미터의 값으로 사용된 calldatasize는 calldata의 length를 리턴합니다. 정리하면 calldatacopy 는 calldata의 bytes를 memory로 복사하는 기능을 수행합니다.
  3. 이제 delegatecall을 호출해서 Proxy Contract의 Message Call을 delegate합니다. delegatecall은 6개의 paramter로 구성됩니다.
    - gas: 함수를 실행하는데 필요한 가스
    - _impl: 호출할 로직 컨트랙트의 주소
    - ptr: 보낼 데이터의 시작 메모리 주소
    - calldatasize: 보낼 데이터의 사이즈 (결국 ptr이 가리키고 있는 memory 주소에 있는 데이터에서부터 calldatasize만큼 보내게 됩니다.)
    - 0: memory에서 output 데이터를 가져오는 paramter로 delegatecall의 결과로 어떤 output을 출력할지 예상할 수 없기 때문에 0으로 set합니다(mem[out..(out+outsize))).
    - 0: output의 사이즈를 나타내는 파라미터로 이 또한 delegatecall의 결과로 output의 사이즈를 예상할 수 없기 때문에 0으로 set합니다. Solidity의 delegatecall은 오직 true / false만 리턴합니다.
  4. 다음은 returndatasize opcode를 사용해서 return된 데이터의 사이즈를 구합니다.
  5. 다음으로 returndatacopy(ptr, 0, size)를 사용해서 returndata의 position 0부터 size만큼 복사하여 ptr이 가리키는 메모리 주소에 복사합니다.
  6. 최종적으로 result의 결과, 즉 delegatecall의 성공/실패 여부에 따라 revert하거나 return을 하게 됩니다. revert는 말 그대로 Message Call이 revert되는 것이고, return은 메모리에 있는 값을 가지고 리턴합니다.

정리하면 Proxy Pattern은 아래 그림과 같이 작동하게 됩니다.

  1. Logic Contract의 주소를 구한다(implementation 함수 호출).
  2. 유저가 요청한 Message Call을 Logic Contract로 redirect시킨다(delegatecall OPCODE 호출). Proxy Contract의 delegatecall 호출로 인해 변경되는 Storage는 모두 Proxy Contract에 Storage에 반영됩니다.

Proxy Pattern

Proxy Pattern이란 Proxy Contract를 이용해서 업그레이드 가능한 컨트랙트를 만들 수 있도록 설계한 Pattern을 말합니다. Proxy Pattern의 핵심은 Storage Slot의 관리입니다. Proxy Pattern에서 사용되는 Storage 데이터는 크게 두 가지입니다.

  1. Upgrade 매커니즘과 관련된 Storage 데이터 (e.g. 최신 로직 컨트랙트 주소, 버전 정보)
  2. Logic Contract에서 사용되는 Storage 데이터 (e.g. ERC20: balance, totalBalance, …)
계속해서 Upgrade 매커니즘과 관련된 Storage 데이터를 업데이트 데이터라고 부르고, Logic Contract에서 사용되는 Storage 데이터를 로직 데이터라고 부름.

위의 코드는 Proxy Pattern을 잘못 사용한 예시 코드입니다. Score 컨트랙트는 ProxyStorage와 ScoreStorage를 모두 상속하고 있습니다. ProxyStorage 컨트랙트의 implementation 변수는 Proxy 컨트랙트의 index 0인 Storage Slot을 사용하고, ScoreStorage 컨트랙트의 score 변수는 Proxy 컨트랙트의 index 1인 Storage Slot을 사용하게 됩니다(Proxy 컨트랙트가 delegatecall을 하게 됨으로써).

Score 컨트랙트가 V2로 업데이트될 때 기존의 Score 컨트랙트와 마찬가지로 상속을 하기 때문에 Storage Slot의 변경이 없어 큰 문제가 되지 않습니다. 하지만 V3으로 업데이트할 때 새로운 ScoreStorage 컨트랙트를 만들어서 상속을 하게 됩니다. 새로 만든 ScoreStorage 컨트랙트의 상태 변수는 score가 아닌 lastPersonToSetTheScore라는 변수입니다. 이를 상속한 ScoreV3 컨트랙트는 lastPersonToSetTheScore 변수가 Proxy 컨트랙트의 index 1인 Storage Slot을 사용하게 됨으로써 기존 score 상태 변수를 overwrite하게 됩니다.

이렇게 Proxy Contract는 Logic Contract로 delegatecall을 하기 때문에 Logic Contract에서 사용하는 Storage는 모두 Proxy Contract의 Storage Slot을 사용합니다. 그렇기 때문에 Logic Contract의 로직이 수행되면서 Proxy Contract에서 이미 사용중인 Storage Slot을 초기화시키거나 Storage 값을 overwrite시킬 잠재적인 가능성이 있습니다. 예를 들어 로직 컨트랙트가 업데이트 데이터를 초기화하거나 변경시키면 아주 큰 문제가 발생하게 됩니다. 그렇기 때문에 Proxy Pattern은 Proxy Contract의 업데이트 데이터로직 데이터의 Storage Slot이 겹치지 않도록 설계되어야 합니다. 또한 기존의 로직 데이터의 Storage Slot과 새로 업데이트한 로직 컨트랙트의 로직 데이터의 Storage Slot 또한 겹치지 않도록 해야 합니다.

이를 고려한 Proxy Pattern으로 zeppelinOS에서는 3가지 아키텍쳐를 소개하고 있습니다.

Upgradeability using Inherited Storage (소스 코드)

Inherited Storage를 사용한 아키텍쳐의 핵심은 로직 컨트랙트가 업그레이드 데이터도 가지도록 하는 것입니다. 위의 그림에서 Token_V0 컨트랙트는 Upgradeable 컨트랙트를 상속하고 있는데, Upgradeable 컨트랙트가 UpgradeabilityStorage(업그레이드 데이터를 가지고 있는 컨트랙트)를 상속하고 있어 결국 Token_V0는 로직 데이터뿐만 아니라 업그레이드 데이터도 가지게 됩니다.

그리고 로직 컨트랙트를 업데이트하기 위해서는 기존의 로직 컨트랙트를 새로 상속해서 만들게 됩니다(Token_V1이 Token_V0을 상속). 업데이트한 로직 컨트랙트에 상태 변수를 추가해도 기존 로직 컨트랙트를 상속하기 때문에 새로 만든 상태 변수의 Storage Slot이 기존 상태 변수 Storage Slot을 overwrite하지 않게 됩니다. 이후 UpgradeabilityProxy 컨트랙트를 이용해서 새로 만든 로직 컨트랙트를 등록하는 과정을 거쳐 업데이트를 수행합니다.

Upgradeability using Eternal Storage (소스 코드)

Eternal Storage를 이용한 아키텍쳐의 핵심은 제네릭한 스토리지 구조를 사용한다는 것입니다.

EternalStorage 컨트랙트는 위의 코드처럼 Solidity가 제공하는 변수의 타입들에 대한 mapping의 집합으로 구성됩니다. 즉 모든 로직 데이터들을 다음과 같이 타입과 변수명을 이용한 맵핑 데이터의 형태로 사용합니다.

addressStorage[keccak256(“owner”)] = msg.sender

이렇게 했을 때의 장점은 로직 컨트랙트가 업그레이드 데이터를 가지는 컨트랙트를 상속하지 않아도 된다는 점입니다. 업그레이드 데이터는 단순 타입의 데이터이고, 로직 데이터는 모두 mapping 데이터이기 때문에 둘의 Storage Slot이 overwrite될 확률은 거의 없습니다.

Upgradeability using Unstructured Storage (소스 코드)

Unstructured Storage를 이용한 아키텍쳐는 Inherited Storage를 이용한 아키텍쳐와 매우 비슷한 구조를 가지고 있습니다. 하지만 Unstructured Storage를 이용한 아키텍쳐는 Inherited Storage를 이용한 아키텍쳐와는 달리 로직 컨트랙트가 업데이트 데이터를 가지고 있는 컨트랙트를 상속하지 않아도 된다는 점입니다.

Proxy Pattern에서 가장 중요한 것은 업데이트 데이터의 Storage Slot과 로직 데이터의 Storage Slot이 overwrite되지 않는 것입니다. 여기서의 핵심 아이디어는 업데이트 데이터의 Storage Slot의 키 값을 특정 string의 해시의 값으로 사용하는 것입니다.

17번째 라인에서 keccak256(“org.zeppelinos.proxy.implementation”)의 값은 로직 컨트랙트 주소의 키 값으로 사용됩니다. 특정 string을 해시한 값을 Storage Slot으로 사용하기 때문에 로직 컨트랙트에서는 이와 중복되는 Storage Slot을 사용할 확률을 거의 없습니다. 이로 인해 로직 컨트랙트는 업그레이드 데이터를 가지고 있는 컨트랙트를 상속할 필요가 없어지게 된 것입니다.

마치며

한번 배포된 컨트랙트는 수정할 수 없다는 점에서 큰 신뢰성을 얻을 수 있습니다. 그 코드는 누구에게나 공개되어있고, 공개된 코드에 의해서만 로직이 수행되기 때문입니다. 하지만 어떠한 개인이 컨트랙트를 마음대로 수정할 수 있다면 일반 유저들은 그 개인을 trust해야만 합니다. 즉 trustless하지 못한 것이죠. 이와 관련해서 devcon4에서는 ‘How to make smart contracts upgradable without adding trust’ 라는 세션이 있었습니다. 이에 대한 자세한 설명은 Onther의 철학자(Kevin)님의 글에서 확인해보실 수 있습니다.

긴 글 읽어주셔서 감사합니다.

Reference

이더리움 블록체인 R&D 스타트업 온더(Onther Inc.)에서는 현재 Ethereum client를 그대로 Plasma chain에 올릴 수 있는 솔루션인 Plasma EVM에 대한 구체적인 설계/개발/연구를 진행하고 있습니다. 사이드체인과 플라즈마 체인에 관심을 가지고 계시는 기업, 이를 실제 서비스에 적용하고자 하는 팀, 여기에 기여하고 함께 연구하고 발전 시키고자 하시는 연구자 분들이 계시면 아래의 채널을 통해 언제든지 연락, 질문, 제안을 해주시기 바랍니다. 온더의 문은 항상 열려있습니다 :)
Github : https://github.com/onther-tech
Facebook : https://www.facebook.com/OntherInc
Telegram : @onther_blockchain
Blog : https://medium.com/onther-tech
E-mail : info@onther.io