Upgradable Contract

CHOYEAH·2023년 10월 23일
0
post-thumbnail
post-custom-banner

delegate call을 이용한 Upgradable Contract


비즈니스 로직이 담겨있는 컨트랙트

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract ImpleV1 {
    // Proxy 컨트랙트와 현재 컨트랙트의 스테이트 값이 순서대로 동일한 값으로 작성되어야 한다.
    // 만약 순서대로 명시하지 않으면 의도하였던 값이 다른 변수에 담기게되는 스테이트 컬리젼이 발생한다.
    address public implementation;
    uint public x;

    function inc() external {
        x += 1;
    }
}

비즈니스 로직이 담긴 컨트랙트를 호출할 컨트랙트

// SPDX-License-Identifier: MIT
pragma solidity >= 0.8.0 <0.9.0;

contract Proxy {
    // 컨트랙트 주소를 담을 변수
    address public implementation;
    uint public x;

    function setImplementation(address _implementation) external {
        implementation = _implementation;
    }

    // 대상 컨트랙트에 딜리게이트콜 하여 대상 컨트랙트의 함수를 가져와 현재 컨트랙트에서 실행을 시킨다.
    function _delegate(address _implementation) internal {
        assembly {
            // 요청받은 인풋데이터를 복사한다.
            calldatacopy(0, 0, calldatasize())

            // 요청받은 정보를 그대로 delegatecall() 호출에 사용한다.
            // 실행된 결과는 result 변수에 담기게 된다. 
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)

            // 리턴받은 결과를 다시 한 번 복사한다.
            returndatacopy(0,0, returndatasize())

            switch result
            case 0 { // 결과가 0이면 리버트 
                revert(0, returndatasize())
            }
            default {
                return (0, returndatasize())
            }
        }
    }

    // 사용자가 호출하는 함수는 현재 컨트랙트에 없을것이기 때문에 fallback 함수를 통해 _delegate를 호출한다.
    fallback() external payable {
        _delegate(implementation);
    }
}

테스트

  1. 프록시 컨트랙트와 임플리멘트 컨트랙트를 배포한다.
  2. 배포된 임플리먼트 컨트랙트의 주소를 복사한다.
  3. 프록시 컨트랙트의 setInplementation() 을 호출하여 인플리먼트 컨트랙트 주소를 저장한다.
  4. 인풋 데이터를 확인하기 위해 인플리먼트 컨트랙트의 inc()를 호출하여 input 데이터를 복사한다.
  5. 복사한 인풋 데이터를 프록시 컨트랙트 로우레벨 인터렉션 인풋창 콜데이터에 넣고 트랜잭션을 날린다.
  6. 프록시 컨트랙트의 x를 호출하면 임플리먼트의 함수를 호출했음에도 자신의 스테이트에 데이터가 저장된것을 확인할 수 있다.

비즈니스 로직 컨트랙트에 기능 추가 또는 수정이 필요한 상황이 발생하였다 가정한다.

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract ImpleV2 {
    // Proxy 컨트랙트와 현재 컨트랙트의 스테이트 값이 순서대로 동일한 값으로 작성되어야 한다.
    // 만약 순서대로 명시하지 않으면 의도하였던 값이 다른 변수에 담기게되는 스테이트 컬리젼이 발생한다.
    address public implementation;
    uint public x;

    function inc() external {
        x += 1;
    }

    function dec() external {
        x -= 1;
    }
}

  1. 새로운 임플리먼트 컨트랙트2를 작성하여 배포한다.
  2. 기존 프록시 컨트랙트의 인플리먼트 컨트랙트 주소를 새로 배포한 컨트랙트 주소로 교체한다. (only owner를 적용해야겠다)
  3. 새로 추가된 dec 함수를 호출하여 인풋 데이터를 확인한다.
  4. 프록시 컨트랙트 콜데이터에 인풋데이터를 넣고 트랜잭션을 날린다.
  5. 프록시 컨트랙트에 x를 호출하면 dec가 호출되어 처리되었기 때문에 기존 값에서 -1된 값이 리턴된다.

Openzeppelin UUPSUpgradeable contract


오픈제펠린에서는 사용자가 안전하고 편리하게 upgradeable 한 컨트랙트를 작성할 수 있도록 몇가지 방식을 제공하고있다. 그중에 하나인 UUPSUpgradeable을 살펴보자

openzeppelin-contracts/contracts/proxy at master · OpenZeppelin/openzeppelin-contracts

UUPSUpgradeable 도 임포트를 따라 들어가다보면 Proxy.sol을 사용하게되는데 그 내용은 앞서 작성한 코드와 거의 같음을 볼 수 있다.

openzeppelin-contracts/Proxy.sol at master · OpenZeppelin/openzeppelin-contracts

소스코드

