[Solidity] Upgradable contract - 3 (Transparent Proxy Pattern 실습)

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

Smart Contract

목록 보기
10/11

목표 : Openzepplin 라이브러리 없이 핵심 기능을 직접 구현해 Transparent Proxy Pattern을 만들자.


원래 라이브러리에서 제공하는 컨트랙트들을 상속받아 예제를 만들려고 했으나,
뭔가 자꾸 엉켜서 직접 핵심기능들만 만들어 보는 것은 안 비밀.

원래 UUPS 예제도 만들려고 했으나 upgradeToandAll 호출하는 과정에서 에러의 원인을
못찾아 Transparent Proxy Pattern만 하는 것은 안 비밀.


1. Proxy vs Transparent proxy

핵심은 '관리자'를 위한 역할 분리!

1편에서 만들었던 컨트랙트는 관리자가 따로 없었다.
누구든 로직 컨트랙트의 주소를 수정할 수 있었다.
Transparent Proxy는 다른 거 없다.
관리자를 위한 슬롯을 따로 둬서,
그들만 실행할 수 있는 함수들을 구분짓는 것이 핵심이다.

  • 사용자 : Logic의 일반 함수 호출 가능
  • 관리자 : Logic 주소 옮기는 등 함수 호출 가능

2. 예제 코드

Proxy

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

