Skip to main content

2. ERC1967, TransparentProxy, UUPS

이전 글에 이어서 프록시 패턴에 대해 본격적으로 살펴보려고 한다.

프록시 패턴은 기본적으로 delegatecall 과 fallback 함수의 조합을 통해 구현된다.

fallback 함수

fallback 함수란 컨트랙트에 존재하지 않는 함수가 호출되었을 경우 대신해서 호출되는 함수이다.

0.6 이전의 fallback 함수

function() external payable {}

0.6 이후의 fallback 함수

  • fallback : 호출된 함수가 컨트랙트에 존재하지 않을때 작동하는 함수
  • receive : calldata 없이 컨트랙트에 이더가 전송됐을 때 작동하는 함수
fallback() external payable {}
receive() external payable {}

오픈재플린의 ERC1967Proxy, TransparentProxy, UUPS 등 프록시 패턴을 구현한 여러 방식들은 모두 Proxy 추상 컨트랙트를 기반으로 구현되어있다.

openzeppelin-contracts/contracts/proxy/Proxy.sol
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.6.0) (proxy/Proxy.sol)

pragma solidity ^0.8.0;

/**
* @dev This abstract contract provides a fallback function that delegates all calls to another contract using the EVM
* instruction `delegatecall`. We refer to the second contract as the _implementation_ behind the proxy, and it has to
* be specified by overriding the virtual {_implementation} function.
*
* Additionally, delegation to the implementation can be triggered manually through the {_fallback} function, or to a
* different contract through the {_delegate} function.
*
* The success and return data of the delegated call will be returned back to the caller of the proxy.
*/
abstract contract Proxy {
/**
* @dev Delegates the current call to `implementation`.
*
* This function does not return to its internal call site, it will return directly to the external caller.
*/
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())
}
}
}

/**
* @dev This is a virtual function that should be overridden so it returns the address to which the fallback function
* and {_fallback} should delegate.
*/
function _implementation() internal view virtual returns (address);

/**
* @dev Delegates the current call to the address returned by `_implementation()`.
*
* This function does not return to its internal call site, it will return directly to the external caller.
*/
function _fallback() internal virtual {
_beforeFallback();
_delegate(_implementation());
}

/**
* @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if no other
* function in the contract matches the call data.
*/
fallback() external payable virtual {
_fallback();
}

/**
* @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if call data
* is empty.
*/
receive() external payable virtual {
_fallback();
}

/**
* @dev Hook that is called before falling back to the implementation. Can happen as part of a manual `_fallback`
* call, or as part of the Solidity `fallback` or `receive` functions.
*
* If overridden should call `super._beforeFallback()`.
*/
function _beforeFallback() internal virtual {}
}

Proxy 컨트랙트의 코드를 살펴보면, _delegate라는 인라인 어셈블리로 적힌 내부 함수가 있고, 이 함수를 fallback 함수와 receive 함수에서 호출하고 있는 것을 볼 수 있다. 현재 이 컨트랙트에서는 외부에서 호출할 수 있는 함수를 따로 만들어두지 않았기 때문에, 외부에서 어떤 calldata를 갖고 호출하더라도 실행되는 것은 fallback 함수, 즉 _beforeFallback_delegate 두 함수가 실행될 것이다.

_delegate 함수에는 트랜잭션에 담긴 calldata를 통하여 _implementation(구현 컨트랙트 주소)에 delegatecall을 하고, 성공했다면 return 데이터를 반환하고 실패했다면 revert 시키는 로직이 담겨있다.

이와 같이 delegatecall과 fallback 함수의 조합을 통하여, 이전 글에서처럼 프록시 컨트랙트 쪽에 함수를 정의할 필요 없이, 구현 컨트랙트 쪽에 있는 함수들을 유동적으로 호출하는 방식이 가능해진다. 이 방식을 적용하면 구현 컨트랙트를 업그레이드하는 경우에 함수명과 인자, return 되는 데이터까지 프록시 컨트랙트에 얽매이지 않고 수정할 수 있게 된다.

ERC1967

하지만 이전 글에서 살펴보았듯이, delegatecall을 사용할 때 스토리지충돌이 발생할 수 있다는 문제는 여전히 해결되지 않은 채로 남아있다.

특히 프록시 컨트랙트는 구현 컨트랙트에 담긴 로직을 호출하기 위해 구현 컨트랙트의 주소값이 꼭 필요한데, 이 주소값이 담긴 변수가 충돌하여 예상치 못한 값으로 변경된다면 프록시 컨트랙트가 제대로 동작하지 못하게 되는 심각한 문제로 이어진다.

이 문제를 해결하기 위해 오픈재플린에는 ERC1967을 기반으로한 ERC1967이 구현되어있다.

문제를 해결하는 아이디어는 의외로 간단하다. 구현 컨트랙트의 주소가 저장되는 스토리지 슬롯의 위치를 임의로 배정해주는 것이다.

openzeppelin-contracts-upgradeable/contracts/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol
abstract contract ERC1967UpgradeUpgradeable {
/**
* @dev Storage slot with the address of the current implementation.
* This is the keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1, and is
* validated in the constructor.
*/
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;


/**
* @dev Returns the current implementation address.
*/
function _getImplementation() internal view returns (address) {
return StorageSlotUpgradeable.getAddressSlot(_IMPLEMENTATION_SLOT).value;
}

/**
* @dev Stores a new address in the EIP1967 implementation slot.
*/
function _setImplementation(address newImplementation) private {
require(AddressUpgradeable.isContract(newImplementation), "ERC1967: new implementation is not a contract");
StorageSlotUpgradeable.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation;
}

/**
* @dev Perform implementation upgrade
*
* Emits an {Upgraded} event.
*/
function _upgradeTo(address newImplementation) internal {
_setImplementation(newImplementation);
emit Upgraded(newImplementation);
}
}

