원래 라이브러리에서 제공하는 컨트랙트들을 상속받아 예제를 만들려고 했으나,
뭔가 자꾸 엉켜서 직접 핵심기능들만 만들어 보는 것은 안 비밀.
원래 UUPS 예제도 만들려고 했으나 upgradeToandAll 호출하는 과정에서 에러의 원인을
못찾아 Transparent Proxy Pattern만 하는 것은 안 비밀.
1편에서 만들었던 컨트랙트는 관리자가 따로 없었다.
누구든 로직 컨트랙트의 주소를 수정할 수 있었다.
Transparent 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 {}
}
저장해야 될 변수는 3분류다.
이 컨트랙트에서 사용할 변수인 value, logic 컨트랙트 주소 impl, owner 주소
사용할 변수들은 앞에 몰아서 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)
}
}
그저 v1 주소를 v2 주소로 바꾸는 것 뿐이다.
대신 아무나 할 수 없게 Owner 체크를 하는 것이다.
// === 업그레이드 ===
function upgradeTo(address newImpl) external {
require(msg.sender == getOwner(), "Not owner");
_setImplementation(newImpl);
}
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을 한다.
별 것 없다. 이것이 핵심만 찝은 기능들이다.
// 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)
}
}
}
로직은 어떤 변수들을 저장해야 하는가?
일반 변수, 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);
// 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)
}
}
}
이 흐름이다.