본문 바로가기
솔리디티

업그레이더블 컨트랙트 정리

by gun_poo 2023. 7. 19.

업그레이더블 컨트랙트란

 

컨트랙트는 본래 수정할수 없다. 하지만 프록시 패턴을 사용해서 교묘하게 속임수를 쓸 수 있다.

프록시 서버를 예로 들자면 진짜 서버는 따로 있고 중간에서 연결해주는 역할을 해주는 프록시 서버의 특성을 띈다고 할 수 있겠다.

 

이게 가능 한 이유는 

 

Delegatecall 때문이다.

 

솔리디티는 다른 컨트랙트를 호출 할때 크게 두가지 EVM opcode를 사용한다. call, delegatecall

call은 다 아니 생략하고

delegatecall은 다른 컨트랙트의 코드를 사용하지만 실행 환경은 기존 컨트랙트에서 수행하게끔한다. 

 

a 컨트랙트가 b 컨트랙을 호출할때 delegatecall을 사용하면 b contract의 code를 사용하지만

스토리지는 a 컨트랙을 소모한다. 

그래서 스토리지는 그대로 유지한 채로 컨트랙 로직이 되는 부분을 상황에 맞게 사용할 수 있게끔 해주는 것이다. 

 

오픈제플린 프록시 컨트랙트 코드를 살펴보자면 

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())
            }
        }
    }
// ...
}

_delegate() 함수는 delegatecall을 사용해 implementation 주소의 컨트랙 로직을 사용할 수 있게한다.

먼저 calldata를 통해 함수 호출에 필요한 데이터를 불러온다.

이 데이터를 통해 implementation 컨트랙 주소로 delegatecall을 실행한다.

calldata는 call에 필요한 data, 컨트랙 함수 실행을 위해 트랜잭션에 포함된 데이터를 의미한다.

 

_delegate() 함수는 internal 함수이기에 해당 컨트랙트에 포함되거나 상속하는 컨트랙에서 호출되어야한다.

다시 코드를 살펴보자면