// SPDX-License-Identifier: MIT
   // OpenZeppelin Contracts (last updated v4.6.0) (proxy/Proxy.sol)
   
   pragma solidity ^0.8.0;
   
   /**
    * @dev This abstract contract provides a fallback function that delegates all calls to another contract using the EVM
    * instruction `delegatecall`. We refer to the second contract as the _implementation_ behind the proxy, and it has to
    * be specified by overriding the virtual {_implementation} function.
    *
    * Additionally, delegation to the implementation can be triggered manually through the {_fallback} function, or to a
    * different contract through the {_delegate} function.
    *
    * The success and return data of the delegated call will be returned back to the caller of the proxy.
    */
   abstract contract Proxy {
       /**
        * @dev Delegates the current call to `implementation`.
        *
        * This function does not return to its internal call site, it will return directly to the external caller.
        */
       function _delegate(address implementation) internal virtual {
           assembly {
               // Copy msg.data. We take full control of memory in this inline assembly
               // block because it will not return to Solidity code. We overwrite the
               // Solidity scratch pad at memory position 0.
               calldatacopy(0, 0, calldatasize())
   
               // Call the implementation.
               // out and outsize are 0 because we don't know the size yet.
               let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
   
               // Copy the returned data.
               returndatacopy(0, 0, returndatasize())
   
               switch result
               // delegatecall returns 0 on error.
               case 0 {
                   revert(0, returndatasize())
               }
               default {
                   return(0, returndatasize())
               }
           }
       }
   
       /**
        * @dev This is a virtual function that should be overridden so it returns the address to which the fallback function
        * and {_fallback} should delegate.
        */
       function _implementation() internal view virtual returns (address);
   
       /**
        * @dev Delegates the current call to the address returned by `_implementation()`.
        *
        * This function does not return to its internal call site, it will return directly to the external caller.
        */
       function _fallback() internal virtual {
           _beforeFallback();
           _delegate(_implementation());
       }
   
       /**
        * @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if no other
        * function in the contract matches the call data.
        */
       fallback() external payable virtual {
           _fallback();
       }
   
       /**
        * @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if call data
        * is empty.
        */
       receive() external payable virtual {
           _fallback();
       }
   
       /**
        * @dev Hook that is called before falling back to the implementation. Can happen as part of a manual `_fallback`
        * call, or as part of the Solidity `fallback` or `receive` functions.
        *
        * If overridden should call `super._beforeFallback()`.
        */
       function _beforeFallback() internal virtual {}
   }

fallback을 통해 delegate()를 호출하는 방식

실습

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

// ProxyContractV1은 비즈니스 로직을 담고있는 컨트랙트이다.
// Initializable: 프록시로 업데이트를 하였을때 constructor로 초기값을 셋팅하지 못하기 때문에 Initializable을 사용하여 펑션 형태로 초기값을 설정할 수 있음.
contract ProxyContractV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    uint256 public count;

		// 초기값 셋팅 
    function initialize() public initializer {
        count = 10;
        __Ownable_init();
    }

		// 프록시 정보를 변경할 수 있는 오너를 관리할 수 있는 함수
    function _authorizeUpgrade(address) internal override onlyOwner {}

		// 비즈니스 로직
    function inc() external {
        count++;
    }
}

// 비즈니스 로직이 추가되어 업그레이드 될 ProxyContractV1을 상속하는 컨트랙트
contract ProxyContractV2 is ProxyContractV1 {
    function dec() external {
        count--;
    }
}

Deploy with Proxy에 체크 후 배포 실행 (리믹스에서는 자동으로 프록시 컨트랙트를 인식하여 위와 같은 체크박스 옵션을 제공한다.)

임플리멘테이션과 erc1967 두 개의 컨트랙트가 연달아 배포된다.

UUPS를 사용하면 앞서 실습한것과 달리 프록시 컨트랙트에서도 비즈니스 로직을 담당하는 함수가 동일하게 적용된다.

initialize를 실행한 후 inc를 통해 값 증가를 시도한 후 count를 통해 증가 여부를 확인할 수 있다.

이제 비즈니스 로직 컨트랙트를 업그레이드 해보자

이번에는 Upgrade with Proxy를 선택하여 임플리먼트 컨트랙트를 배포한다.
기존 프록시 컨트랙트를 유지하려면 use last deployed erc1967 contract 체크박스를 체크한다.
만약 다른 프록시 어드레스를 적용하려면 체크박스에 체크하지 않고 다른 프록시 컨트랙트 주소를 복사하여 붙여넣기 해준다.

마찬가지로 두 컨트랙트가 연달아 배포되었다.

새로 추가된 dec() 함수를 확인할 수 있고 기존의 데이터에 변화 없이 기능을 사용할 수 있게되었다.
참고로 프록시 컨트랙트의 주소는 변경되지 않는다. (스크린샷은 여러번 테스트 해보며 찍은것이라 주소가 다름)

interface를 활용한 간단한 외부 컨트랙트 호출


//SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract Counter {
    uint256 public count;

    function getCount() external view returns (uint256) {
        return count;
    }

    function inc() external {
        count += 1;
    }
}

interface ICounter {
    function getCount() external view returns (uint256);

    function inc() external;
}

contract MyContract {
    function incrementCount(address _counterAddress) external {
        ICounter(_counterAddress).inc();
    }

    function getCount(address _counterAddress) external view returns (uint256) {
        return ICounter(_counterAddress).getCount();
    }
}

MyContract의 incrementCount(), getCount()를 호출시에 배포된 Counter 컨트랙트의 주소를 파라메터로 넘기면 Counter 컨트랙트의 기능을 호출할 수 있다.
이때 결과 값이 담긴 스테이트 변수는 MyContract가 아닌 Counter 컨트랙트에 저장된다. (delegatecall이 아닌 그냥 call이기 때문)

만약 비즈니스 로직 수정이 필요하다면 수정한 컨트랙트를 다시 배포한 후 배포된 컨트랙트의 주소값을 사용하여 업데이트된 로직을 호출할 수 있다.

profile
Move fast & break things
post-custom-banner

0개의 댓글