TransparentUpgradeable Proxy
이번 글은 가장 많이 쓰이는 프록시 패턴인 TransparentUpgradeable Proxy에 대해 다룬다.
개요
ERC1967 Proxy에 함수 선택자 충돌 방지 기능을 더한 프록시이다. 스토리지 충돌 외에 프록시가 가질 수 있는 문제는 만약 Proxy 와 Implementation에 같은 함수 선택자를 가진 함수가 있다면 Implementation 컨트랙트로 delegatecall이 이루어지지 않는다는 것이다. fallback()은 컨트랙트에 해당 함수가 없는 경우에만 실행되기 때문에 Proxy에 해당 함수 선택자를 가진 함수가 있다면 그게 실행된다. 함수 선택자는 고작 4바이트짜리 데이터이기 때문에 완전히 동일한 함수가 아니더라도 함수 선택자가 충분히 같을 수 있다.
이를 방지하기 위해 Transparent Upgradeable Proxy는 fallback()을 제외하고는 프록시 컨트랙트에 다른 public함수가 없도록 구현되었다. 그렇다면 기존에 프록시를 업그레이드하기 위해 프록시에 구현되었던 업그레이드 함수도 없다는 뜻인데, 프록시를 어떻게 업그레이드하는 것일까?
이제는 fallback()에서 msg.sender가 admin인지 여부에 따라 다른 동작을 하도록 구분한다. msg.sender가 admin이면 업그레이드 로직, 아니면 Implementation으로 delegatecall을 하는 것이다. 아래는 openzeppelin의 TransparentUpgradeableProxy.sol에 구현된 _fallback()이다.
address private immutable _admin;
// ...
function _fallback() internal virtual override {
if (msg.sender == _proxyAdmin()) {
// ...
_dispatchUpgradeToAndCall();
} else {
super._fallback();
}
}
Solidity
복사
contracts/proxy/transparent/TransparentUpgradeableProxy.sol
그런데 _admin변수가 immutable로 선언되어있는 것을 볼 수 있다. 오직 admin만 프록시를 업그레이드할 수 있으므로 만약을 대비하여 업그레이드 가능한 프록시는 admin을 변경할 수 있어야 한다. 왜 immutable로 admin을 선언한 것일까?
ERC1967을 준수하기 위해 admin storage slot에 admin의 주소가 있어야 한다. 하지만 매번 Proxy로 호출할 때마다 스토리지를 읽는 것은 비용이 많이 든다. 스토리지를 읽고 쓰는 것은 다른 연산보다 가스 비용이 비싸기 때문이다. 이렇게 보면 admin을 immutable로 선언하는 것이 합리적인 것 같다. 하지만 admin을 변경할 수 없기 때문에 여기서 ProxyAdmin이라는 것이 등장한다.
ProxyAdmin이란?
스마트 컨트랙트의 주소는 변경되지 않으므로 ProxyAdmin이라는 별도의 컨트랙트를 작성하고 immutable admin에 이 컨트랙트의 주소를 할당하는 것이다.
사실상 ProxyAdmin의 owner가 진짜 admin이라고 보면 된다. ProxyAdmin은 owner의 호출을 그냥 proxy로 전달만 해준다. 이 owner를 변경함으로써 proxy upgrade 권한을 가진 계정을 변경할 수 있다. 아래는 openzeppelin의 ProxyAdmin이다. 프록시 컨트랙트의 upgradeToAndCall을 호출하는 간단한 함수 upgradeAndCall만 정의되어 있는 아주아주 간단한 컨트랙트다.
function upgradeAndCall(
ITransparentUpgradeableProxy proxy,
address implementation,
bytes memory data
) public payable virtual onlyOwner {
proxy.upgradeToAndCall{value: msg.value}(implementation, data);
}
Solidity
복사
contracts/proxy/transparent/ProxyAdmin.sol
admin설정 시 주의사항
admin 주소를 0으로 설정하거나, upugradeAndCall을 부를 수 없는 다른 컨트랙트 주소로 지정한다면 프록시는 더이상 업그레이드가 불가능하다.
예를 들어 AdminProxy의 owner가 다른 AdminProxy로 설정되는 경우를 생각해볼 수 있다.
구현 분석
openzeppelin에서 구현한 Transparent Upgradeable Proxy를 자세히 살펴보자. 네 개의 컨트랙트로 이루어져있다.
•
Proxy.sol
•
ERC1967Proxy.sol
•
TransparentUpgradeableProxy.sol
•
ProxyAdmin.sol
TransparentUpgradeableProxy → ERC1967Proxy → Proxy 순으로 상속하고 있다.
최상단 프록시: Proxy.sol
4개의 함수 중 _implementation()은 ERC1967Proxy에서, _fallback()은 TransparentUpgradeableProxy에서 재정의한다.
_delegate함수가 Implementation주소로 콜데이터를 전달해 delegatecall을 하는 핵심 함수다.
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.0) (proxy/Proxy.sol)
pragma solidity ^0.8.20;
/* The success and return data of the delegated call will be returned back to the caller of the proxy. */
abstract contract Proxy {
function _delegate(address implementation) internal virtual {
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())
}
}
}
function _implementation() internal view virtual returns (address);
function _fallback() internal virtual {
_delegate(_implementation());
}
fallback() external payable virtual {
_fallback();
}
}
Solidity
복사
ERC1967Proxy
Proxy를 상속하여 현재 설정된 Implementation의 주소를 반환하는 함수를 재정의한 컨트랙트다.
ERC1967Proxy의 생성자는 ERC1967에 명시된 스토리지 슬롯에 implementation을 저장하고 데이터가 있다면 Implementation으로 해당 데이터를 갖고 delegatecall을 한다. 보통 생성자에서는 초기화를 위한 데이터를 전달하여 Implementation지정 후 바로 초기화가 되게 한다.
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.2.0) (proxy/ERC1967/ERC1967Proxy.sol)
pragma solidity ^0.8.22;
import {Proxy} from "../Proxy.sol";
import {ERC1967Utils} from "./ERC1967Utils.sol";
contract ERC1967Proxy is Proxy {
// Requirements: If `data` is empty, `msg.value` must be zero.
constructor(address implementation, bytes memory _data) payable {
ERC1967Utils.upgradeToAndCall(implementation, _data);
}
// @dev 현재 implmentation 주소 반환
function _implementation() internal view virtual override returns (address) {
return ERC1967Utils.getImplementation();
}
}
Solidity
복사
ERC1967Utils에 있는 upgradeToAndCall은 아래와 같다.
function upgradeToAndCall(address newImplementation, bytes memory data) internal {
_setImplementation(newImplementation);
emit IERC1967.Upgraded(newImplementation);
if (data.length > 0) {
Address.functionDelegateCall(newImplementation, data);
} else {
_checkNonPayable();
}
}
Solidity
복사
contracts/proxy/ERC1967/ERC1967Utils.sol
_setImplementation은 newImplementation의 코드 길이를 확인한 후 아래와 같이 IMPLEMENTATION_SLOT에 주소를 저장한다. 같이 전달된 데이터가 있다면 새로 설정된 Implementation으로 delegatecall을 하고, 데이터가 없다면 _checkNonPayable을 불러 msg.value가 0임을 보장한다.
StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = newImplementation;
Solidity
복사
잘 보면 해당 함수는 기존의 Implementation과 새로운 Implementation의 주소를 비교하고 있지 않다. 즉, 같은 컨트랙트로의 업그레이드도 가능한 것이다.
TransparentUpgradeableProxy
TransparentUpgradeableProxy는 ERC1967Proxy를 상속하여 admin을 설정하고 조회하는 getter함수, _fallback함수 및 proxy upgrade함수를 추가했다.
upgradeToAndCall을 interface로 정의한 이유
ProxyAdmin에서 owner가 ProxyAdmin을 통해 프록시를 업그레이드할 수 있어야 한다. 하지만 upgradeToAndCall은 이 컨트랙트에서 정의된 것이 아니기 때문에 ABI에 기록되지 않는다. 그래서 ProxyAdmin에서 해당 인터페이스를 사용할 수 있도록 인터페이스를 만들어 둔 것이다.
이와는 별개로 upgradeToAndCall은 TransparentUpgradeableProxy에 직접 구현된 함수가 아니기 때문에 ProxyAdmin에서 이를 부르면 _fallback()를 통해 처리된다. 아무 함수나 사용해도 같은 처리가 되지만, upgradeToAndCall를 인터페이스에 명시하고 이를 사용하게끔 하는 것은 admin이 해당 함수를 통해 프록시를 업그레이드한다는 것을 명시적으로 알린다. _fallback()에서 검증하기도 편하기도 하다.
생성자에서 바로 ERC1967의 생성자가 Implementation주소와 calldata를 설정할 수 있도록 _logic과 _data를 받는다. 또한, initialOwner를 받아 ProxyAdmin을 배포하여 프록시의 첫 owner를 설정한다.
_fallback()을 보면 sender가 admin이고 msg.sig를 통해 부른 함수가 upgradeToAndCall인지 확인한 다음 _dispatchUpgradeToAndCall을 불러 프록시를 업그레이드한다. 이 말은 proxy admin은 업그레이드뿐만 아니 Implementation으로 임의의 호출을 하기 위해서도 항상 upgradeToAndCall을 호출해야 한다는 뜻이다. 위에서 잠깐 언급했듯이 upgradeToAndCall은 새 implementation의 주소가 기존과 다르지 않아도 되므로 같은 주소를 설정하고 호출하고자 하는 함수의 콜데이터를 전달하면 문제없이 Implementation의 함수를 부를 수 있다.
sender가 아니라면 Proxy의 _fallback()을 불러 Implementation컨트랙트로 delegatecall을 한다.
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.2.0) (proxy/transparent/TransparentUpgradeableProxy.sol)
pragma solidity ^0.8.22;
import {ERC1967Utils} from "../ERC1967/ERC1967Utils.sol";
import {ERC1967Proxy} from "../ERC1967/ERC1967Proxy.sol";
import {IERC1967} from "../../interfaces/IERC1967.sol";
import {ProxyAdmin} from "./ProxyAdmin.sol";
interface ITransparentUpgradeableProxy is IERC1967 {
/// @dev See {UUPSUpgradeable-upgradeToAndCall}
function upgradeToAndCall(address newImplementation, bytes calldata data) external payable;
}
contract TransparentUpgradeableProxy is ERC1967Proxy {
address private immutable _admin;
error ProxyDeniedAdminAccess();
constructor(address _logic, address initialOwner, bytes memory _data) payable ERC1967Proxy(_logic, _data) {
_admin = address(new ProxyAdmin(initialOwner));
// Set the storage value and emit an event for ERC-1967 compatibility
ERC1967Utils.changeAdmin(_proxyAdmin());
}
function _proxyAdmin() internal view virtual returns (address) {
return _admin;
}
function _fallback() internal virtual override {
if (msg.sender == _proxyAdmin()) {
if (msg.sig != ITransparentUpgradeableProxy.upgradeToAndCall.selector) {
revert ProxyDeniedAdminAccess();
} else {
_dispatchUpgradeToAndCall();
}
} else {
super._fallback();
}
}
function _dispatchUpgradeToAndCall() private {
(address newImplementation, bytes memory data) = abi.decode(msg.data[4:], (address, bytes));
ERC1967Utils.upgradeToAndCall(newImplementation, data);
}
}
Solidity
복사
ProxyAdmin
owner는 upgradeAndCall을 통해 proxy를 업그레이드하거나 임의의 호출을 전달할 수 있다.
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.2.0) (proxy/transparent/ProxyAdmin.sol)
pragma solidity ^0.8.22;
import {ITransparentUpgradeableProxy} from "./TransparentUpgradeableProxy.sol";
import {Ownable} from "../../access/Ownable.sol";
contract ProxyAdmin is Ownable {
string public constant UPGRADE_INTERFACE_VERSION = "5.0.0";
constructor(address initialOwner) Ownable(initialOwner) {}
function upgradeAndCall(
ITransparentUpgradeableProxy proxy,
address implementation,
bytes memory data
) public payable virtual onlyOwner {
proxy.upgradeToAndCall{value: msg.value}(implementation, data);
}
}
Solidity
복사
유틸리티: ERC1967Utils
ERc1967과 관련하여 admin, implementation, beacon proxy설정 및 변경 등과 관련된 유틸리티 함수가 포함된 컨트랙트다.
Transparent에서 admin을 설정할 때 이 함수가 불린다. admin 슬롯에 AdminProxy의 주소를 설정한다. 물론 Transparent에서는 이 슬롯을 읽지 않고 immutable변수로 admin을 조회한다.
function changeAdmin(address newAdmin) internal {
emit IERC1967.AdminChanged(getAdmin(), newAdmin);
_setAdmin(newAdmin);
}
function _setAdmin(address newAdmin) private {
if (newAdmin == address(0)) {
revert ERC1967InvalidAdmin(address(0));
}
StorageSlot.getAddressSlot(ADMIN_SLOT).value = newAdmin;
}
Solidity
복사
contracts/proxy/ERC1967/ERC1967Utils.sol
비콘 프록시를 사용하는 경우를 위해 비콘 프록시를 설정하고 변경하는 함수다.
ERC1967 beacon slot에 beacon proxy를 저장한다.
function _setBeacon(address newBeacon) private {
if (newBeacon.code.length == 0) {
revert ERC1967InvalidBeacon(newBeacon);
}
StorageSlot.getAddressSlot(BEACON_SLOT).value = newBeacon;
address beaconImplementation = IBeacon(newBeacon).implementation();
if (beaconImplementation.code.length == 0) {
revert ERC1967InvalidImplementation(beaconImplementation);
}
}
Solidity
복사
Beacon Proxy주소를 다시 설정하고 데이터가 있는 경우 beacon proxy로 임의의 호출을 한다(delegatecall).
function upgradeBeaconToAndCall(address newBeacon, bytes memory data) internal {
_setBeacon(newBeacon);
emit IERC1967.BeaconUpgraded(newBeacon);
if (data.length > 0) {
Address.functionDelegateCall(IBeacon(newBeacon).implementation(), data);
} else {
_checkNonPayable();
}
}
Solidity
복사
3줄요약
1.
Implementation과 Proxy의 함수 선택자 충돌을 방지하기 위해 아예 Proxy에서 fallback()을 제외한 public함수를 모두 없애버렸다.
2.
gas saving을 위해 admin을 immutable로 선언하고 대신 AdminProxy를 통해 admin 변경을 꾀한다.
3.
admin은 AdminProxy의 upgradeAndCall을 사용해 프록시를 업그레이드하거나 Implementation으로 임의의 호출을 할 수 있다.