abstract contract Proxy {
		// ...
		/**
     * @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());
    }
		fallback() external payable virtual {
        _fallback();
    }
    // ...
}

 

fallback()함수를 통해 _delegate() 함수가 호출 된다. 이 폴백 함수란 만일을 대비한 함수로, 주어진 함수식별자와 일치하는 함수가 없거나, 또는 어떠한 calldata도 제공되지 않고 동시에 receive 함수가 없는 경우에 호출된다. 여기선 함수 식별자가 일치하지 않는 것을 활용한다.

예로들어서 어떤 함수를 호출하고자 할때 해당 함수의 식별자가 calldata에 포함되는데, 이 식별자와 일치하는 함수가 해당 컨트랙에 없으면 fallback 함수가 호출된다.

하여 프록시 컨트랙트를 통해 로직 컨트랙에 구현된 함수를 호출하면 프록시 컨트랙에는 해당 함수 식별자와 일치하는 함수가 없기 때문에 fallback 함수가 실행되고 자연스럽게 delegate 함수가 실행되는 구조이다.

 

실행 방법에 대해서는 알았으니 보다 자세한 로직에 대해서 알아보자.

 

프록시 컨트랙트 구현시 주의해야 할 사항

 

 스토리지 충돌

우선 솔리디티로 컨트랙을 작성 할 때 여러가지 상태변수를 선언하고 사용한다.

이때 상태변수들이 저장되는 방식을 스토리지 레이아웃 이라 부른다.

 

스토리지 레이아웃의 가장 기본이 되는 단위는 슬롯으로 모든 데이터들이 이 슬롯을 기준으로 관리된다.

 

스토리지 슬롯이 관리되는 규칙은

  • 슬롯은 32 바이트 크기를 갖는다
  • 일반적으로 변수가 선언된 순서대로 슬롯에 할당
  • 해당 변수 타입의 크기 만큼 슬롯을 사용하며, 다음 변수타입이 기존 슬롯에서 다 포함될수 없는 경우 다음 슬롯에 할당된다.
  • 동적 배열과 매핑 타입은 예외다
  • 동적 배열의 경우 배열이 시작해야하느 슬롯에는 오직 해당배열의 길이만 저장한다. 실제 배열 원소들은 배열이 위치한 슬롯 넘버를 keccak256으로 해싱한 값이 된다.
  • 매핑 값은 매핑이 시작되는 슬롯 넘버와 key 값을 동일하게 해싱한 값의 슬롯 넘버에 위치한다.

실제 예시 코드를 디벼보면서 살펴보자

 

contract StorageLayout {
	uint256 foo;
	uint256 bar;
	uint256[] items;
	mapping(uint256 => uint256) values;

	function allocate() public {
		require(0 == items.length);
		// allocate array items
		items.length = 3;
		items[0] = 12;
		items[1] = 42;
		// allocate mapping values
		values[0] = 100;
		values[1] = 200;
	}
}

allocate() 함수를 호출해 상태 변수에 값을 저장한다고 할 때 이 컨트랙의 스토리지 레이아웃은 

256 비트, 32바이트 크기로 변수가 선언된 순서대로 슬롯에 차곡차곡 할당되고 있다. 동적 배열이 저장되는 방식, 매핑 값들이 매핑이 선언된 순서의 슬롯 넘버와 키 값을 해싱한 값의 슬롯 넘버에 저장되는 것을 보자.

여기서 매핑 값들이 저장되는 방식이 이후 프록시 패턴에서의 스토리지 충돌을 해결하기 위한 중요한 단서가 된다.

 

스토리지 충돌

 

레이아웃이 어떤형태를 띄는지 알았으니 스토리지 충돌이 무엇이고 왜 발생하는가에 대해 알아본다.

 

예로 프록시, 로직 컨트랙 두개를 대충 보자면

contract Proxy {
	address implementation
	// ...
}

contract Implementation {
	address foo;
	uint256 bar;
	// ...
}

 

각 컨트랙은 다음과 같은 레이아웃을 가진다

프록시 컨트랙트는 delegatecall을 활용해 로직 컨트랙트의 함수를 호출한다. 이는 로직 컨트랙트의 함수를 프록시 컨트랙트의 컨텍스트에서 실행한다는 것을 의미한다. 로직 컨트랙에서 foo 변수를 수정하는 행위는 결국 implementation 변수를 수정하게 되는 것을 의미한다.

implementation 변수는 로직 컨트랙 주소를 저장하는 아주 중요한 부분이고, 이 변수에 예상치 못한 값이 할당되면 해당 프록시 컨트랙은 더이상 제 기능을 할 수 없다. 

 

이를 해결하기 위해

 

eip-1967 : standard proxy storage slots

표즌 프록시 스토리지 슬롯이 있다.

스토리지 슬롯을 순차적으로 사용하면 충돌 가능성이 높으니, 슬롯을 랜덤에 가깝게 배정한다는 것이다.

이는 매핑이 스토리지 슬롯에 저장되는 방식과 유사하다. 매핑이 선언된 슬롯의 인덱스 넘버, 매핑 키값을 해싱하여 벨류가 저장되는 슬롯 위치를 결정했다.

 

구체적인 아이디어는 

저장하고 싶은 변수의 이름을 keccak256으로 해싱한 후 1을 뺀 값을 슬롯 넘버로 사용하는 것.

이 때 주의해야할 점은 해싱 값을 절대 중복 사용해선 안된다는 것이다. 

(https://ethereum-magicians.org/t/eip-1967-standard-proxy-storage-slots/3185/7) 참고

 

implementation 변수의 슬롯 넘버는 이렇게 지정할 수 있다.

contract Implementation {
  bytes32 internal constant _IMPLEMENTATION_SLOT = bytes32(uint256(
    keccak256('eip1967.proxy.implementation')) - 1
  ));
}

 

eip-1967로 지정된 슬롯에 있는 값은 어떻게 읽고 쓰는 것일까

오픈제플린 erc1967 관련 컨트랙과 StorageSlot 컨트랙을 통해 확인 가능하다.

abstract contract ERC1967Upgrade {
	bytes32 internal constant _IMPLEMENTATION_SLOT = bytes32(uint256(
	  keccak256('eip1967.proxy.implementation')) - 1
	));

	function _getImplementation() internal view returns (address) {
		return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value;
	}

	function _setImplementation(address newImplementation) private {
		require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");
		StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation;
	}
}

library StorageSlot {
	function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
		/// @solidity memory-safe-assembly
		assembly {
			r.slot := slot
		}
	}
}

게터 세터 패턴과 비슷하다. 차이점은 솔리디티 어셈블리를 통해 해당 슬롯 변수를 로우 레벨에서 읽고 쓴다는 점

1967은 프록세 컨트랙트에서 상태변수를 사용하며 동시에 안전하게 로직 컨트랙을 사용하고 업그레이드도 무리 없이 진행 할 수 있지만!

 

이전 버전의 로직 컨트랙과 업그레이드 된 새 버전 로직 컨트랙의 스토리지 충돌은 피해갈수 없다.

 

로직 컨트랙 간 스토리지 충돌

 

contract V1 {
	address foo;
	uint256 bar;
	// ...
}

contract V2 {
	address baz;
	address foo;
	uint256 bar;
}

v1 => v2로 업그레이드 한다 할때 v2에 새 baz라는 변수가 추가 됐다. 선언 위치를 제일 앞으로 두었다.

이렇게 되면 foo slot에 baz slot이 할당되어 충돌이 발생한다. 여기서만 발생하는 것이 아니라 전체적으로 다 충돌이 일어난다.

이러한 충돌을 피하기 위해 업그레이드시 상태 변수 선언 순서에 주의를 기울여야한다.

contract V1 {
	address foo;
	uint256 bar;
	// ...
}
contract V2 {
	address foo;
	uint256 bar;
	address baz;
}

기존 변수 뒤에 새로운 상태 변수를 선언하면 충돌은 발생하지 않는다.

때문에 실제 업그레이더블 컨트랙을 작성 할 때는 기존 로직 컨트랙을 상속해 작성하여 기존 변수 선언에 변화가 없도록 하는 것이 일반적이다.

 

생성자 초기 코드(Initializing constructor code)

프록시 패턴에서는 로직 컨트랙에서 생성자를 사용할수 없다.

생성자 함수는 컨트랙 배포 시 한번 호출되고 런타임 바이트코드에 포함되지 않아서이다.

프록시 패턴에서는 생성자 함수를 사용할 수 없을까? 다행이도 역시 속임수가 있다. 

생성자 함수의 역할을 initialize()함수로 옮기고 해당 함수가 컨트랙 라이프사이클에서 단 한번만 호출되게 보장하는 방식이다.

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

contract A {
	constructor(address foo) {
		// do something...
	}
}
contract B is Initializable {
    // cannot call initialize more than once due to the `initializer` modifier
    function initialize(
        address foo
    ) public initializer {
        // do something same as contract A contructor code
    }

b 컨트랙 과 같이 코드를 작성하면 생성자 코드와 동일한 역할을 하는 initialize 함수를 통해 수행 가능하다.

주의 할 점은 initializer modifier를 initialize 함수에 적용해아 한다는 점이다.

 

함수 충돌

스토리지 충돌과 비슷한 방식으로 함수레벨에서도 프록시, 로직 사이 충돌이 발생 할 수 있다.

핵심은 프록시 컨트랙에 존재하지 않는 함수 식별자를 통해 호출하면 자연스레 fallback 함수로 이어져 delegatecall로 로직컨트랙 함수 호출을 하게 되는 것이었다.

 

프록시 컨트랙의 기능 구현은 로직 컨트랙에서 이뤄지는 것이 맞지만 업그레이드 관련 기능을 수행하는 함수는 여전히 필요하다는 것이다.

이때 로직 컨트랙도 동일한 함수를 가지고 있다면 문제가 발생한다.

 

솔리디티에서 함수 식별자는 함수 시그니쳐를 해싱하여 앞의 4바이트만 사용한다. 함수명과 파라미터등이 다른 경우에도 얼마든지 충돌이 날 수 있다. 그래서 솔리디티 컴파일러는 같은 컨트랙트 내에서 시그니처가 다른데도 불구하고 식별자가 겹치는 경우를 미리 방지한다. 하지만 프록시, 로직 컨트랙은 구분된 다른 컨트랙이기에 컴파일러 단계에서 이러한 충돌을 방지 할 수 없다.

 

이런 이슈를 해결하기 위해 나온것이 Transparent 패턴이다. 

 

Transparent

이 프록시 패턴의 핵심은 두가지이다.

1. 사용자 어카운트와 어드민 어카운트의 함수 호출 대상 컨트랙을 구분하는 것

2. 업그레이드 관련 로직을 프록시 컨트랙에 구현하는 것

 

1번을 통해 함수 충돌 이슈를 해결한다. 유저는 항상 로직 컨트랙의 함수를 실행하고, 프록시 컨트랙 오너는 항상 프록시 컨트랙 함수를 실행하도록 한다. 이를 통해 함수 충돌 이슈는 발생하지 않는다. 또한 2번을 통해 프록시 컨트랙 오너가 항상 업그레이드를 문제 없이 수행 할 수 있도록 보장한다.

contract TransparentProxy {
    function _delegate(address implementation) internal virtual { /*..*/ }
		function implementation() external ifAdmin returns (address implementation_) {
        implementation_ = _implementation();
    }
    fallback() external { /*..*/ } // call _delegate()
		function upgradeTo(address newImplementation) external ifAdmin {
		// check if caller is admin
		// upgrade to newImplementation
		}
		// Makes sure the admin cannot access the fallback function
    function _beforeFallback() internal virtual override {
        require(msg.sender != _getAdmin(), "TransparentUpgradeableProxy: admin cannot fallback to proxy target");
        super._beforeFallback();
    }
}

