[Solidity] Upgradable contract - 2 (심화편)

드림보이즈·2025년 3월 27일
0

Smart Contract

목록 보기
9/11

목표 : Upgradable contract 주의점과 Openzepplin 라이브러리 원리까지 이해한다.

키워드 : slot layout 충돌, Transparent Proxy, UUPS


1. Slot Layout 충돌

1편을 완벽하게 이해했다면,
Proxy에 실제 변수들을 저장해놓고, 이 변수들을 활용하는 로직만을 V1,V2에 구현해야 한다는 것을 느꼈을 것이다.
다만 이때 delegatecall을 사용하기에 Slot layout이 Proxy 것임을 고려하여,
충돌이 안나게 해야할 것이다.
아래 예시를 보자.

Proxy

    address public logic;
    address public admin;

이 순서대로 코드가 작성되었으면,
slot0 -> logic
slot1 -> admin
으로 저장될 것이다.

logicV1, logicV2

contract LogicV1 {
    uint256 public value;

    function setValue(uint256 _value) public {
        value = _value;
    }
}

둘다 slot0에 value를 저장하고 있다.
이대로 둔다면, CA를 저장해야할 proxy의 값이 엉켜버린다.

따라서 slot layout이 충돌이 안나게, proxy의 슬롯 순서를 고려하여 logic을 작성해야 한다. => 딱봐도 에러 확률 개높아보인다. 어려워보인다.

Q. 만약 proxy는 slot0,1만 쓰는데, v3에서 slot3을 수정하면 어캐됨?

Proxy의 slot3에 들어갈 것이다. 다만 이걸 활용할 코드가 없는데 어떻게 사용하게?


2. Openzepplin 라이브러리

Openzepplin에서는 upgradable contract를 에러없이, 손쉽게 사용할 수 있도록 여러
라이브러리를 제공한다.
코드 분석으로는 굉장히 깊고 어렵다. 양도 어마어마하다.
우리 수준에선 핵심 라이브러리들을 훑어보며 필요성과 기술 이해 정도를 목표로 해보자.

2-1. Initializable.sol

constructor() 대신 단 1회 호출 가능한 initalize()를 지원하는 컨트랙트

이제 Proxy와 Implementation(Logic) 컨트랙트를 구분지어 설명하자.
Logic 컨트랙트 코드에는 constructor()를 사용할 수 없다. 왜?

3가지를 먼저 이해하자.

  • logic 컨트랙트는 독립된 컨트랙트가 아니라, proxy랑 한 세트로써 의미가 있다.
  • constructor()는 배포될 때 한번 실행되고 만다.
  • logic 컨트랙트를 배포한 후 Proxy를 배포한다.

이제 감이 오는가? proxy보다 먼저 배포되는 Logic에,
constructor()를 실행한다면, 이 결과를 Proxy는 모르기 때문이다.
따라서 이를 대체할 initialize를 만든거지.
딱 1번만 실행되어야 하고, 이후에는 누구도 실행을 못하게하는, constructor 대체제.

핵심 코드 키워드만 보자.

modifier initializer()

    modifier initializer() {
        // solhint-disable-next-line var-name-mixedcase
        InitializableStorage storage $ = _getInitializableStorage();

        // Cache values to avoid duplicated sloads
        bool isTopLevelCall = !$._initializing;
        uint64 initialized = $._initialized;

        // Allowed calls:
        // - initialSetup: the contract is not in the initializing state and no previous version was
        //                 initialized
        // - construction: the contract is initialized at version 1 (no reininitialization) and the
        //                 current contract is just being deployed
        bool initialSetup = initialized == 0 && isTopLevelCall;
        bool construction = initialized == 1 && address(this).code.length == 0;

        if (!initialSetup && !construction) {
            revert InvalidInitialization();
        }
        $._initialized = 1;
        if (isTopLevelCall) {
            $._initializing = true;
        }
        _;
        if (isTopLevelCall) {
            $._initializing = false;
            emit Initialized(1);
        }
    }

logic에 initialize() 함수를 만들어 놓고, 이걸 붙이면,
한번만 실행되고 영원히 실행할 수 없게 하는 것이다.
마치 flag로 false를 했다가, 호출하면 true로 바뀌고, 영원히 다시 실행 못하게 하는 논리로.

