[Solidity] Proxy Patterns

임형석·2023년 10월 24일
1

Solidity


Proxy Patterns

오픈제플린 블로그를 참조하여 작성했습니다.

Openzeppelin - Proxy Patterns

솔리디티는 이미 배포된 컨트랙트의 코드를 업그레이드할 수 없지만, 새로운 배포된 컨트랙트를 사용하여 로직이 업그레이드된 것처럼 동작하도록 프록시 계약 아키텍처를 설정하는 것이 가능하다.

기존의 컨트랙트를 업그레이드하려면, 새 버전을 배포하고 프록시를 새 컨트랙트 주소를 참조하도록 업데이트하면 된다.

세가지의 프록시 패턴을 소개하면,

  1. Inherited Storage (상속 스토리지)
  2. Eternal Storage (영구적 스토리지)
  3. Unstructured Storage (비구조적 스토리지)

위 세가지 프록시 패턴에 대해 알아보기 전에 이해해야 할 중요한 두가지 개념.

계약에 지원되지 않는 함수 호출이 이루어지면 폴백 함수가 호출됩니다. 이러한 상황을 처리하려면 사용자 정의 폴백 함수를 작성할 수 있습니다. 프록시 계약은 다른 계약 구현으로의 호출을 리디렉션하기 위해 사용자 정의 폴백 함수를 사용합니다.

계약 A가 다른 계약 B로 호출을 위임하면 계약 B의 코드를 계약 A의 문맥에서 실행합니다. 이것은 msg.value 및 msg.sender 값이 유지되고 모든 스토리지 수정이 계약 A의 스토리지에 영향을 미칠 것을 의미합니다. Zeppelin의 프록시 계약은 이 특별한 이유로 자체 delegatecall 함수를 구현하며 이 함수는 로직 계약을 호출하는 데 사용된 값이 반환됩니다. Zeppelin의 프록시 계약 코드를 사용할 계획이라면 사용할 코드의 모든 세부 사항을 완전히 이해해야 합니다. 이것이 어떻게 작동하는지 자세히 살펴보고 이를 달성하기 위해 사용하는 어셈블리 옵코드를 이해해 봅시다.

=> 요약하자면,

  1. 로직 컨트랙트에 정의되지 않은 함수를 호출한 경우, fallback 함수가 호출되기 때문에 프록시 컨트랙트는 사용자 정의 fallback 함수를 사용한다.

  2. 프록시 컨트랙트는 유저에게서 전달받은 msg.value, msg.sender 값을 그대로 유지한 채로 로직 컨트랙트를 호출한다. 오픈제플린의 프록시 컨트랙트를 사용할 계획이라면, 어셈블리 옵코드를 이해해야 한다.


Assembly opcode

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) }
 }

다른 컨트랙트에 함수 호출을 위임하려면 Proxy 컨트랙트가 받은 msg.data 를 전달해야 한다.

msg.data 는 bytes 유형의 동적 데이터 구조이므로 다양한 크기를 가진다. 이 크기는 msg.data 의 첫 번째 워드 크기 (32바이트) 에 저장되며, 실제 데이터만 추출하려면 첫 번째 워드 크기를 뛰어넘어 0x20 (32바이트) 에서 시작해야 한다.

그러나 대신 이를 수행하기 위해 사용할 두개의 옵코드가 있다. 우리는 calldatasize 를 사용하여 msg.data 의 크기를 얻고, calldatacopy 를 사용하여 ptr 변수로 복사한다.

ptr 변수의 위치 0x40 에 있는 메모리 슬롯은 자유롭게 사용가능한 메모리 포인터의 값을 포함하므로 메모리에 변수를 직접 저장할 때마다 0x40 에서 어디에 저장해야 하는지 확인해야 한다.

변수를 저장할 수 있는 위치를 알았으므로 calldata 를 ptr 위치에 0 부터 시작하여 크기 calldatasize 로 복사하기 위해 calldatacopy 를 사용할 수 있다.

    let ptr := mload(0x40)
    calldatacopy(ptr, 0, calldatasize)