_IMPLEMENTATION_SLOT의 값이 도출된 과정은 “eip1967.proxy.implementation” 를 해싱한 후에 1을 뺀 값이라고 주석에 설명 되어있다.

이후 _setImplementation 함수나 _getImplementation 함수에서는 해당 슬롯값을 활용하여 구현 컨트랙트의 주소를 저장하거나 조회하는 것을 볼 수 있다.

이전 글에서 솔리디티의 스토리지 레이아웃에 대해 살펴볼 때, 컨트랙트의 변수들이 스토리지에 할당되는 기본적인 방식은 선언되는 순서에 따라 슬롯넘버가 할당되는 방식이며, 매핑타입의 경우 value가 저장되는 방식은 매핑 타입 변수가 최초에 할당된 슬롯넘버와 매핑의 key값을 함께 해싱한 슬롯넘버에 할당된다는 것을 알 수 있었다.

ERC1967에서 구현 컨트랙트 주소의 스토리지 슬롯넘버를 임의로 지정하여 저장하는 방식은 매핑 타입의 value가 저장되는 방식을 차용한 방식이다. 일반적인 경우에 사용되지 않을 특정 슬롯넘버에 구현 컨트랙트 주소를 저장해놓았기 때문에 이후 구현 컨트랙트에서 선언되는 변수들에 의해 구현 컨트랙트 주소에 대한 스토리지 충돌이 발생하는 것을 막을 수 있는 것이다.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract ERC1967 is ERC1967Proxy, Ownable {
constructor(address _logic, bytes memory _data)
payable
ERC1967Proxy(_logic, _data)
{}

function upgradeTo(address newImplementation) external onlyOwner {
_upgradeToAndCall(newImplementation, bytes(""), false);
}

function upgradeToAndCall(address newImplementation, bytes calldata data)
external
payable
onlyOwner
{
_upgradeToAndCall(newImplementation, data, true);
}

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

위와 같이 ERC1967을 통해 업그레이드 가능한 컨트랙트를 구현하였을 때, 더 이상 구현 컨트랙트에 구현 컨트랙트 주소가 담긴 변수를 순서에 맞게 선언해줄 필요가 없어진 것을 볼 수 있다. 왜냐하면 구현 컨트랙트 주소는 StorageSlot 라이브러리를 활용하여 미리 정해진 슬롯넘버의 스토리지슬롯에 저장되기 때문이다.

contracts/ERC1967/v4.sol
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/StorageSlot.sol";

contract ERC1967ImplementationV4 {
bytes32 internal constant _IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

mapping(address => uint256) public counts;

function count() external {
counts[msg.sender] += 3;
}

function storageTest(address _target) external {
StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = _target;
}

function getAddress() external view returns (address target_) {
target_ = StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value;
}
}
console.log("V4 ------ storage test ------");
const storageTest = await proxy.storageTest(users[0].address); await storageTest.wait();
console.log("users[0]:", users[0].address); console.log("implementation:", await erc1967Proxy.implementation());
테스트 결과
V4 ------ storage test ------
users[0]: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
implementation: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

참고로 당연한 결과겠지만, 프록시 컨트랙트에서 사용하고 있는 슬롯넘버를 구현 컨트랙트에서 동일하게 사용하게 되면 스토리지 충돌이 발생한다.


물론 이와 같은 방식을 통해 해결한 것은 프록시 컨트랙트에 필수적으로 필요한 구현 컨트랙트 주소 변수에 한정된 스토리지 충돌이다. 업그레이드 되는 구현 컨트랙트에 선언된 변수가 이전 버전의 구현 컨트랙트에서 사용되던 변수와 충돌할 수 있는 위험은 여전히 그대로 존재한다.

그렇다면 스토리지 충돌 이외에는 프록시 패턴의 단점은 존재하지 않고 이제 ERC1967을 그대로 사용하기만 하면 되는 것일까?

생성자 함수

이 단점은 ERC1967의 단점이라기 보다는 delegatecall을 기반으로 하는 프록시 패턴 자체의 단점이라고 볼 수 있다.

프록시 패턴을 구현할 때 로직이 담긴 구현 컨트랙트에서는 constructor를 사용할 수 없다. 정확히 말해 사용할 수 없다기보다는 설사 구현 컨트랙트에서 생성자 함수를 사용하여 변수들에 값을 초기화했다고 하더라도 의미가 없다.

이전 글에서 보았듯이 프록시 패턴의 기본 아이디어는 데이터와 로직을 분리하는 것이고, 데이터는 새롭게 배포된 구현 컨트랙트의 스토리지를 활용하는 것이 아니라 이미 배포되어있던 프록시 컨트랙트의 스토리지를 이어서 활용한다는 것이 업그레이드의 의미이기 때문이다.

실제 프로덕트로 활용되고 있는 컨트랙트 데이터는 프록시 컨트랙트의 데이터일 것이기 때문에, 구현 컨트랙트가 배포되면서 실행된 생성자 함수의 로직은 의미없이 동작한 채로 끝날 것이다. 생성자 함수는 컨트랙트가 배포되는 순간에 한 번 실행된 이후로 프록시 컨트랙트에서 호출할 수 없기 때문에, 만약 초기 셋팅이 필요하다면 생성자 함수가 아닌 다른 방식을 사용해야 한다.

일반적으로 사용되는 방식은 구현 컨트랙트에서 임의의 초기화 함수를 만들어놓고 버전 별로 딱 한 번만 실행되고, 한 번 호출된 이후로는 호출되더라도 실행되지 않게 제한 해놓는 방식이다.

같은 이유로 프록시 패턴을 사용할 때는 구현 컨트랙트를 작성할 때 변수에 초기값을 대입하는 방식을 사용할 수 없다. 대입한 값은 구현 컨트랙트의 스토리지에 저장돼있을 뿐, 프록시 컨트랙트의 스토리지와는 상관이 없기 때문이다.

함수 충돌

다시 ERC1967로 돌아와서, ERC1967을 사용했을 때 발생할 수 있는 위험 중에는 스토리지 충돌만 존재하는 것은 아니다. 위에서 테스트를 위해 구현된 ERC1967 컨트랙트를 살펴보면, 구현 컨트랙트의 주소를 변경하는 upgradeTo 함수와 업그레이드 하면서 구현 컨트랙트에 미리 작성해놓은 초기화 함수를 함께 호출하는 경우에 활용하는 upgradeToAndCall 함수, 그리고 현재 구현 컨트랙트의 주소를 조회하는 implementation 함수가 외부 함수로 존재하는 것을 볼 수 있다.

우리는 여기서 한 가지 합당한 궁금증을 가질 수 있다. 만약 구현 컨트랙트에도 upgradeTo라는 이름의 함수가 존재한다면, 프록시 컨트랙트로 upgradeTo 함수를 호출 했을 때, 프록시 컨트랙트에 존재하는 함수가 동작해야 정상적으로 동작한 것일까 아니면 구현 컨트랙트에 존재하는 함수가 동작해야 정상적으로 동작한 것일까?

프록시 컨트랙트에 구현 컨트랙트의 주소값이 꼭 필요한 것처럼, 업그레이드를 위해서는 구현 컨트랙트의 주소를 수정하는 로직의 함수가 꼭 필요하다. 물론 구현 컨트랙트를 작성하는 개발자가 이 점을 잘 숙지하고 있을 것이기 때문에 구현 컨트랙트에 굳이 프록시 컨트랙트에서 자체적으로 사용하는 함수와 동일한 함수명과 인자를 가진 함수를 만들지는 않을 것이다.

하지만 사람은 언제나 실수를 할 수 있을 뿐더러, 더 까다로운 문제는 함수명이 동일하지 않더라도 동일한 함수로 인식되는 경우가 발생할 수 있다는 것이다.

컨트랙트가 함수를 식별하는 방식

컨트랙트에 어떤 함수를 호출하는 요청이 들어왔을 때, 호출된 함수가 무엇인지는 트랜잭션의 데이터 필드(calldata)에 들어있는 데이터들을 통해 결정된다. 여기에는 함수의 이름과 인자 타입을 keccak256으로 해싱한 함수 시그니처의 4바이트 함수 식별자와 인자값들이 포함되어있다.

fallback 함수를 기반으로 구현된 프록시 컨트랙트는 calldata에 담긴 함수 식별자와 동일한 식별자를 가진 함수가 존재하는지 확인하고, 존재하지 않으면 fallback 함수를 실행시킬 것이다. 그러면 fallback 함수에서는 구현 컨트랙트에 해당 함수를 delegatecall 하는 로직을 수행할 것이다.

문제는 함수 식별자가 해시값의 앞부분 4바이트 값만으로 이루어진다는 것이다. 때문에 함수명과 인자가 다른 함수라하더라도 동일한 함수 식별자를 갖게 되는 경우가 발생할 수 있다. 하나의 컨트랙트 안에서 함수 식별자가 충돌되는 경우에는 솔리디티 컴파일러가 컴파일 과정에서 에러를 발생시키기 때문에 미리 방지할 수 있다.

"DeclarationError" : 유효하지 않거나 혹은 의결 할수 없는(unresolvable), 식별자 이름충돌입니다.

하지만 프록시 컨트랙트와 구현 컨트랙트에서처럼 서로 다른 컨트랙트에서 함수 식별자가 중복되는 경우는 컴파일 과정에서 방지할 수 없다.


contracts/ERC1967/v3.sol
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;

contract ERC1967ImplementationV3 {
mapping(address => uint256) public counts;

function count() external {
counts[msg.sender] += 3;
}

function upgradeTo(address _target) external {
counts[_target] += 3;
}
}

위처럼 구현 컨트랙트에 프록시 컨트랙트와 동일한 이름과 파라미터를 가진 upgradeTo 함수가 있을 때, 해당 함수를 호출하면 어떻게 될까?

console.log("V3 ------ upgradeTo function Error ------");
await expect(proxy.upgradeTo(users[0].address)).to.be.revertedWith(
"ERC1967: new implementation is not a contract"
);

테스트 결과를 보면, 업그레이드 하려는 구현 컨트랙트의 주소가 컨트랙트인지 확인하는 require문에 걸려 revert 되는 것을 볼 수 있다.

프록시 컨트랙트에는 해당 함수식별자를 가진 함수가 이미 존재하기 때문에 fallback 함수로 넘어가지 않고 프록시 컨트랙트의 upgradeTo 함수가 실행된다.

만약 함수호출자가 컨트랙트를 업그레이드 하려던게 아니라, 구현 컨트랙트에 있는 target 주소에 카운트를 3 더 해주는 기능을 수행하고 싶었던 것이라면 요청의 실패가 무척 당황스웠을 것이다.

이처럼 호출자의 의도와는 다르게 컨트랙트의 기능들이 동작할 수 있기 때문에 ERC1967을 사용할 때는 함수 충돌의 문제를 고려해야한다.

TransparentProxy

ERC1967의 함수 충돌 문제를 해결하기 위해 나온 방식이 오픈재플린의 투명프록시이다.

투명프록시가 함수 충돌 문제를 해결하는 아이디어는 호출자가 누구인지를 기준으로 프록시 컨트랙트의 자체 함수를 호출할지, 구현 컨트랙트의 함수를 호출할지를 결정하게 하는 것이다.

openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol
contract TransparentUpgradeableProxy is ERC1967Proxy {
/**
* @dev Initializes an upgradeable proxy managed by `_admin`, backed by the implementation at `_logic`, and
* optionally initialized with `_data` as explained in {ERC1967Proxy-constructor}.
*/
constructor(
address _logic,
address admin_,
bytes memory _data
) payable ERC1967Proxy(_logic, _data) {
_changeAdmin(admin_);
}

/**
* @dev Modifier used internally that will delegate the call to the implementation unless the sender is the admin.
*/
modifier ifAdmin() {
if (msg.sender == _getAdmin()) {
_;
} else {
_fallback();
}
}

/**
* @dev Returns the current admin.
*
* NOTE: Only the admin can call this function. See {ProxyAdmin-getProxyAdmin}.
*
* TIP: To get this value clients can read directly from the storage slot shown below (specified by EIP1967) using the
* https://eth.wiki/json-rpc/API#eth_getstorageat[`eth_getStorageAt`] RPC call.
* `0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103`
*/
function admin() external ifAdmin returns (address admin_) {
admin_ = _getAdmin();
}

/**
* @dev Returns the current implementation.
*
* NOTE: Only the admin can call this function. See {ProxyAdmin-getProxyImplementation}.
*
* TIP: To get this value clients can read directly from the storage slot shown below (specified by EIP1967) using the
* https://eth.wiki/json-rpc/API#eth_getstorageat[`eth_getStorageAt`] RPC call.
* `0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc`
*/
function implementation() external ifAdmin returns (address implementation_) {
implementation_ = _implementation();
}

/**
* @dev Changes the admin of the proxy.
*
* Emits an {AdminChanged} event.
*
* NOTE: Only the admin can call this function. See {ProxyAdmin-changeProxyAdmin}.
*/
function changeAdmin(address newAdmin) external virtual ifAdmin {
_changeAdmin(newAdmin);
}

/**
* @dev Upgrade the implementation of the proxy.
*
* NOTE: Only the admin can call this function. See {ProxyAdmin-upgrade}.
*/
function upgradeTo(address newImplementation) external ifAdmin {
_upgradeToAndCall(newImplementation, bytes(""), false);
}

/**
* @dev Upgrade the implementation of the proxy, and then call a function from the new implementation as specified
* by `data`, which should be an encoded function call. This is useful to initialize new storage variables in the
* proxied contract.
*
* NOTE: Only the admin can call this function. See {ProxyAdmin-upgradeAndCall}.
*/
function upgradeToAndCall(address newImplementation, bytes calldata data) external payable ifAdmin {
_upgradeToAndCall(newImplementation, data, true);
}

/**
* @dev Returns the current admin.
*/
function _admin() internal view virtual returns (address) {
return _getAdmin();
}

/**
* @dev Makes sure the admin cannot access the fallback function. See {Proxy-_beforeFallback}.
*/
function _beforeFallback() internal virtual override {
require(msg.sender != _getAdmin(), "TransparentUpgradeableProxy: admin cannot fallback to proxy target");
super._beforeFallback();
}
}

투명프록시의 코드를 살펴보면, 구현 컨트랙트를 업그레이드 하거나 프록시 컨트랙트의 어드민을 바꾸는 등의 프록시 자체 함수들에는 모두 ifAdmin이라는 modifier가 추가 돼있는 것을 볼 수 있다.

ifAdmin의 로직을 보면 sender가 어드민인 경우에만 자체 함수를 실행하고 어드민이 아닌 경우에는 _fallback을 실행한다. 또한 _beforeFallback 함수에서는 sender가 어드민인 경우에 revert 시키는 것을 볼 수 있다. _beforeFallback 함수는 _fallback 함수 안에서 delegatecall이 실행되기 전에 호출되기 때문에 sender가 어드민인 경우에는 구현 컨트랙트에 대한 함수 호출이 차단되고, 오로지 프록시 컨트랙트의 자체 함수들에만 접근할 수 있게 된다.

ERC1967에서는 구현 컨트랙트에 프록시 컨트랙트와 동일한 함수 식별자를 가진 함수가 존재한다면, 구현 컨트랙트의 함수를 호출하려는 의도로 함수를 호출하더라도 fallback 함수가 실행되지 않고 프록시 컨트랙트 자체 함수가 실행되었을 것이다.

반면 투명 프록시에서는 호출자가 어드민인지 아닌지에 따라서 호출자의 의도를 결정 짓는 방식이라고 볼 수 있다. 호출자가 어드민이라면 프록시 컨트랙트의 어드민 기능을 사용하기 위해 자체 함수를 호출했을 것이라고 보고, 어드민이 아니라면 자체 함수와 동일한 함수식별자 함수가 호출됐더라도 무조건 구현 컨트랙트로 delegatecall하는 방식인 것이다.

프록시 어드민

투명프록시를 사용할 때 어드민을 EOA 주소로 설정하기 보다는, 프록시 컨트랙트의 어드민 기능을 호출하는 함수들이 구현된 어드민 컨트랙트를 따로 배포하여 해당 컨트랙트 주소로 설정하는 것이 일반적이다.

어드민을 EOA로 설정하면 해당 어카운트는 프록시 컨트랙트의 자체 함수들만 호출할 수 있기 때문에, 업그레이드와 관련된 어드민 어카운트와 구현 컨트랙트에 존재하는 비즈니스 로직과 연계된 어드민 기능을 사용하는 어카운트를 분리하여 관리해야한다.

컨트랙트를 프록시 어드민으로 설정하고, 프록시 컨트랙트의 함수를 호출하는 어드민 컨트랙트의 함수에 따로 권한을 설정하는 방식을 사용하면 동일한 어카운트로도 구현 컨트랙트의 기능과 프록시 컨트랙트의 어드민 기능을 사용할 수 있게 된다. (또한 여러 어카운트를 어드민으로 설정할 수도 있게 된다.)

contracts/transparent/v2.sol
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;

import "./v1.sol";

contract TransparentImplementationV2 is TransparentImplementationV1 {
function initialize(uint256 _count) external virtual override {
uint256 version = 2;
require(!initializes[version], "already initialize");

counts[msg.sender] = _count + 1;
initializes[version] = true;
}

function count() external override {
counts[msg.sender] += 2;
}

function admin() external view returns (address) {
return msg.sender;
}
}

위와 같이 프록시 컨트랙트의 자체 함수와 동일한 admin 함수가 구현 컨트랙트에도 존재한다고 했을 때,

console.log("proxyAdmin:", proxyAdmin.address);
console.log(
"admin() from proxyAdmin",
await proxyAdmin.getProxyAdmin(proxy.address)
);
console.log("users[0]", users[0].address);
console.log("admin() from users[0]", await proxy.admin());

테스트 결과는 아래와 같다.

테스트 결과
proxyAdmin: 0x5FbDB2315678afecb367f032d93F642f64180aa3
admin() from proxyAdmin 0x5FbDB2315678afecb367f032d93F642f64180aa3
users[0] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
admin() from users[0] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
openzeppelin-contracts/contracts/proxy/transparent/ProxyAdmin.sol
pragma solidity ^0.8.0;

function getProxyAdmin(TransparentUpgradeableProxy proxy) public view virtual returns (address) {
// We need to manually run the static call since the getter cannot be flagged as view
// bytes4(keccak256("admin()")) == 0xf851a440
(bool success, bytes memory returndata) = address(proxy).staticcall(hex"f851a440");
require(success);
return abi.decode(returndata, (address));
}

프록시 컨트랙트의 admin 함수를 호출해서 return 해주는 어드민 컨트랙트의 getProxyAdmin 함수를 통해 호출했을 때는 sender가 프록시의 어드민이기 때문에 프록시 컨트랙트에 있는 admin 함수가 실행된 것을 볼 수 있고, 같은 어카운트로 프록시 컨트랙트에 바로 admin 함수를 호출했을 때는 _fallback 으로 넘어가서 구현 컨트랙트의 admin 함수가 실행된 것을 볼 수 있다.

@openzeppelin/hardhat-upgrades

hardhat에서 제공하는 플러그인을 사용하면 프록시 패턴을 활용한 구현 컨트랙트 배포 및 업그레이드를 보다 쉽게 진행할 수 있는데, 가장 많이 사용되는 upgradeProxy 함수의 내부 코드를 살펴보면 프록시 컨트랙트에 등록된 어드민이 EOA인지 CA인지에 따라 다르게 동작하는 것을 볼 수 있다.

async function getUpgrader(
proxyAddress: string,
signer: Signer
): Promise<Upgrader> {
const { provider } = hre.network;

const adminAddress = await getAdminAddress(provider, proxyAddress);
const adminBytecode = await getCode(provider, adminAddress);

if (adminBytecode === "0x") {
// No admin contract: use TransparentUpgradeableProxyFactory to get proxiable interface
const TransparentUpgradeableProxyFactory =
await getTransparentUpgradeableProxyFactory(hre, signer);
const proxy = TransparentUpgradeableProxyFactory.attach(proxyAddress);

return (nextImpl, call) =>
call ? proxy.upgradeToAndCall(nextImpl, call) : proxy.upgradeTo(nextImpl);
} else {
// Admin contract: redirect upgrade call through it
const manifest = await Manifest.forNetwork(provider);
const AdminFactory = await getProxyAdminFactory(hre, signer);
const admin = AdminFactory.attach(adminAddress);
const manifestAdmin = await manifest.getAdmin();

if (admin.address !== manifestAdmin?.address) {
throw new Error(
"Proxy admin is not the one registered in the network manifest"
);
}

return (nextImpl, call) =>
call
? admin.upgradeAndCall(proxyAddress, nextImpl, call)
: admin.upgrade(proxyAddress, nextImpl);
}
}

adminBytecode가 존재하지 않는다는 것은 해당 어카운트가 컨트랙트가 아니라는 것이기 때문에 이 경우에는 프록시 컨트랙트에 있는 업그레이드 함수를 직접 호출하고, 어드민이 컨트랙트라면 어드민 컨트랙트 주소를 가져와서 어드민 컨트랙트에 있는 업그레이드 함수를 호출하는 식으로 동작하는 것이다.

투명프록시의 단점

이렇게 투명프록시를 통하여 ERC1967에 존재하던 함수 충돌의 문제를 해결하는 과정을 살펴보았다.

하지만 역시 투명프록시에도 단점은 존재한다. ERC1967에서 시작하여 투명프록시로 이어져오면서 구현 컨트랙트 주소의 스토리지 충돌 문제, 함수 충돌 문제와 같은 보안 관련 문제들은 어느 정도 해결되었다고 볼 수 있다.

다만 문제가 해결되었다는 결과도 중요하지만, 얼마나 효율적으로 문제를 해결했는지 그 과정도 중요할 것이다.

투명프록시의 방식을 다시 되짚어보면, 함수 충돌 문제를 해결하기 위해 항상 호출자가 누구인지를 필수적으로 확인해야한다.

프로덕트 환경에서 프록시 컨트랙트의 업그레이드 기능이 호출되는 빈도는 구현 컨트랙트의 비즈니스 로직이 담긴 기능들이 호출되는 빈도에 비해 극히 낮을 것이다. 그럼에도 컨트랙트의 기능이 호출될 때마다 호출자가 어드민인지 여부를 항상 확인하는 투명프록시의 방식은 그리 효율적이라고 볼 수 없다.

특히 이더리움을 사용하면 가스비 문제는 보안 문제만큼이나 중요한 문제이다. 컨트랙트 사용자들의 입장에서는 불필요한 작업을 최대한 줄여 가스비를 낮춰주는게 보안적으로 문제가 없는 컨트랙트만큼이나 좋은 컨트랙트를 판단하는 지표일 것이다.

UUPS

투명프록시의 비효율적인 부분을 개선하고 보다 효율적인 방식으로 함수 충돌 문제를 해결하기 위해 나온 것이 UUPS이다.

UUPS는 EIP-1822에서 따온 이름으로 Universal Upgradeable Proxy Standard의 약자이다.

투명프록시가 가진 비효율의 요점은, 함수 충돌을 해결하기 위해 어떤 요청에서든 프록시 컨트랙트에 등록된 어드민 주소값에 접근하여 요청자의 주소와 동일한지 여부를 항상 확인해야한다는 것이었다.

그렇다면 UUPS는 어떤 방식으로 이런 비효율을 해결했을까? UUPS에서는 함수 충돌 문제를 해결하기 위해 호출자를 기준으로 호출 함수를 구분하는 방식이 아니라, 프록시 컨트랙트에 존재하는 자체 함수들도 아예 구현 컨트랙트로 옮기는 방식을 선택했다.

기존에는 업그레이드와 관련된 기능의 함수들은 프록시 컨트랙트를 배포할 때 자체 함수들로 이미 구현되어있고, 구현 컨트랙트에는 업그레이드 이외의 실제 사용해야할 기능들이 함수로 구현되어 배포 및 업그레이드 되는 방식이었다.

하지만 UUPS에서는 프록시 컨트랙트에 자체 함수가 존재하지 않고 업그레이드를 포함한 모든 기능들이 구현 컨트랙트에 존재한다. 때문에 프록시 컨트랙트는 어떤 함수에 대한 호출이 들어와도 무조건 fallback 함수가 실행되어 구현 컨트랙트의 함수를 호출하게 된다.

openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol
abstract contract UUPSUpgradeable is
Initializable,
IERC1822ProxiableUpgradeable,
ERC1967UpgradeUpgradeable
{
function __UUPSUpgradeable_init() internal onlyInitializing {}

function __UUPSUpgradeable_init_unchained() internal onlyInitializing {}

/// @custom:oz-upgrades-unsafe-allow state-variable-immutable state-variable-assignment
address private immutable __self = address(this);

/**
* @dev Check that the execution is being performed through a delegatecall call and that the execution context is
* a proxy contract with an implementation (as defined in ERC1967) pointing to self. This should only be the case
* for UUPS and transparent proxies that are using the current contract as their implementation. Execution of a
* function through ERC1167 minimal proxies (clones) would not normally pass this test, but is not guaranteed to
* fail.
*/
modifier onlyProxy() {
require(
address(this) != __self,
"Function must be called through delegatecall"
);
require(
_getImplementation() == __self,
"Function must be called through active proxy"
);
_;
}

/**
* @dev Check that the execution is not being performed through a delegate call. This allows a function to be
* callable on the implementing contract but not through proxies.
*/
modifier notDelegated() {
require(
address(this) == __self,
"UUPSUpgradeable: must not be called through delegatecall"
);
_;
}

/**
* @dev Implementation of the ERC1822 {proxiableUUID} function. This returns the storage slot used by the
* implementation. It is used to validate that the this implementation remains valid after an upgrade.
*
* IMPORTANT: A proxy pointing at a proxiable contract should not be considered proxiable itself, because this risks
* bricking a proxy that upgrades to it, by delegating to itself until out of gas. Thus it is critical that this
* function revert if invoked through a proxy. This is guaranteed by the `notDelegated` modifier.
*/
function proxiableUUID()
external
view
virtual
override
notDelegated
returns (bytes32)
{
return _IMPLEMENTATION_SLOT;
}

/**
* @dev Upgrade the implementation of the proxy to `newImplementation`.
*
* Calls {_authorizeUpgrade}.
*
* Emits an {Upgraded} event.
*/
function upgradeTo(address newImplementation) external virtual onlyProxy {
_authorizeUpgrade(newImplementation);
_upgradeToAndCallUUPS(newImplementation, new bytes(0), false);
}

/**
* @dev Upgrade the implementation of the proxy to `newImplementation`, and subsequently execute the function call
* encoded in `data`.
*
* Calls {_authorizeUpgrade}.
*
* Emits an {Upgraded} event.
*/
function upgradeToAndCall(address newImplementation, bytes memory data)
external
payable
virtual
onlyProxy
{
_authorizeUpgrade(newImplementation);
_upgradeToAndCallUUPS(newImplementation, data, true);
}

/**
* @dev Function that should revert when `msg.sender` is not authorized to upgrade the contract. Called by
* {upgradeTo} and {upgradeToAndCall}.
*
* Normally, this function will use an xref:access.adoc[access control] modifier such as {Ownable-onlyOwner}.
*
* ```solidity
* function _authorizeUpgrade(address) internal override onlyOwner {}
* ```
*/
function _authorizeUpgrade(address newImplementation) internal virtual;

/**
* @dev This empty reserved space is put in place to allow future versions to add new
* variables without shifting down storage in the inheritance chain.
* See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps
*/
uint256[50] private __gap;
}

그럼 오픈재플린의 UUPS 코드를 살펴보자. 기존 프록시 컨트랙트에 있었던 upgradeTo 함수와 upgradeToAndCall 함수 등이 이제는 구현 컨트랙트에 모두 포함되어있다.

업그레이드 함수들에는 onlyProxy라는 modifier가 붙어있다. __self에는 구현 컨트랙트가 배포될 때 구현 컨트랙트 주소값이 담겨있을 것이다.

require(address(this) != __self, "Function must be called through delegatecall");

만약 delegatecall을 통해 호출됐다면, 현재 콘텍스트의 컨트랙트 주소(address(this))가 구현 컨트랙트의 주소값이 아닌 delegatecall을 실행한 컨트랙트 (프록시 컨트랙트) 의 주소일 것이다.

require(_getImplementation() == __self, "Function must be called through active proxy");

그리고 해당 프록시 컨트랙트에서 _getImplementation()로 구현 컨트랙트의 주소를 조회했을 때 현재 호출한 구현 컨트랙트의 주소와 동일한지를 체크함으로써 프록시 컨트랙트의 적절한 요청이 맞는지를 확인하는 것이다.


앞서 ERC1967에서는 구현 컨트랙트 주소가 담긴 상태 변수의 스토리지 충돌 문제를 해결하기 위해 해당 변수를 임의의 스토리지 슬롯넘버에 할당하는 방식을 사용했다. 그렇다면 업그레이드 함수가 구현 컨트랙트에 있을 때, 구현 컨트랙트는 업그레이드 되는 주소를 할당할 슬롯넘버를 알고 있어야할 것이다.

openzeppelin-contracts-upgradeable/contracts/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol
/**
* @dev Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
*
* Emits an {Upgraded} event.
*/
function _upgradeToAndCallUUPS(
address newImplementation,
bytes memory data,
bool forceCall
) internal {
// Upgrades from old implementations will perform a rollback test. This test requires the new
// implementation to upgrade back to the old, non-ERC1822 compliant, implementation. Removing
// this special case will break upgrade paths from old UUPS implementation to new ones.
if (StorageSlotUpgradeable.getBooleanSlot(_ROLLBACK_SLOT).value) {
_setImplementation(newImplementation);
} else {
try
IERC1822ProxiableUpgradeable(newImplementation).proxiableUUID()
returns (bytes32 slot) {
require(
slot == _IMPLEMENTATION_SLOT,
"ERC1967Upgrade: unsupported proxiableUUID"
);
} catch {
revert("ERC1967Upgrade: new implementation is not UUPS");
}
_upgradeToAndCall(newImplementation, data, forceCall);
}
}

UUPSUpgradeable 컨트랙트가 상속 받고 있는 ERC1967의 _upgradeToAndCallUUPS 함수를 살펴보면, 컨트랙트가 업그레이드 될 때 새로운 구현 컨트랙트 주소로 proxibleUUID 함수를 call하여 return된 슬롯 넘버값이 기존 구현 컨트랙트를 할당해두었던 슬롯 넘버와 같은지를 확인한다.

만약 슬롯이 같지 않거나, 새 구현 컨트랙트에 proxibleUUID 함수가 존재하지 않으면 업그레이드가 불가능한 것을 볼 수 있다.


업그레이드와 관련된 권한 설정은 _authorizeUpgrade 함수를 통해 설정하면 된다.

_authorizeUpgrade는 업그레이드 로직이 실행되기 전에 항상 먼저 실행되는 함수이다.

contracts/UUPS/v1.sol
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract UUPSImplementationV1 is UUPSUpgradeable, OwnableUpgradeable {
mapping(address => uint256) public counts;

function initialize(uint256 _count) public virtual initializer {
__Ownable_init();

counts[msg.sender] = _count;
}

function _authorizeUpgrade(address) internal override onlyOwner {}

function count() external virtual {
counts[msg.sender] += 1;
}
}

위와 같이 _authorizeUpgrade 함수를 오버라이딩하여 업그레이드에 대한 권한 및 조건에 대한 로직을 추가해주면 된다.


이처럼 UUPS는 기존 프록시 컨트랙트에 있던 업그레이드 함수를 구현 컨트랙트로 모두 옮김으로써 보다 가스 효율적인 방식으로 함수 충돌 문제를 해결했다. 앞서 얘기했듯이 동일 컨트랙트 안에서는 동일한 함수 식별자를 가진 함수가 존재할 수 없기 때문에, 구현 컨트랙트에 있는 함수들끼리는 함수 충돌이 일어나지 않는다. 이제 프록시 컨트랙트는 호출자가 누구인지를 확인할 필요없이 무조건 구현 컨트랙트로 delegatecall 하기만 하면 된다.

UUPS

투명프록시와 UUPS의 가스비 차이를 살펴보면, 프록시 컨트랙트의 배포 비용이 매우 저렴해진 것을 볼 수 있다. 기존 프록시 컨트랙트에 구현되어있던 함수들이 모두 구현 컨트랙트로 옮겨갔기 때문에 당연한 결과일 것이다. 만약 투명 프록시를 사용하면 통상적으로 프록시 어드민으로 설정할 어드민 컨트랙트도 함께 배포하기 때문에, UUPS를 사용하면 이 비용도 절약할 수 있다.

물론 반대로 구현 컨트랙트를 배포할 때는 비용이 증가한 것을 볼 수 있다. 아무래도 프록시 컨트랙트는 최초 한 번 배포되고, 이후에는 업그레이드 될 때마다 구현 컨트랙트만 배포되기 때문에 장기적인 배포 비용을 생각하면 UUPS가 더 비싸다고 볼 수 있다.

하지만 중요한 것은 컨트랙트 런타임시에 오버헤드가 줄어들었다는 점이다. UUPS에서는 함수가 호출될 때마다 어드민 변수 스토리지에 접근하여 값을 비교하는 연산을 하지 않아도 되기 때문이다.


UUPS를 사용할 때 유념해야할 점은 업그레이드 기능이 구현 컨트랙트에 존재하기 때문에, 만약 새롭게 업그레이드한 구현 컨트랙트 쪽에 업그레이드를 수행하는 함수가 존재하지 않는다면 더 이상의 업그레이드가 불가능해진다는 점이다.

오히려 이런 특성을 이용하여 더 이상의 업그레이드가 불가능하게 만들고 싶을 때 고의적으로 업그레이드 기능을 배제한 구현 컨트랙트로 업그레이드 하는 식으로 UUPS를 운영할 수도 있다.

UUPS의 또 다른 장점 중에 하나는 업그레이드 기능이 프록시 컨트랙트에 분리돼있지 않기 때문에, 업그레이드 기능을 어드민 기능으로만 제한하지 않고 서비스와 연계하여 유저들도 사용할 수 있는 기능으로 유연하게 만들 수 있다는 점이다.

예를 들어 업그레이드 함수의 권한을 단순히 특정 어카운트로 설정해놓는 것이 아니라, 어떤 조건을 달성한 어카운트인 경우에 실행할 수 있는 식으로 설정해놓는다면, 기능 추가에 대한 거버넌스 투표를 통해 선정된 기능을 제안한 유저가 업그레이드를 직접 수행하게 만들 수 있다.

(물론 투명프록시에서도 어드민 컨트랙트를 통해 이런 로직을 적용시키는 것이 가능하지만, 유저는 업그레이드 기능과 구현 컨트랙트에 있는 기능을 별 개의 컨트랙트로 나누어 사용해야 한다.)

맺음말

ERC1967부터 투명프록시, UUPS까지 업그레이드 가능한 컨트랙트를 구현하기 위해 가장 보편적으로 사용되고있는 여러 방식의 프록시 패턴에 대해 알아보았다.

물론, 마지막에 살펴본 UUPS 방식도 장점만 존재하는 것은 아닐 것이다. 기본적으로 구현 컨트랙트에서 선언되는 변수들의 스토리지 충돌은 프록시 패턴을 사용할 때 여전히 큰 문제를 불러일으킬 수 있는 부분이기 때문에 항상 유념하여 사용해야 한다.

또한 컨트랙트는 배포시에 24KB의 크기 제한이 있기 때문에 컨트랙트를 계속 업그레이드 하면서 기능 및 스토리지 변수를 추가하는 데에도 근본적인 한계가 있다. 이런 단점을 해결하기 위해 제안된 다이아몬드 패턴이라는 것도 존재한다. 다이아몬드 패턴에 대해서도 다음에 기회가 된다면 자세히 살펴보도록 하겠다.

이렇게 오픈재플린을 필두로한 이더리움 생태계에서는 더 효율적이고 더 안정적인 업그레이드 가능한 컨트랙트를 구현하기 위해 새로운 아이디어와 기술적 접근을 통한 오픈소스들이 끊임없이 제안되고 있다.

스마트 컨트랙트의 불변성

개인적으로 끊임없이 개선되고 발전하는 방식들을 이해하고 학습하며 개발 환경에 적용하는 것도 중요하지만, 업그레이드 가능한 컨트랙트라는 키워드에 접근할 때는 기술적인 고민과 함께 컨트랙트에 대한 의미론적인 고민도 병행돼야한다고 생각한다.

이전 글에서 컨트랙트의 불변성에 대해 얘기할 때 언급했듯이, 스마트 컨트랙트란 근본적으로 디지털 계약서로서의 역할을 하기 위해 탄생한 기술이다. 그런 면에서 계약의 불변성은 단순히 불편하고 그렇기 때문에 극복돼야하는 제약사항으로서만 존재하는 것이 아니라 web3 생태계에서 탈중앙화된 서비스를 제공하기 위한 중요한 정체성 중에 하나이다.

따라서 어떤 디앱에서 업그레이드 가능한 컨트랙트를 도입한다고 했을 때는 무조건적으로 편리하고 효율적인 업그레이드만 추구하기보다, 탈중앙성과 서비스 관리 측면 사이에서의 적절한 균형이 항상 고려돼야한다.

아무리 서비스 개선과 더 다양한 기능 추가를 목적으로 하는 업그레이드라고 하더라도, 어드민 차원에서 단 한번의 트랜잭션으로 컨트랙트의 주요 로직이 변경될 수 있다고 한다면 유저는 과연 그 컨트랙트를 계약서로서 신뢰할 수 있을까? 그리고 그런 계약서들로 이루어진 어플리케이션을 과연 디앱이라고 부를 수 있을까?


글에서 언급된 예시코드는 링크로 들어가면 볼 수 있고, 직접 테스트 결과를 확인해볼 수 있다.