constant INITIALIZABLE_STORAGE

// keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.Initializable")) - 1)) & ~bytes32(uint256(0xff))
    bytes32 private constant INITIALIZABLE_STORAGE = 0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00;

Proxy는 Logic 컨트랙트의 슬롯을 공유한다.
그런데 Logic 컨트랙트에서만 저장하고 싶은 데이터가 있다면?
잘못하면 Proxy에서 덮어쓸 수도 있지 않은가? 그래서 절대로 Proxy에서 접근하지 않을 장소에
Logic의 데이터를 위한 주소가 필요하다. 그래서

(uint256(keccak256("openzeppelin.storage.Initializable")) - 1)

이렇게 쓴거다. Proxy에서 Dynamic 데이터를 저장할 때 Keccak을 사용하기에 어디로 튈지 모르지만, "openzeppelin.storage.Initializable" 이걸 해싱할 일은 없지 않겠는가.

위의 값은 주소고, 그럼 실제로 어떤 데이터를 저장할까? 솔리디티에서 그런 문법이 있나?

struct InitializableStorage {
    uint64 _initialized;
    bool _initializing;
}

이 구조체를 저 슬롯에 저장할 것이다.

function _getInitializableStorage() private pure returns (InitializableStorage storage $) {
    assembly {
        $.slot := INITIALIZABLE_STORAGE
    }
}

저수준 문법을 사용해 InitializableStorage 구조체의 슬롯을 저기로 박아버린 것이다.


2-2. ERC1967Upgrade.sol

이번엔 Proxy가 상속하는 컨트랙트다.
핵심 키워드를 살펴보자.

constant _IMPLEMENTATION_SLOT

// keccak256("eip1967.proxy.implementation") - 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 
    0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

logic 컨트랙트 주소를 저장하기 위한 슬롯 번호다.
아까 위에서 봤던 것 처럼, 슬롯이 꼬여서 컨트랙트 주소가 42로 바뀐 걸 이전 포스팅에서 봤을 것이다. 때문에 이처럼 주소를 확정지어준다.

function _getImplementation()

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

뭔지 알겠지? logic CA 반환.

function _setImplementation()

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

새로운 logic 컨트랙트 주소를 저장.

function _upgradeTo

function _upgradeTo(address newImplementation) internal {
    _setImplementation(newImplementation);
    emit Upgraded(newImplementation);
}

내부적으로 위 _setImplementation 호출하는 거다.
얘도 internal이라면, 누가 얘를 호출하는건가?
상위 컨트랙트에 upgradeTo 이름의 함수가 있겠지. 대신 아무나 호출 못하게 할거고.
ㅈㄴ 너무 당연하다 그냥.
(참고) TransparentUpgradeableProxy에 upgradeTo 있음

User (Owner)
  ↓
ProxyAdmin.upgrade(proxy, LogicV2)
  ↓
TransparentUpgradeableProxy.upgradeTo(LogicV2)
혹은 
UUPSUpgradeable.upgradeTo(LogicV2)
  ↓
ERC1967Upgrade._upgradeTo(LogicV2)
  ↓
ERC1967Upgrade._setImplementation(LogicV2)
  ↓
StorageSlot.write(_IMPLEMENTATION_SLOT, LogicV2)


둘 중 하나의 시스템을 사용해야 한다.


2-3. UUPSUpgradeable.sol

다시 Logic 컨트랙트가 상속하는 컨트랙트다.
재밌는 점은, 우린 지금까지 프록시에서 V1주소를 V2로 바꿔줘야 했었다.
그런데 UUPS는 logic 컨트랙트에 upgradeTo가 있다.
즉,

로직 컨트랙트 자신이, 새로운 로직 컨트랙트 주소를 받아서 바꿔치기한다.

그림을 보면 쉽다.

관리자 (owner)
    │
    ▼
[1] 트랜잭션 전송 → proxy (delegatecall 발생)
    │
    ▼
[2] proxy → delegatecall → LogicV1의 upgradeTo() 실행
    │
    ▼
[3] LogicV1 내부 코드 실행:
       - _authorizeUpgrade() → 권한 체크
       - _setImplementation(newImpl) 호출
    │
    ▼
