프록시 패턴을 이용해서 이더리움 토큰을 발행해보자(feat. Hardhat)
March 11, 2025
배경
EVM에서는 이미 오래 전부터 업그레이더블 컨트랙트(Upgradable Contract)를 구현하기 위한 패턴들이 사용되어 왔다. 업그레이더블 컨트랙트는 EVM에 대한 깊은 이해가 필요하다. 필요한 배경지식은 다음과 같다.
- Account Storage Layout
- fallback function
- Yul Assembly
- delegatecall
여기서는 위 내용들을 필요할 정도만 간단하게 짚고 넘어갈 예정이다. 더 자세한 내용은 아래 정리한 레퍼런스에서 확인할 수 있다.
이후에는 Openzeppelin의 라이브리러와 hardhat을 이용해서 간단하게 컨트랙트를 배포하고 상호작용 해 볼 예정이다.
Hardhat을 정상적으로 이용하기 위해서는, 20 버전 이상의 node.js가 설치되어 있어야 한다.
구현에서 사용할 예시들은 yarn
패키지 매니저를 기반으로 한다.
Account Storage Layout
EVM에는 두 종류의 계정(Account)이 있다.
하나는 EOA(Extenrally Owned Account)로 보통 유저가 지갑을 통해서 소유하고 있는 계정을 말한다. 또 하나는 CA(Contract Account)로 블록체인 상에서 컨트랙트로 사용되는 주소를 말한다.
Account Storage는 CA와 관련이 있다.
EVM의 컨트랙트는 블록체인에서 작동하는 프로그램이다.
EVM에서는 컨트랙트 계정에 저장공간을 둔다.
이를 Account Storage
라고 한다.
EVM은 데이터를 저장할 때 RLP 인코딩
과 HP 인코딩
의 Raw한 형태로, 수정된 머클 패트리샤 트리
를 이용해서 저장하는 방식을 채택했다.
방금 설명한 각각의 인코딩 방식이나 자료구조는 자세히 다루지는 않겠다.
Account Storage
가 이와 같은 방식을 통해 블록체인 상에 저장된다는 것만 알아두면 된다.
Solidity로 작성하는 컨트랙트 코드 내에서 영구적으로 저장되는 데이터가 저장되는 변수를 상태변수(State Variable)
라고 한다.
이 상태변수를 차곡차곡 저장하는 방식을 정리한 것이 Account Storage Layout
이다.
Account Storage Layout은 256 bits의 크기의 데이터를 갖는 2^256 개의 슬롯(slot)으로 구성되어 있다. 0 부터 시작하는 index로 slot을 구별하며, 해당 index로 참조되는 slot에 값이 저장되는 식이다. 방금 언급했듯이 한 slot은 256 bits의 데이터를 저장할 수 있다. 아래의 간단한 솔리디티 코드로 살펴보도록 하자.
contract Example{
struct S {
uint sa;
uint32 sb
address sc;
}
uint a;
uint32 b;
uint128 c;
int128 d;
S s;
mapping(uint=>address) e;
}
a, b, c, d의 변수를 확인할 수 있다.
uint 데이터 타입을 가지고 있는 a는 256 bits 데이터를 가질 수 있다. a는 slot 0 하나를 모두 차지해서 사용한다.
b와 c는 각각 32 bits, 128 bits를 데이터를 가질 수 있다. 합쳐도 256 bits가 안되기 때문에 b와 c는 slot 1을 공유해서 사용한다. slot 1 은 96 bits 가 남아있다.
d는 128 bits 데이터를 갖는 변수다. slot 1 의 빈 자리가 남아있어도 d의 데이터를 온전히 한 슬롯에 같이 저장할 수 없다면 다음 슬롯에 할당된다. 그래서 d는 slot 2 를 이용한다.
s는 S 구조체 타입을 사용하는 변수다. 내부적으로 256 bits, 32 bits, 160 bits(address)를 사용한다. s는 위에서 설명했던 규칙으로 필드들이 slot을 차지하게 된다. 합이 256 bits를 넘어가면 해당 필드는 다음 slot으로 넘어간다. 필드의 차지하는 모든 slot이 곧 s의 slot이다. 여기서는 slot 3 과 4가 s의 slot이다.
solidity에는 여러 타입의 데이터가 있지만, 정적인 타입의 데이터는 위에서 설명한 것과 같이 데이터 크기를 기준으로 할당된다. map이나 동적배열 같은 동적 타입의 데이터는 실제 데이터를 다른 곳에 저장하고, 해당 위치를 참조하는 포인터를 slot에 256 bits로 저장하기 때문에 당장은 정적 데이터에 slot 을 할당하는 방식과 똑같이 이해해도 무방하다. e는 그래서 map 형식을 가지기 때문에 256 bits를 우선 가져야 한다. s가 이미 slot 4 까지 차지하고 있기 때문에, e는 slot 5를 할당받게 된다.
[Account Storage 할당 결과]
Account Storage Layout
은 이렇게 변수가 어떤 방식으로 slot을 할당받게 되는지에 대해서 이해하면 된다.
fallback function
컨트랙트의 함수를 실행하는 트랜잭션은 calldata를 갖는다.
이 calldata에는 어떤 함수를 실행해야 하는지를 정하는 Selector라는 값이 맨 앞 4 bytes로 표현된다.
이를테면 0x12345678aaaaaaaaaaa...
의 calldata가 있다면 '12345678'이 Selector다.
이 뒤로 오는 값은 함수를 실행하는 필요한 인수의 값이다.
Selector에 매칭이 안되면 컨트랙트는 컨트랙트 내에 fallback function이 구현되어 있을 시, 해당 calldata와 함께 fallback 함수를 실행할 수 있다.
뒤에서 다룰 inline assembly
인 yul을 통해 해당 데이터를 raw하게 처리할 수 있다.
Yul Assembly
하지만 yul을 자세하게 다루지는 않을 예정이다. Proxy Pattern을 구현하기 위한 yul의 사용처는 calldata를 받고, delegatecall 함수를 실행시키고, returndata를 반환하는 정도로 정해져있기 때문이다. yul은 EVM에서 제공하는 저레벨의 함수들을 메모리까지 직접 다루면서 사용할 수 있게 도와준다. 덕분에 인수도 없는 fallback 함수에서 트랜잭션의 calldata를 가져와 사용할 수 있게 만든다.
delegatecall
delegatecall이 사실 Proxy Contract를 가능하게 하는 핵심적인 함수다. delegatecall은 호출된 컨트랙트의 함수 로직을 호출한 컨트랙트의 상태값에 반영하기 위해 사용한다. 위의 그림을 통해 쉽게 확인이 가능할 것이다.
간단하게 설명하자면, 컨트랙트 A, B가 있는데 B의 로직으로 A의 상태값을 바꾸는 것이다. 이는 A에서 delegatecall을 호출하는데서 시작한다. delegatecall은 컨트랙트 주소를 지정할 수 있는데, 여기서는 B의 컨트랙트 주소가 된다. 그리고 A에서 B의 컨트랙트를 호출하기 위한 calldata를 만들어 내부 트랜잭션을 만들고 전송하면 B의 컨트랙트 로직이 실행된다. B는 자신의 Storage Layout을 그대로 사용하여 로직을 실행하지만, 실제 데이터는 컨트랙트 A의 상태값을 변경하고 있다. B의 함수 실행 종료 이후, 리턴값이 있다면 컨트랙트 A에 리턴값이 반환된다. 컨트랙트 A도 리턴값을 확인하고 함수를 종료하며 마무리한다.
어떻게 다른 컨트랙트의 값이 변경되는지를 고민하기보다는 EVM 차원에서 이렇게 동작하는 함수를 지원해준다고 이해해야 한다.
Problem 1. Storage Collision
이미 눈치 챈 사람도 있겠지만 delegatecall에 의해서 생기는 문제들이 있다. 하나는 storage collision으로, 상태값의 충돌이 생길 수 있다.
contract A {
uint count;
uint balance;
}
contract B {
uint balance;
function increaseBalance() external virtual {
balance += 1000;
}
}
위와 같이 A와 B의 컨트랙트가 있는 상황이다.
A가 balance의 값을 1000으로 변경하기 위해서 B 컨트랙트의 함수 increaseBalance()
호출했다고 가정해보자.
B의 balance는 0번 슬롯을 사용한다. A의 balance는 1번 슬롯을 사용한다.
컨트랙트 B는 자신의 로직대로 0번 슬롯의 값을 1000 증가시키게 함수를 실행한다.
하지만 상태값이 변경되는 컨트랙트 A의 0번 슬롯에는 count 변수가 있다.
이 때문에 A에서는 balance의 값이 수정되지 않고, 의도하지 않은 count 값이 변경이 되버린다.
이런 문제를 storage collision
이라고 한다.
Problem 2. Function Clashes
또 하나의 문제는 함수를 호출하는 컨트랙트 내의 함수와 호출되는 컨트랙트 내의 함수의 selector가 충돌하는 문제다. 전혀 다른 함수명과 인수를 갖고 있음에도 불구하고, selector는 함수의 signature를 해싱한 값의 앞 4 bytes 만 확인하기 때문에 같은 selector를 갖는 함수가 존재할 수 있다.
이 때문에 fallback 함수가 실행되지 않고, 기존 컨트랙트 내 함수가 대신 실행되는 문제를 함수 충돌(Function Clashes)이라고 한다.
delegatecall 함수를 사용할 때, Storage Collision와 Function Clahses, 이 두 문제가 있다는 것을 인지해야 한다.
Proxy Pattern
프록시 패턴을 구성하는 것은 기본적으로 Proxy Contract와 Logic Contract다.
- Proxy Contract는 fallback 함수 내부에서 delegatecall을 통해 Logic Contract의 로직을 실행시키는 주체이다.
- Logic Contract는 전달받은 calldata를 따라 구현되어 있는 함수의 로직을 실행시키는 역할을 한다.
프록시 패턴의 종류는 여러가지가 있다. 그 중 대표적인 프록시 패턴 중 두 개를 소개한다.
Transparent Proxy Pattern
Transparent Proxy Pattern은 업그레이드를 위해서 Logic Contract의 주소를 변경하는 함수를 구현해야하는 데, 이 함수를 Proxy Contract에 구현하는 방법을 말한다.
보통 이 함수의 이름은 upgradeTo
로 관행적으로 사용하고 있다.
함수 내부에서 Proxy Contract가 가리키는 Logic Contract의 주소를 변경하는 로직을 갖는다.
하지만 로직을 업그레이드 하는 행위는, 컨트랙트를 배포해서 운영하는 관리자만 가능해야 한다. 그리고 실제 로직을 이용하는 다른 유저 지갑 계정은 fallback을 통해서 Logic Contract의 함수를 실행해야 한다. 이를 위해서 Transparent Proxy Pattern에서는 fallback 함수에 관리자를 확인하는 분기문을 구현한다.
Transparent Proxy Pattern은 위와 같은 특징 때문에 블록체인 상에서 가스비를 더 많이 소비하게 된다.
그리고 내부적으로 upgradeTo
를 구현하고 있기 때문에 영구적으로 컨트랙트를 업그레이드 가능하게 만든다.
Universal Upgradeable Proxy Standard(UUPS, ERC-1822)
UUPS는 upgradeTo
함수를 로직 컨트랙트에 구현하는 패턴이다.
Logic Contract의 upgradeTo
에서만 함수 호출 계정이 관리자인지 체크하는 분기가 구현된다.
Proxy Contract의 fallback
함수는 관리자 체크 분기가 빠지기 때문에 upgradeTo
를 제외한 다른 함수를 실행시키는 데서 지불하는 불필요한 가스비 소모를 없앨 수 있다.
하지만 업그레이드 이후 새로운 Logic Contract에서 upgradeTo
를 구현하고 있지 않다면 더 이상 업그레이드가 가능하지 못하게 된다.
덕분에 더 이상 업그레이드가 가능하지 않아 탈중앙화의 취지에는 맞게 되지만, 의도치 않게 위 상황을 만들지 않으려면 다음 Logic Contract가 upgradeTo
함수를 구현하고 있는지 체크해야 한다.
Storage Collision을 해결하기 위한, Proxy Storage Slot(ERC-1967)
delegatecall을 사용하면서 생길 수 있는 문제로 Storage Collision이 있다. 두 컨트랙트가 같은 내용의 storage layout을 구성하지 않으면, 로직 컨트랙트에서 잘못된 상태값을 변경하는 문제다.
위의 이미지에서 확인할 수 있듯이, Proxy Contract와 ERC20 Contract는 다른 Storage Layout을 가지고 있다.
하필 ERC20 Contract에서 주요한 정보를 저장하는 _balances
변수가 Proxy Contract의 _implementation과 겹친다.
_implementation은 Proxy Contract에서 함수를 실행할 적에, 해당 주소를 갖는 컨트랙트를 저장해놓고 식별하기 위해 사용한다.
만약 ERC20의 transfer라는 함수를 실행하게 되면 _balances
의 slot 값을 변경하게 되는데, Proxy는 해당 slot을 _implementation
이 차지하고 있다.
_implementation이 바뀌어 버리면 더 이상 제대로 된 Logic Contract를 식별할 수 없게 되는 문제가 생긴다.
이 문제를 해결하기 위해 EIP-1967 에서는, 약속해둔 index를 갖는 slot에 값을 저장하는 방식을 제안한다.
contract ERC1967Upgrade {
byte32 private constant _IMPLEMENTATION_SLOT = bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
function _getImplementation() internal view returns (address) {
return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value;
}
}
library StorageSlot {
struct AddressSlot {
address value;
}
function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r.slot := slot
}
}
}
위 코드는 ERC-1967 의 예시코드 중 일부 필요한 부분만 발췌했다.
_IMPLEMENTATION_SLOT
은 eip1967.proxy.implementation
문자열을 해싱한 값이다.
실제 값은 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
이다.
keccak256의 결과값은 256 bits의 데이터이기 때문에 slot의 범위와 일치해서 그대로 사용할 수 있다.
solidity의 yul은 임의의 변수로 특정 slot에 저장된 값을 불러들이는 기능이 있다.
이렇게 반환된 값에 value를 참조하면 저장되었던 특정 주소값을 얻을 수 있다.
이 방식으로 위에서 문제가 되었던 _implementation
에 저장해야될 값을 대신 저장해놓고 필요할 때마다 가지고 와서 사용해서 충돌을 방지한다.
해싱한 값에 1을 빼는 작업은 브루트포스로 해싱을 계산하는 것을 어렵게 하기 위함이라고 설명하는데, 어짜피 공개되어 있는 정보인데 숨길 필요가 있나 의문이 들긴 하다.
간단하게 slot을 0 번에서부터 차례대로 쌓는 방식이 아닌, 특정할 수 있는 해시값을 인덱스로 갖는 slot으로 붕 띄어서 저장해놓아 충돌을 줄이고자 하는 것이 골자다.
구현
이번 포스트에서는 UUPS의 구현만 다룰 예정이다.
구현의 목표는 다음과 같다.
- Logic Contract를 배포한다.
- Proxy Contract를 배포한다.
- Proxy Contract를 통해 Logic Contract의 로직을 작동시켜 Proxy Contract의 상태값을 변경한다.
- Logic Contract를 다른 Logic Contract로 변경한다.
Openzeppelin에서는 Proxy Pattern을 구현하기 위한 컨트랙트 코드를 지원해주고 있다. 이번에 이용하는 컨트랙트는 다음과 같이 정리된다.
+-- contracts
+-- proxy
+-- ERC1967
+-- ERC1967Proxy.sol
+-- ERC1967Utils.sol
+-- utils
+-- UUPSUpgradeable.sol
+-- contracts-upgradeable
+-- proxy
+-- utils
+-- Initializable.sol
+-- access
+-- OwnableUpgradeable.sol
+-- token
+-- ERC20
+-- ERC20Upgradeable.sol
+-- ERC20BurnableUpgradeable.sol
hardhat 세팅
우선 hardhat을 세팅하자. hardhat은 EVM 컨트랙트를 컴파일하고, 배포하는 것을 도와주는 JavaScript/TypeScript 언어 기반 툴이다.
# 작업 폴더 만들기
mkdir upgradeable_contract
# 초기화 및 hardhat package 추가
cd upgradeable_contract
yarn init -y
yarn add -D hardhat
# hardhat 프로젝트 초기화 => Typescript + hardhat 선택, 종속성 설치 여부 y(Yes)
yarn hardhat init
# openzeppelin 라이브러리 추가
yarn add @openzeppelin/contracts @openzeppelin/contracts-upgradeable
컨트랙트 코드 작성
hardhat 까지 정상적으로 초기화를 했다면 작업 폴더에 여러 폴더와 파일들이 생성될 것이다. 로컬 서버에 배포해서 테스트할 프록시 패턴 컨트랙트를 작성해야 한다.
hardhat의 관행에 따라 생성된 contracts 폴더에 다음의 세 컨트랙트를 만들 예정이다.
+-- contracts
# Logic 컨트랙트 1
+-- TokenUpgradeable.sol
# Logic 컨트랙트 2
+-- TokenBurnableUpgradeable.sol
# Proxy 컨트랙트
+-- TokenProxy.sol
TokenUpgradeable
TokenUpgradeable.sol
의 코드이다.
// contracts/TokenUpgradeable.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import '@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol';
import '@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol';
import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol';
import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol';
contract TokenUpgradeable is Initializable, ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable {
constructor(){
_disableInitializers();
}
function initialize(address initialOwner) public initializer {
__ERC20_init("TEST", "TT");
__Ownable_init(initialOwner);
_mint(msg.sender, 5000000000000000000000000000);
}
function _authorizeUpgrade(
address newImplementation
) internal virtual override onlyOwner {}
}
업그레이드 전 로직을 담당할 Logic Contract다. ERC20 표준을 구현하고 있다.
initialize
함수를 새로 구현하고 있는데 이는 상속받고 있는 추상 컨트랙트인 Initializable
의 필수 구현 요소이다.
이 함수가 필요한 이유는, Proxy Contract의 초기화 때문이다.
Proxy Contract는 Logic Contract의 생성자를 이용하는게 힘들다.
Proxy Contract에 이번 예시로 들고 있는 ERC20의 메타데이터들을 초기화하자고 하자.
본래 하나의 컨트랙트였을 경우에는 컨트랙트를 배포하면서 실행하는 생성자를 통해 초기화를 할 수 있다.
하지만 Proxy Pattern에서는 Logic Contract를 배포하고 난 이후에 Proxy Contract를 배포하게 된다.
Proxy Contract의 생성자에서는 ERC20의 메타데이터를 초기화할 수 있는 방법이 없다.
EVM 구조적 특성으로 인해 어쩔 수 없이 초기화해주는 함수인 initialize
를 로직 컨트랙트에서 작성해 사후적으로 처리하고 있다.
로직 컨트랙트로 ERC20Upgradeable
을 이용하는 이유는, 기존 Openzeppelin이 제공하는 ERC20
컨트랙트는 초기화에 필요한 __ERC20_init
함수를 지원하지 않기 때문이다.
또한 ERC20Upgradeable
은 ERC-1967을 준수하고 있다.
즉 ERC20 에서 필수적으로 사용하는 상태값인 balances
나 name
, symbol
, totalSupply
를 충돌없이 사용하게끔, 해싱된 index의 slot에 저장하여 사용하게 구현하고 있다는 것이다.
대신 openzeppelin
에서 지원하는 ERC-1967는, slot index를 구하는 hash 문자열을 ERC-1967의 표준과는 다르게 구하고 있기 때문에 주의해야한다.
업그레이드를 하며 인덱스 값을 구하는 해싱 문자열이 바뀌면 Proxy Contract에 저장되던 상태값들이 다 무의미해지기 때문이다.
이는 다른 컨트랙트로 업그레이드할 때 신경쓰면 된다.
또 하나의 함수인 _authorizeUpgrade
는 upgradeTo
함수를 실행하고 있는 주체가 권한이 있는지 확인하기 위한 함수다.
이번 예시에서는 Proxy Contract를 생성한 EOA의 계정이 upgradeTo
를 실행할 수 있는 권한을 갖게 설정할 것이다.
ERC20BuranbleUpgradeable
도 이번 구현에 필요한 컨트랙트 중 하나다.
ERC20Upgradeable
에 Burnable 기능만 추가한 것이기 때문에 여기서 코드까지 소개하지는 않도록 하겠다.
TokenProxy
TokenProxy.sol
의 코드이다.
// contracts/TokenProxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract TokenProxy is ERC1967Proxy {
constructor(
address implementation,
bytes memory _data
) payable ERC1967Proxy(implementation, _data) {}
}
생성자만 존재하는 간단한 코드가 작성된다.
Logic Contract를 먼저 배포해서 얻은 주소를 TokenProxy 생성자의 인자인 implementation에 넘긴다. 상속 받는 ERC1967 생성자로 implementation이 인자로 넘어가는데, 그 곳에서 실제 implementation인 Logic Contract 주소가 저장된다.
생성자의 _data 값에 함수를 실행하기 위한 유의미한 값을 넣는다면, Proxy Contract의 생성과 동시에 Logic Contract에 delegatecall을 실행시킬 수 있다.
위에서 설명한 initialize
함수를 실행시키는 데 사용한다.
몸체는 ERC1967Proxy에서 상속하는 Proxy 이다.
추가적으로 Openzeppelin의 ERC1967Proxy
과 Proxy
코드를 확인해보자.
// @openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import {Proxy} from "../Proxy.sol";
import {ERC1967Utils} from "./ERC1967Utils.sol";
contract ERC1967Proxy is Proxy {
constructor(address implementation, bytes memory _data) payable {
ERC1967Utils.upgradeToAndCall(implementation, _data);
}
function _implementation() internal view virtual override returns (address) {
return ERC1967Utils.getImplementation();
}
}
// @openzeppelin/contracts/proxy/Proxy.sol
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.0) (proxy/Proxy.sol)
pragma solidity ^0.8.20;
abstract contract Proxy {
function _delegate(address implementation) internal virtual {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
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();
}
}
코드로 작성된 Proxy 컨트랙트에서 일반적으로 보여야 하는 fallback 함수가 구현되어 있는 것을 확인할 수 있다.
fallback 함수를 타고타고 들어가면 _delegate
함수가 호출되는 것을 확인할 수 있다.
_delegate
내부에서 드디어 delegatecall
를 확인할 수 있다.
Logic Contract의 주소를 얻기 위해서는 _implementation
함수를 구현할 필요가 있다.
이는 ERC1967Proxy
에서 간단하게 구현해내고 있다.
참고로 ERC1967Utils
는 ERC-1967의 구현을 돕는 라이브러리로, 여기서는 logic contract의 주소를 해싱된 index를 갖는 slot에서 가져올 수 있게 돕는 역할을 하고 있다.
UUPS는 Logic Contract에서 upgradeTo를 구현한다고 했는데, 언뜻 ERC1967Proxy 컨트랙트에서 upgradeTo가 보인다. 처음에는 필자도 헷갈렸는데, 1회성으로 Logic Contarct의 주소를 업데이트 하기 위해 생성자에서만 호출이되고 이후에는 사용이 안되는 함수다.
ignition 배포 코드 작성
hardhat은 컨트랙트의 쉬운 배포를 위한 ignition 기능을 제공한다. 코드 작성도 간단하다.
// ignition/modules/TokenUpgradable.ts
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
const TokenUpgradeableModule = buildModule("TokenUpgradeable", (m)=>{
const TokenUpgradeable = m.contract("TokenUpgradeable");
const initializeCall = m.encodeFunctionCall(TokenUpgradeable, 'initialize', [m.getAccount(0)])
const TokenProxy = m.contract("TokenProxy", [TokenUpgradeable, initializeCall])
return {TokenUpgradeable, TokenProxy};
})
export default TokenUpgradeableModule
여기서 눈여겨봐야할 곳은 m.encodeFunctionCall
이다.
이 함수를 통해서 initialize
함수를 실행시키기 위한 calldata를 만들 수 있다.
순서대로 컨트랙트, 함수명, 그리고 해당 함수를 실행시키는 데 사용할 파라미터 배열이 인자값으로 구성된다.
컨트랙트를 생성하는 지갑 주소를 initialize
에 넘기기 위해서 m.getAccount(0)
을 작성하고 있다.
이렇게 만들어진 calldata는 TokenProxy가 배포되면서 동시에 delegatecall을 통해 logic contract에 구현되어 있는 initialize
를 실행시킨다.
TokenProxy의 Owner는 m.getAccount(0)
에서 반환하는 주소가 지정된다.
앞으로 upgradeTo를 실행시킬 수 있는 권한은 Owner에 등록된 주소를 갖고 있는 계정한테 있다.
즉 여기서는 m.getAccount(0)
에게 권한이 있다.
로컬 네트워크 배포
# Solidity 파일 컴파일
yarn hardhat compile
# hardhat Local Network 실행
yarn hardhat node
# 로컬 네트워크 배포
yarn hardhat ignition deploy ./ignition/modules/TokenUpgradable.ts --network localhost
컨트랙트와 ignition 배포 코드가 정상적으로 작성이 되었다면 위의 명령을 통해 로컬 네트워크로 ERC-20을 사용하는 Proxy Pattern이 배포가 될 것이다.
테스트
hardhat을 기반으로 테스트 코드를 작성해봤다. 이 포스트에는 다루지 않은 TokenBurnableUpgradable 컨트랙트가 포함되어 있는 상태이기 때문에 테스트 실행시 문제가 있을 수 있다. 깃헙 리포지터리를 참고하기 바란다.
// test/upgradeable.ts
import { expect } from "chai";
import { ethers } from 'hardhat';
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { TokenBurnableUpgradeable, TokenUpgradeable } from "../typechain-types";
import { formatEther, formatUnits, parseEther } from "ethers";
describe("Upgradeable Token Contract", ()=>{
async function deployTokenFixture() {
const [signer, account]= await ethers.getSigners()
const Token = await ethers.deployContract("TokenUpgradeable");
const Proxy = await ethers.deployContract("TokenProxy", [await Token.getAddress(), Buffer.from("")]);
const TokenBurnable = await ethers.deployContract("TokenBurnableUpgradeable");
const proxy = Token.attach(Proxy) as TokenUpgradeable;
const ProxyWithBurnable = TokenBurnable.attach(proxy) as TokenBurnableUpgradeable;
await proxy.initialize(signer);
return {Proxy: proxy, ProxyWithBurnable, Token, TokenBurnable, signer, account};
}
describe("initialize", ()=>{
it("should have given metadata", async ()=>{
const metadata = {
name: "TEST",
symbol: "TT",
};
const {Proxy} = await loadFixture(deployTokenFixture);
const proxyName = await Proxy.name();
const proxySymbol = await Proxy.symbol();
expect({name: proxyName, symbol: proxySymbol}).to.be.eql(metadata);
})
it("should have same amount of supply", async ()=>{
const targetSupply = 5_000_000_000_000000000000000000n;
const { Proxy } = await loadFixture(deployTokenFixture);
const totalSupply = await Proxy.totalSupply();
expect(totalSupply).to.be.equal(targetSupply);
})
})
describe("Transfer", ()=>{
it("should transfer token to given account", async ()=> {
const { Proxy, signer, account } = await loadFixture(deployTokenFixture);
const targetDiffAmount = "5.0";
const beforeBalance = await Proxy.balanceOf(signer);
await Proxy.transfer(account, parseEther("5.0"));
const afterBalance = await Proxy.balanceOf(signer);
expect(formatEther(beforeBalance - afterBalance)).to.be.equal(targetDiffAmount)
})
})
describe("Upgradeable", ()=>{
it("should not accept calling 'burn' method before upgrade", async ()=>{
const { ProxyWithBurnable } = await loadFixture(deployTokenFixture);
await expect(ProxyWithBurnable.burn(parseEther("5.0"))).to.be.revertedWithoutReason();
})
it("should accept calling 'burn' method after upgrade", async ()=>{
const { ProxyWithBurnable, TokenBurnable, signer } = await loadFixture(deployTokenFixture);
const targetDiffAmount = "0.5"
await ProxyWithBurnable.upgradeToAndCall(TokenBurnable, Buffer.from(""));
const beforeBalance = await ProxyWithBurnable.balanceOf(signer);
await ProxyWithBurnable.burn(parseEther(targetDiffAmount));
const afterBalance = await ProxyWithBurnable.balanceOf(signer);
expect(formatEther(beforeBalance - afterBalance)).to.be.equals(targetDiffAmount);
})
})
})
- initialize: Proxy Contract인 TokenProxy의 메타데이터의 초기값 확인
- Transfer: Proxy Contract를 통해 토큰 전송 기능 정상 작동 여부 확인
- Upgradeable: 업그레이드 전후로 burn 기능 사용 가능 여부 확인
위에 리스팅된 기능들을 확인하는 테스트 코드를 작성했다. 전송 기능을 확인하기 위해서는 전송 전후의 잔고를 확인하고 있다. 업그레이드 적용 여부를 확인하기 위해서 업그레이드 전후로 burn 함수를 실행했을 때, 에러 발생 여부를 확인한다. 업그레이드 이후에는 burn으로 인한 잔고의 변경 여부를 확인하고 있다.
ethers 에서는 TokenProxy Contract를 그대로 이용하면 Type 문제 때문에 에러가 발생한다.
TokenProxy에서 직접적으로 ERC-20의 함수를 호출하는 방법은 없어서 attach
를 이용하고 있다.
attach
를 이용하면 TokenUpgradeable나 TokenBurnableUpgradeable의 컨트랙트 인터페이스를 TokenProxy의 주소를 가지고 이용할 수 있다.
즉 껍데기는 Logic Contract이고 알맹이는 Proxy Contract 일 수 있다는 것이다.
에러도 발생 문제도 해결해주기 때문에 테스트코드를 짤 때 요긴하게 사용했다.
# 테스트 실행
yarn hardhat test
[테스트 결과]
모든 테스트가 통과하는 것을 확인하며 Proxy Pattern의 구현을 마친다.