다음은 delegatecall 옵코드를 사용하는 부분이다.

let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
  • delegate call 의 매개변수

gas : 실행 함수에 필요한 가스를 전달한다.

_impl : 호출 중인 로직 컨트랙트의 주소.

ptr : 데이터의 시작 위치를 가르키는 메모리 포인터이다.

calldatasize : 전달하는 데이터의 크기이다.

0 : 데이터 아웃용이며, 로직 컨트랙트를 호출한 결과 값을 나타낸다. 하지만 이것은 사용되지 않는다. 왜냐하면 데이터 아웃의 크기를 아직 모르기 때문에 변수에 할당할 수 없다. 그러나 나중에 returndata 옵코드를 사용하여 이 정보에 접근 할 수 있다.

0 : 사이즈 아웃용, 이것 또한 사용되지 않는다. 왜냐하면 다른 계약을 호출하기 전에 데이터 아웃을 저장할 임시 변수를 만들기 전에 그 크기를 알지 못했기 때문이다. 그러나 나중에 returndatasize 옵코드를 호출하여 대체 방법을 사용. 이 값을 얻을 수 있다.

returndatasize 옵코드를 사용하여 반환된 데이터의 크기를 가져온다.

let size := returndatasize

그리고 반환된 데이터의 크기를 사용하여 returndatacopy 라는 옵코드 함수를 사용, 반환된 데이터의 내용을 ptr 변수로 복사한다.

returndatacopy(ptr, 0, size)

마지막으로, switch 문은 반환된 데이터를 반환하거나 문제가 발생했을 경우 예외를 throw 한다.

    switch result
    case 0 { revert(ptr, size) }
    default { return(ptr, size) }

이제 로직 컨트랙트로 부터 적절한 결과 값을 검색하는 방법을 알게 되었다.

프록시 계약이 어떻게 작동하는지 이해했으므로 프록시 패턴 세가지에 대해 알아보자. (상속, 영구, 비구조적)


상속 스토리지 패턴

상속된 스토리지의 접근 방법은 로직 컨트랙트가 프록시에서 필요로 하는 스토리지 구조를 통합하도록 하는 것에 의존합니다. 프록시와 로직 컨트랙트 둘 다 필요한 프록시 상태 변수를 저장하기 위해 동일한 스토리지 구조를 상속받습니다.

이 접근 방법을 탐구하는 동안 우리는 로직 컨트랙트에서 프록시가 필요로하는 스토리지 구조를 통합하는 아이디어를 시도해 보았다. 새로운 로직 컨트랙트로 업그레이드 하려면, 해당 로직 컨트랙트의 새 버전을 Registry 에 등록하고 프록시에 이를 업그레이드 하도록 요청해야 한다.

Registry 를 가지고 있다는 것은 스토리지 매커니즘에 영향을 미치지 않는다.이를 이 게시물에서 소개된 스토리지 패턴 중 어느 것이든 구현할 수 있다.

  • 업그레이드 방법
  1. 컨트랙트의 새 버전을 배포한다. 이 버전은 초기 버전을 상속하여 프록시의 스토리지 구조와 초기 버전의 스토리지 구조를 유지하도록 한다.

  2. 새 버전의 컨트랙트를 Registry 에 등록한다.

  3. UpgardeablilityProxy 인스턴스를 호출하여 새로 배포한 버전으로 업그레이드 한다.

  • 특징

동일한 UpgardeablilityProxy 컨트랙트를 계속 호출함으로써 미래에 배포할 로직 컨트랙트에서 업그레이드된 함수뿐만 아니라, 새로운 함수와 새로운 상태 변수를 도입할 수 있다.


영구 스토리지 패턴

영구 스토리지 패턴에서 스토리지 스키마는 프록시와 로직 컨트랙트 모두 상속하는 별도의 계약에 정의된다. 스토리지 컨트랙트는 로직 컨트랙트가 필요로 하는 모든 상태 변수를 보유하며, 프록시도 이러한 상태 변수를 인식하므로 업그레이드에 필요한 자체 상태 변수를 정의할 수 있다.