이 코드를 보면 fallback 함수가 실행할 때 마다 호출자가 누구인지 확인한다. 이는 불필요한 지출을 야기한다.

컨트랙의 중점은 보안과 가스이다. 

이 문제점을 해결하는 방법으로 uups 패턴이 등장했다.

 

UUPS(universal upgradeable proxy standard)

EIP-1822 에서 제안된 패턴으로 업그레이드 로직이 구현체, 로직 컨트랙에 위치한다. 그래서 프록시 컨트랙의 구현이 심플해진다.

호출자를 확인하는 작업이 더 이상 필요없다.

contract UUPSLogic {
		function upgradeTo(address newImplementation) external virtual onlyProxy {
        _authorizeUpgrade(newImplementation); // check if admin or not
        _upgradeToAndCallUUPS(newImplementation, new bytes(0), false); // upgrade
    }

로직 컨트랙 내부에 업그레이드 함수를 구현하고 해당 함수 내부에서만 호출자 admin 여부를 확인한다.

UUPS 패턴을 사용할 때 조심해야할 점은 반드시 업그레이드를 수행할때 업그레이드 기능이 포함되어야한다는 점이다.

이부분이 빠지면 영원히 업그레이드를 진행 할 수 없다. 업그레이드시 이를 확인해줄수 있는 기능이 포함된 검증된 라이브러리를 기반으로 작업하는 것을 권장한다.

이를 테면 opzepplien의 uupsupgradeable 컨트랙을 상속해 사용하면된다.

uups 패턴의 장점이 하나 있는데 장기적으로 컨트랙 탈중앙성을 지킬수 있다는 것이다. 

 

요약

  • 프록시 패턴에선 스토리지 충돌, 생성자 함수 사용 불가, 함수 충돌 이슈가 있다.
  • 스토리지 충돌은 1967을 사용해 해결
  • 생성자 함수는 initialize()함수로 대체
  • 함수 충돌은 Transparent패턴으로 해결되지만 더 효율적인 UUPS로 해결하자

결론

프록시 컨트랙을 작성하는 베스트는 uups를 갖다쓰고 스토리지 충돌이 걱정되는 implementation 주소 같은 변수는 1967로 해결하며 로직 컨트랙 초기화는 initialize 함수로 대체 하는 것이다. 

 

다른 방식들도 있으나 기존 컨트랙을 업그레이더블하게 변경한 후 생각해보자

 

 

 

https://medium.com/@aiden.p 님의 블로그 내용을 토대로 정리함을 알립니다.

'솔리디티' 카테고리의 다른 글

delegatecall : immutable, constant 예제  (0) 2024.02.03
openzzeplin 5V => upgrade contract 분석하기  (1) 2024.01.20
ERC-4337 , Account Abstraction  (0) 2023.04.04
EIP-4337 이해하기  (0) 2023.03.22
call vs delegate call 알아보기  (0) 2023.01.04

댓글