1편을 완벽하게 이해했다면,
Proxy에 실제 변수들을 저장해놓고, 이 변수들을 활용하는 로직만을 V1,V2에 구현해야 한다는 것을 느꼈을 것이다.
다만 이때 delegatecall을 사용하기에 Slot layout이 Proxy 것임을 고려하여,
충돌이 안나게 해야할 것이다.
아래 예시를 보자.
address public logic;
address public admin;
이 순서대로 코드가 작성되었으면,
slot0 -> logic
slot1 -> admin
으로 저장될 것이다.
contract LogicV1 {
uint256 public value;
function setValue(uint256 _value) public {
value = _value;
}
}
둘다 slot0에 value를 저장하고 있다.
이대로 둔다면, CA를 저장해야할 proxy의 값이 엉켜버린다.
Proxy의 slot3에 들어갈 것이다. 다만 이걸 활용할 코드가 없는데 어떻게 사용하게?
Openzepplin에서는 upgradable contract를 에러없이, 손쉽게 사용할 수 있도록 여러
라이브러리를 제공한다.
코드 분석으로는 굉장히 깊고 어렵다. 양도 어마어마하다.
우리 수준에선 핵심 라이브러리들을 훑어보며 필요성과 기술 이해 정도를 목표로 해보자.
이제 Proxy와 Implementation(Logic) 컨트랙트를 구분지어 설명하자.
Logic 컨트랙트 코드에는 constructor()를 사용할 수 없다. 왜?
3가지를 먼저 이해하자.
이제 감이 오는가? proxy보다 먼저 배포되는 Logic에,
constructor()를 실행한다면, 이 결과를 Proxy는 모르기 때문이다.
따라서 이를 대체할 initialize를 만든거지.
딱 1번만 실행되어야 하고, 이후에는 누구도 실행을 못하게하는, constructor 대체제.
핵심 코드 키워드만 보자.
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로 바뀌고, 영원히 다시 실행 못하게 하는 논리로.
// 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 구조체의 슬롯을 저기로 박아버린 것이다.
이번엔 Proxy가 상속하는 컨트랙트다.
핵심 키워드를 살펴보자.
// keccak256("eip1967.proxy.implementation") - 1
bytes32 internal constant _IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
logic 컨트랙트 주소를 저장하기 위한 슬롯 번호다.
아까 위에서 봤던 것 처럼, 슬롯이 꼬여서 컨트랙트 주소가 42로 바뀐 걸 이전 포스팅에서 봤을 것이다. 때문에 이처럼 주소를 확정지어준다.
function _getImplementation() internal view returns (address) {
return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value;
}
뭔지 알겠지? logic CA 반환.
function _setImplementation(address newImplementation) private {
require(Address.isContract(newImplementation), "not a contract");
StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation;
}
새로운 logic 컨트랙트 주소를 저장.
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)
둘 중 하나의 시스템을 사용해야 한다.
다시 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
감이 잡히지? 그럼 핵심 키워드를 보자.
function _checkProxy() internal view {
if (address(this) == __self || ERC1967Utils.getImplementation() != __self) {
revert UUPSUnauthorizedCallContext();
}
}
위 그림을 보자. 반드시 proxy를 통한 delegatecall으로 실행이 보장되어야 한다.
이를 위한 modifier다.
function _authorizeUpgrade(address newImplementation) internal virtual;
선언만 되어있다. 실제 logic에서 구현을 하라는건데, 이유는
각 컨트랙트마다 보안 정책이 다를 수 있다. upgrade 권한을 누구한테 줄거냐,admin? owner?
다 다를테니까.
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 를 상속한 컨트랙트라면 구현을 했어야하니, 알 수 있다.
function upgradeToAndCall(address newImplementation, bytes memory data)
public payable onlyProxy
{
_authorizeUpgrade(newImplementation);
_upgradeToAndCallUUPS(newImplementation, data);
}
위 2가지 함수를 종합한거다.
실제로 proxy에 새 Logic 컨트랙트 주소 등록전에
너무 당연하지 않는가?
위처럼 Proxy가 직접 새로운 주소로 업그레이들 할 것이고, 그 권한은 관리자에게 줄것이고,
그런식이지 않겠냐.
TransparentUpgradeableProxy의 부모컨트랙트로,
관리자 설정을 목표로 하는거다.
🟩 Logic (V1, V2 등)
└── inherits: UUPSUpgradeable
├── inherits: Initializable
└── implements: IERC1822Proxiable
🟦 Proxy
└── 일반 ERC1967Proxy (혹은 Minimal Proxy)
🟦 TransparentUpgradeableProxy (프록시)
└── inherits: ERC1967Proxy
└── inherits: Proxy
🟨 ProxyAdmin (관리자 전용)
└── inherits: Ownable
🟩 Logic (V1, V2 등)
└── 일반 로직 컨트랙트
자 이제 다음 강의에서, 이 컨트랙트들을 활용한 Upgradable contract 예제를 작성하며 심화 마무리하자.
코드를 완벽하게 분석하는건 최상위권, 미들급 이상에서 하자.