[4] delegatecall 상태니까,
    실제로는 **proxy의 슬롯에 newImpl 주소 저장됨**

이렇게 되면 proxy 컨트랙트에 Upgrade가 필요없다. V1,V2가 주소 바꿔주는 로직을 만드니까.

반면 Transparent Proxy는

┌──────────┐
│ 관리자  │
└────┬─────┘
     │    [트랜잭션 전송: upgradeTo(newImpl)]
     ▼
┌─────────────┐
│ Transparent │ ◀────────────┐
│   Proxy     │              │
│ (업그레이드 함수 직접 가짐) │
└────┬────────┘              │
     │                       │
     ▼                       │
_implementation 슬롯 ← newImpl

감이 잡히지? 그럼 핵심 키워드를 보자.

onlyProxy modifier

function _checkProxy() internal view {
    if (address(this) == __self || ERC1967Utils.getImplementation() != __self) {
        revert UUPSUnauthorizedCallContext();
    }
}

위 그림을 보자. 반드시 proxy를 통한 delegatecall으로 실행이 보장되어야 한다.
이를 위한 modifier다.

_authorizeUpgrade()

function _authorizeUpgrade(address newImplementation) internal virtual;

선언만 되어있다. 실제 logic에서 구현을 하라는건데, 이유는
각 컨트랙트마다 보안 정책이 다를 수 있다. upgrade 권한을 누구한테 줄거냐,admin? owner?
다 다를테니까.

_upgradeToAndCallUUPS()

function _upgradeToAndCallUUPS(address newImplementation, bytes memory data) private {
    try IERC1822Proxiable(newImplementation).proxiableUUID() returns (bytes32 slot) {
        if (slot != ERC1967Utils.IMPLEMENTATION_SLOT) {
            revert UUPSUnsupportedProxiableUUID(slot);
        }
        ERC1967Utils.upgradeToAndCall(newImplementation, data);
    } catch {
        revert ERC1967Utils.ERC1967InvalidImplementation(newImplementation);
    }
}

새로운 Logic 컨트랙트도 UUPS 호환인지를 확인해야 할 것 아닌가?

try IERC1822Proxiable(newImplementation).proxiableUUID()

여길 보면, 새로운 컨트랙트에서 proxiableUUID를 호출하게 한다.
UUPSUpgradeable 를 상속한 컨트랙트라면 구현을 했어야하니, 알 수 있다.

upgradeToAndCall()

function upgradeToAndCall(address newImplementation, bytes memory data)
    public payable onlyProxy
{
    _authorizeUpgrade(newImplementation);
    _upgradeToAndCallUUPS(newImplementation, data);
}

위 2가지 함수를 종합한거다.
실제로 proxy에 새 Logic 컨트랙트 주소 등록전에

  • 권한 확인
  • 새 Logic 컨트랙트가 UUPS 상속하는지 확인

너무 당연하지 않는가?

TransparentUpgradeableProxy은 생략

위처럼 Proxy가 직접 새로운 주소로 업그레이들 할 것이고, 그 권한은 관리자에게 줄것이고,
그런식이지 않겠냐.

ProxyAdmin‎.sol

TransparentUpgradeableProxy의 부모컨트랙트로,
관리자 설정을 목표로 하는거다.


정리

1. UUPS를 사용하는 경우

🟩 Logic (V1, V2 등)
 └── inherits: UUPSUpgradeable
       ├── inherits: Initializable
       └── implements: IERC1822Proxiable

🟦 Proxy
 └── 일반 ERC1967Proxy (혹은 Minimal Proxy)

2. Transaperent Proxy 경우

🟦 TransparentUpgradeableProxy (프록시)
 └── inherits: ERC1967Proxy
      └── inherits: Proxy

🟨 ProxyAdmin (관리자 전용)
 └── inherits: Ownable

🟩 Logic (V1, V2 등)
 └── 일반 로직 컨트랙트

자 이제 다음 강의에서, 이 컨트랙트들을 활용한 Upgradable contract 예제를 작성하며 심화 마무리하자.

코드를 완벽하게 분석하는건 최상위권, 미들급 이상에서 하자.

profile
시리즈 클릭하셔서 카테고리 별로 편하게 보세용

0개의 댓글