[Solidity] Proxy contract part 1 - Write to Any Slot

임형석·2023년 10월 31일
0

Solidity


Write to Any Slot

이전 글에서는 하드햇과 오픈제플린의 라이브러리를 사용해 간단하게 상속 스토리지 패턴의 프록시를 구현하고 배포했다.

이번에는 Solidity Example 코드를 바탕으로, 직접 슬롯을 지정하고 지정한 슬롯에 로직 컨트랙트의 주소, 프록시 관리자의 주소 값을 저장해보려고 한다.

총 세개의 part 로 나뉘어 정리할 것이며, 오픈제플린의 Transparent proxy 패턴을 하나씩 구현해보는 과정이다.


로직 컨트랙트

먼저, 로직 컨트랙트를 간단하게 작성해준다. 초기버전인 V1, 업그레이드 버전인 V2 를 하나씩 만들어준다.

contract CounterV1 {
    uint public count;

    function increase() public {
        count += 1;
    }
}

contract CounterV2 {
    uint public count;

    function increase() public {
        count += 1;
    }

    function decrease() public {
        count -= 1;
    }
}

슬롯 지정 라이브러리

슬롯의 위치를 지정해주는 라이브러리 코드를 작성한다.

library StorageSlot {
    struct AddressSlot {
        address value;
    }

    function getAddressSlot(
        bytes32 slot
    ) internal pure returns (AddressSlot storage pointer) {
        assembly {
            pointer.slot := slot
        }
    }
}

Struct 구조로 address value 값을 감싸주어야 바로 아래의 getAddressSlot 에서 인식을 할 수 있다.

getAddressSlot 은 주어진 bytes32의 슬롯을 인자로 받아 슬롯을 가르키는 포인터의 역할을 한다.

그리고 아래의 TestSlot 컨트랙트로 확인해본다.

contract TestSlot {
    bytes32 public constant SLOT = keccak256("TEST_SLOT");

    function getSlot() external view returns(address) {
        return StorageSlot.getAddressSlot(SLOT).value;
    }

    function writeSlot(address _addr) external {
        StorageSlot.getAddressSlot(SLOT).value = _addr;
    }
}

먼저, 상수로 선언한 SLOT 값은 keccak256 으로 해싱한 "TEST_SLOT" 값이다.

getSlot 함수를 호출하면 상수 SLOT 에 저장된 값을 반환한다.

writeSlot 함수를 호출하면 인자로 받은 주소 값을 상수 SLOT 위치에 저장한다.

배포해서 확인해보면, 상수 SLOT 은 0xa7cb26ea17989bb9c5eb391c94c40892dcdc94bb4c381353450910ba80883e1c 이다. 이 슬롯에는 아무 주소도 저장되어있지 않다.

writeSlot 함수를 호출한 뒤 확인하면 상수 SLOT 에 주소 값이 저장됨을 확인할 수 있다.


프록시 컨트랙트

다음으로는 프록시 컨트랙트를 구현한다.

위에서 사용한 라이브러리를 통해 간단하게 구현이 가능하다.

contract Proxy {
    bytes32 public constant IMPLEMENTATION_SLOT = bytes32(
        uint(keccak256("implementation")) - 1
    );
    
    bytes32 public constant ADMIN_SLOT = bytes32(
        uint(keccak256("admin")) - 1
    );

    constructor() {
        _setAdmin(msg.sender);
    }

	// _delegate assembly 사용.
    function _delegate(address _implementation) private {  
        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())
            }
        }
    }

    fallback() external payable {
        _delegate(_getImplementation());
    }

    receive() external payable {
        _delegate(_getImplementation());
    }

    function upgradeTo(address _implementation) external {
        require(msg.sender == _getAdmin(), "Admin only.");
        _setImplementation(_implementation);
    }

    function _getAdmin() private view returns(address) {
        return StorageSlot.getAddressSlot(ADMIN_SLOT).value;
    }

    function _setAdmin(address _admin) private {
        require(_admin != address(0), "Admin address zero.");
        StorageSlot.getAddressSlot(ADMIN_SLOT).value = _admin;
    }

    function _getImplementation() private view returns(address) {
        return StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value;
    }

    function _setImplementation(address _implementation) private {
        require(_implementation.code.length > 0, "Not a contract.");
        StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = _implementation;
    }

    function admin() external view returns(address) {
        return _getAdmin();
    }

    function implementation() external view returns(address) {
        return _getImplementation();
    }
}

몇가지 코드를 살펴보면..

bytes32 public constant IMPLEMENTATION_SLOT = bytes32(
        uint(keccak256("implementation")) - 1
    );  

문자 "implementation" 을 시드로 해싱한 값을 슬롯으로 설정한다.

이때, -1 값을 주어 동일한 시드로 슬롯을 생성하여도 해시충돌 문제가 생기지 않도록 하며, 이 슬롯의 위치를 숫자로 구분할 수 있도록 한다.

public 으로 설정하거나 시드 값이 노출되어도 이 슬롯에 접근하는 함수의 권한만 잘 제어한다면 문제가 없다.


_setImplementation 함수는 로직 컨트랙트의 주소를 슬롯에 저장한다. 위의 upgradeTo 함수로 호출하며 컨트랙트 주소, 어드민만이 로직 컨트랙트를 업그레이드 할 수 있다.

_setAdmin 함수는 생성자에 의해서만 한번 호출되며, 컨트랙트 배포자의 주소를 admin 으로 설정한다.


테스트

리믹스에서 테스트한다. 위에 정리한 코드 4가지를 사용한다.

로직 컨트랙트(CounterV1, CounterV2)
슬롯 지정 라이브러리(StorageSlot)
프록시 컨트랙트(Proxy)


  1. CounterV1 로직을 배포.

  1. CounterV1 주소를 사용하여 Proxy 컨트랙트의 upgradeTo 호출한 후 슬롯에 저장되었는지 확인.

  1. CounterV1 를 배포할 컨트랙트로 설정. At Address 에 Proxy 주소를 넣고, At Address 클릭.

  1. increase 함수 10번 호출하여 현재 Proxy 에 저장된 상태변수 count 를 10 으로 변경.

  1. CounterV2 를 배포하고 이 주소를 인자로 Proxy 의 upgradeTo 함수를 호출하여 로직 컨트랙트를 V2 로 업그레이드 한다.

  1. CounterV2 를 배포할 컨트랙트로 설정. At Address 를 Proxy 주소로 넣고 At Address 클릭. 상태변수 Count 값을 확인.

  1. decrease 함수로 상태변수 값을 5로 변경함. 업그레이드가 잘 되었음을 확인.

0개의 댓글