contract SimpleProxy {
	// 일반 상태 변수들
    uint256 value;


    // === EIP-1967 슬롯 위치 상수들 ===
    bytes32 private constant IMPLEMENTATION_SLOT = 
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    bytes32 private constant OWNER_SLOT = 
        0x8f283970dc8a7a74419b30bc81727c946db47b1479b8e8b7632d16b33ecbda66; // keccak256("proxy.owner")

    constructor(address _impl) {
        _setImplementation(_impl);
        _setOwner(msg.sender);
    }
    

    // === Implementation 주소 관련 ===
    function _setImplementation(address impl) internal {
        assembly {
            sstore(IMPLEMENTATION_SLOT, impl)
        }
    }

    function _getImplementation() internal view returns (address impl) {
        assembly {
            impl := sload(IMPLEMENTATION_SLOT)
        }
    }

    // === Owner 관련 ===
    function _setOwner(address _owner) internal {
        assembly {
            sstore(OWNER_SLOT, _owner)
        }
    }

    function getOwner() public view returns (address owner_) {
        assembly {
            owner_ := sload(OWNER_SLOT)
        }
    }

    // === 업그레이드 ===
    function upgradeTo(address newImpl) external {
        require(msg.sender == getOwner(), "Not owner");
        _setImplementation(newImpl);
    }

    // === Delegatecall ===
    fallback() external payable {
        address impl = _getImplementation();
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    receive() external payable {}
}

1. 변수 저장

저장해야 될 변수는 3분류다.
이 컨트랙트에서 사용할 변수인 value, logic 컨트랙트 주소 impl, owner 주소

  • value
  • impl address
  • owner address

사용할 변수들은 앞에 몰아서 slot 0,1,2..로 하고,
관리를 위한 변수들은 겹칠 수도 있으니 아래와 같이 특정 슬롯에 지정한다.
아래 코드 쫄지말자. 그냥 특정 슬롯에 박아서 impl, owner를 저장하는 것 뿐이다.
일반 변수들과 겹치지 않게.

	// 일반 상태 변수들
    uint256 value;


    // === EIP-1967 슬롯 위치 상수들 ===
    bytes32 private constant IMPLEMENTATION_SLOT = 
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    bytes32 private constant OWNER_SLOT = 
        0x8f283970dc8a7a74419b30bc81727c946db47b1479b8e8b7632d16b33ecbda66; // keccak256("proxy.owner")

    constructor(address _impl) {
        _setImplementation(_impl);
        _setOwner(msg.sender);
    }
    

    // === Implementation 주소 관련 ===
    function _setImplementation(address impl) internal {
        assembly {
            sstore(IMPLEMENTATION_SLOT, impl)
        }
    }

    function _getImplementation() internal view returns (address impl) {
        assembly {
            impl := sload(IMPLEMENTATION_SLOT)
        }
    }

    // === Owner 관련 ===
    function _setOwner(address _owner) internal {
        assembly {
            sstore(OWNER_SLOT, _owner)
        }
    }

    function getOwner() public view returns (address owner_) {
        assembly {
            owner_ := sload(OWNER_SLOT)
        }
    }

2. Upgrade()

그저 v1 주소를 v2 주소로 바꾸는 것 뿐이다.
대신 아무나 할 수 없게 Owner 체크를 하는 것이다.

    // === 업그레이드 ===
    function upgradeTo(address newImpl) external {
        require(msg.sender == getOwner(), "Not owner");
        _setImplementation(newImpl);
    }

3. fallback()

logic에 있는 어떤 함수를 호출하든 여기서 delegatecall을 한다.
코드를 좀 더 자세히 보자.

    fallback() external payable {
        address impl = _getImplementation();
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

Logic 주소를 불러와서, msg.data를 쭉 복사한 다음 메모리 0번지부터 넣어놓는다.
그리고 delegatecall을 한다.

  • 지금 남은 가스 : Gas()
  • 주소 : impl
  • data : 메모리 0번지 ~ msg.data 크기 만큼, 즉 Msg.data 내용을 넣어서
  • result 성공하면 1, 실패하면 0
  • case 0 실패시 Revert, 성공시 return

별 것 없다. 이것이 핵심만 찝은 기능들이다.


LogicV1

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

contract Logic1 {
    uint256 public value;
    address public owner;

    // 슬롯을 고정하기 위한 상수: keccak256("my.logic.logicv1") - 1
    bytes32 private constant INIT_SLOT = bytes32(uint256(keccak256("my.logic.logicv1")) - 1);

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    function initialize() public {
        require(!_isInitialized(), "Already initialized");
        _setInitialized();

        owner = msg.sender;
    }

    function setValue(uint256 _v) public onlyOwner {
        value = _v;
    }

    function isOwner() public view returns (string memory) {
        address storedOwner;
        assembly {
            storedOwner := sload(1)
        }

        if (msg.sender == storedOwner) {
            return "HI";
        } else {
            return "NOT OWNER";
        }
    }

    // 🔒 내부: 초기화 여부 확인
    function _isInitialized() internal view returns (bool initialized) {
        assembly {
            initialized := sload(INIT_SLOT)
        }
    }

    // ✅ 내부: 초기화 플래그 true로 설정
    function _setInitialized() internal {
        assembly {
            sstore(INIT_SLOT, true)
        }
    }
}

로직은 어떤 변수들을 저장해야 하는가?

  • 일반 변수들 : value
  • owner 주소 : owner
  • init 여부 : initalized

일반 변수, owner 주소는 Proxy와 동일하게 맞춰주면 된다.

    // === 상태 변수 예시 ===
    uint256 public value;

    // === 오너 저장 슬롯: keccak256("my.proxy.owner") - 1
    bytes32 private constant _OWNER_SLOT = 
        0x8f2839700a186dc06d7288bfa90b5d601bf9f90b0f0dcbb36890e97ae2c5c7ac;

init 여부는 constructor처럼 딱 한번만 실행했는지 여부를 알려주는 Flag다.
initalized는 logicv1에만 잇어야 하는 고유한 주소로 해야한다.
뭔 소리냐면, v2가 나왓을때 Init을 하려고 했다. 근데 Proxy에는 이미 동일한 슬롯에 v1에서 사용한 값이 저장되어 있으면, v2는 Init을 못하지 않겠는가?
그래서 나는 해당 컨트랙트 이름을 사용하여, 슬롯을 유니크하게 만들려고 한다.

 // 슬롯을 고정하기 위한 상수: keccak256("my.logic.logicv1") - 1
    bytes32 private constant INIT_SLOT = bytes32(uint256(keccak256("my.logic.logicv1")) - 1);

LogicV2

  • 다 동일하게 하고, Init 슬롯이랑 일반 함수들만 추가하면 된다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract MyUUPSLogic1 {
    uint256 public value;
    address public owner;

    // 슬롯을 고정하기 위한 상수: keccak256("my.logic.logicv2") - 1
    bytes32 private constant INIT_SLOT = bytes32(uint256(keccak256("my.logic.logicv1")) - 1);

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    function initialize() public {
        require(!_isInitialized(), "Already initialized");
        _setInitialized();

        owner = msg.sender;
    }

    function setValue(uint256 _v) public onlyOwner {
        value = _v;
    }

    function isOwner() public view returns (string memory) {
        address storedOwner;
        assembly {
            storedOwner := sload(1)
        }

        if (msg.sender == storedOwner) {
            return "HI";
        } else {
            return "NOT OWNER";
        }
    }

    // 🔒 내부: 초기화 여부 확인
    function _isInitialized() internal view returns (bool initialized) {
        assembly {
            initialized := sload(INIT_SLOT)
        }
    }

    // ✅ 내부: 초기화 플래그 true로 설정
    function _setInitialized() internal {
        assembly {
            sstore(INIT_SLOT, true)
        }
    }
}

실행 흐름

  1. LogicV1 배포
  2. Proxy 배포 (logicv1 addr 넣어서) : impl 슬롯에 주소 들어감, owner 슬롯에 주소 들어감
  3. Proxy에 tx 보냄 (LogicV1 initialize 실행) : init 슬롯 flag = true
  4. Proxy에 tx(LogicV1 setValue(10) 실행 : proxy slot0의 value 값 변화
  5. LogicV2 배포
  6. Proxy Upgrade(logic2 주소) : Owner 슬롯에서 Msg.sender체크,
    impl 주소가 v1 => v2로 변경
  7. Proxy tx (LogicV2 init) : init 슬롯 (V1과 다른 주소) flag = true
  8. proxy tx(LogicV2 setValue(10) 실행 : Proxy slot0의 value 변화

이 흐름이다.

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

0개의 댓글