즉, 프록시의 상태 변수가 덮어씌워질 염려 없이 업그레이드 가능성에 필요한 자체 상태 변수를 정의할 수 있다. 미래 버전의 로직 컨트랙트는 다른 상태 변수를 정의해서는 안된다. 로직 컨트랙트의 모든 버전은 항상 처음에 정의된 영구 스토리지 구조를 사용해야 한다.

이 패턴은 프록시 소유권 개념을 도입했으며, 프록시 소유자는 프록시를 새로운 로직 컨트랙트를 가리키도록 업그레이드 할 수 있는 유일한 주소이며, 소유권을 이전할 수 있는 유일한 주소입니다.

  • 초기화 방법
  1. EternalStorageProxy 인스턴스를 배포한다.

  2. 로직 컨트랙트의 초기 버전을 배포한다.

  3. EternalStorageProxy 인스턴스를 호출하여 초기 버전의 주소로 업그레이드합니다.

  4. 로직 컨트랙트가 초기 상태를 설정하기 위해 생성자를 사용하는 경우, 프록시의 스토리지는 해당 값들을 모르기 때문에 프록시에 연결한 후에 다시 설정해야 한다. EternalStorageProxy 에는 프록시가 해당 값들을 모르는 경우 초기 설정을 다시 수행하기 위해 특별히 호출하는 upgradeToAndCall 함수가 있다.

  • 업그레이드 방법
  1. 로직 컨트랙트의 새 버전을 배포한다. 이 버전은 영구 스토리지 구조(EternalStorage 구조) 를 보유하도록 해야한다.

  2. EternalStorageProxy 인스턴스를 호출하여 새로운 버전으로 업그레이드 합니다.

  • 특징

토큰 로직 컨트랙트에 중요한 오버헤드가 없는 직관적인 방법이다. 미래의 로직 컨트랙트는 기존 메서드를 도입할 수 있지만 새로운 상태 변수를 도입해서는 안된다.


비구조적 스토리지 패턴

비구조적 스토리지 패턴은 상속된 스토리지와 유사하지만 로직 컨트랙트가 업그레이드 가능성과 관련된 어떤 상태 변수도 상속받지 않아도 된다. 이 패턴은 업그레이드 가능성에 필요한 데이터를 저장하기 위한 프록시에서 정의된 비구조적 스토리지 슬롯을 사용한다.

프록시에서 상수 변수를 정의하며, 이 변수를 해시하면 프록시가 호출해야 하는 로직 컨트랙트의 주소를 저장하기에 충분히 무작위한 스토리지 위치가 제공되어야 한다.

bytes32 private constant implementationPosition = 
keccak256("org.zeppelinos.proxy.implementation");

상수 상태 변수는 스토리지 슬롯을 차지하지 않기 때문에, implementationPosition 이 로직 컨트랙트에 의해 실수로 덮어 씌워지는 우려가 없다. Solidity 가 스토리지에 상태 변수를 배치하는 방식 때문에 이 스토리지 슬롯이 로직 컨트랙트에서 정의한 다른 것에 의해 사용될 충돌 가능성이 극히 적다.

이 패턴을 사용하면 로직 컨트랙트 버전 중 어느 것도 프록시의 스토리지 구조에 대해 알 필요가 없지만, 미래의 로직 컨트랙트는 조상의 버전에서 선언한 스토리지 변수를 상속해야 한다. 상속된 스토리지 패턴과 마찬가지로 미래 업그레이드된 토큰 로직 컨트랙트는 기존 함수를 업그레이드하고 새로운 함수와 새로운 스토리지 변수를 도입할 수 있다.

이 패턴은 프록시 소유권 개념도 사용한다. 프록시 소유자는 프록시를 새로운 로직 컨트랙트를 가리키도록 업그레이드할 수 있는 유일한 주소이며, 소유권을 할 수 있는 유일한 주소이다.

  • 초기화 방법
  1. OwnedUpgradeablilityProxy 인스턴스를 배포한다.

  2. 로직 컨트랙트의 초기 버전을 배포한다.

  3. OwnedUpgradeablilityProxy 인스턴스를 호출하여 초기 버전의 주소로 업그레이드 한다.

  4. 로직 컨트랙트가 초기 상태를 설정하기 위해 생성자를 사용하는 경우, 프록시의 스토리지는 해당 값들을 모르기 때문에 프록시에 연결한 후에 다시 설정해야 한다.
    OwnedUpgradeabilityProxy 에는 프록시가 해당 값들을 모르는 경우 초기 설정을 다시 수행하기 위해 특별히 호출하는 upgradeToAndCall 함수가 있다.

  • 업그레이드 방법
  1. 로직 컨트랙트의 새 버전을 배포한다. 이 새 버전은 이전 버전에서 사용된 상태 변수 구조를 상속해야 한다.

  2. OwnedUpgradeablilityProxy 인스턴스를 호출하여 새로운 계약 버전의 주소로 업그레이드 한다.

  • 특징

이 접근 방법은 토큰 로직 컨트랙트가 프록시 시스템의 일부임을 전혀 알 필요가 없으므로 좋다.


업그레이드의 가능성

로직 컨트랙트가 초기 상태를 설정하는 데 생성자를 사용하는 경우, 프록시가 로직 컨트랙트로 업그레이드된 후에 이 초기 상태를 다시 설정해야 한다.

예를 들어, 로직 컨트랙트가 openzeppelin 의 Ownable 을 상속할 때 생성자도 상속되며 생성 시 소유자를 설정한다. 프록시가 로직 컨트랙트에 연결할 때, 프록시의 관점에서는 소유자의 값이 손실된다.

프록시를 업그레이드 하는 일반적인 패턴은 프록시가 즉시 로직 컨트랙트의 초기화 메서드를 호출하도록 하는 것이다. 초기화 메서드는 일반적으로 생성자에 포함할 모든 것을 구현해야 한다. 또한, 로직 컨트랙트를 여러번 초기화하지 못하도록 하는 플래그도 구현해야 할 것이다.


세가지 패턴의 장단점 정리

프록시 패턴에서 가장 중요한 것은, 데이터를 저장하고 있는 프록시 컨트랙트의 스토리지와 로직 컨트랙트 스토리지의 충돌이라고 보여진다.
따라서 스토리지 문제를 중점적으로 장단점을 정리해보았다.

  • 상속 스토리지 패턴
    프록시 컨트랙트가 유지하고 있는 스토리지 구조를 로직 컨트랙트가 그대로 사용하여 충돌은 없다는 것이 가장 큰 장점.
    하지만, 업그레이드 시에도 구조를 유지해야 하기에 불필요한 상태 변수가 추가될 수 있다는 점과 배포 후 초기화가 필요하다는 점이 단점이다.
  • 영구 스토리지 패턴
    프록시, 로직 컨트랙트가 동일한 영구 스토리지 구조를 공유하므로 로직 컨트랙트는 초기화나 상태 변수 등을 정의할 필요가 없다. 또한, 새로운 함수나 상태 변수를 자유롭게 도입할 수 있다는 점이 장점이다.
    하지만 컨트랙트가 늘어남으로써 관리하기에는 복잡하다는 것이 단점이다.
  • 비구조적 스토리지 패턴
    로직 컨트랙트가 프록시 스토리지 구조에 대해 알 필요가 없으며, 계약 간의 스토리지 충돌 가능성도 낮다는 점이 장점.
    하지만, 프록시 컨트랙트에서 임의의 스토리지 위치를 사용하므로 컨트랙트 간의 스토리지 구조가 분리되며, 현재, 미래의 로직 컨트랙트는 공통 스토리지 구조를 상속해야 한다는 것과 초기화, 초기화의 중복 실행을 방지하기 위한 플래그가 필요하다는 점이 단점이다.

0개의 댓글