UUPS Proxyパターンを使ったアップグレード可能なスマートコントラクト開発
初めまして。 Turingumの開発部門に所属のニア(@nia_tn1012)です。
チューリンガム技術ブログでは、プロジェクトで使用したコントラクト・技術の紹介や、開発の時のコツなど、技術的な知見について書いていきたいと思います。
是非とも、Twitterや本ブログのフォローをしていただけますと幸いです!
1. はじめに
通常、EVMブロックチェーン上にデプロイされたスマートコントラクトは不変です。
もし、バグの修正や機能の追加をできるようにしたい場合、OpenZeppelinのUpgradeableプラグインを利用して、アップグレード可能なコントラクトを実装する手があります。
今回は、OpenZeppelin バージョン4.1で登場したUUPS(Universal Upgradeable Proxy Standard) Proxyパターンを利用したアップグレード可能なスマートコントラクトを実装する方法と、それ以前のバージョンからあるTransparent Proxyパターンとの比較を紹介します。
2. UUPS ProxyパターンとTransparent Proxyパターンのコントラクトの構造
Transparent Proxyパターンでは、
プロキシコントラクト( TransparentUpgradeableProxy )
プロキシ管理コントラクト( ProxyAdmin )
実装コントラクト
の3種類のコントラクトから構成されます。コントラクトのデータはプロキシコントラクトが保持します。
ユーザー(User)は、プロキシコントラクトにアクセスし、プロキシコントラクトは delegatecall で実装コントラクトの対応する関数を呼び出します。
コントラクトの管理者(Admin)は、プロキシ管理コントラクトの upgrade 関数を経由して実行し、プロキシコントラクトの upgradeTo 関数を呼び出し、実装コントラクトのアドレスを新しいものに更新します。(プロキシ管理コントラクトからの呼び出しの時のみ、プロキシコントラクト側の関数を呼び出す仕組みのため、実装コントラクト側にupgradeTo 関数を定義しても、ユーザーからの呼び出しにおいて競合することはありません)
UUPS Proxyパターンでは、
プロキシコントラクト( ERC1967Proxy )
実装コントラクト
2種類のコントラクトから構成されます。 Transparent Proxyパターンと似た構造をしていますが、 upgradeTo 関数の場所がプロキシコントラクトではなく、実装コントラクトの方にあります。 これにより、 upgradeTo 関数にアクセス制御を柔軟にカスタマイズしたり、upgradeTo 関数を無効化してアップグレード不可にしたりすることができます。
UUPS ProxyパターンとTransparent Proxyパターンとの比較をまとめると、以下のようになります。
3. UUPS Proxyパターンを利用したアップグレード可能なコントラクトを実装する方法
ここでは、Hardhatを使用した時の方法を紹介します。
3.1. パッケージのインストール
以下の2つのnpmパッケージを追加します。
@openzeppelin/contracts-upgradeable
@openzeppelin/hardhat-upgrades
yarn add -D @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades
import "@openzeppelin/hardhat-upgrades";
hardhat.config.ts に、 @openzeppelin/hardhat-upgrades をインポートします。
3.1. コントラクトの実装
例えば、以下のようなコントラクトの場合、
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/Ownable.sol";
contract Greeter is Ownable {
string private message;
constructor(string memory _message) {
message = _message;
}
function greet() public view returns (string memory) {
return message;
}
function setGreeting(string memory _message) public onlyOwner {
message = _message;
}
}
UUPS Proxyパターンに対応させるには、以下のように実装します。
OpenZeppelinの UUPSUpgradeable を継承に追加します。
OpenZeppelinの Ownable を OwnableUpgradeable に変更します。
コントラクトのコンストラクタの代わりに、初期化用の関数(initializer付きのpublic関数)を定義し、その中で OwnableUpgradeable の初期化関数 __Ownable_init を呼び出します。
UUPSUpgradeable の _authorizeUpgrade 関数をオーバーライドし、実装するコントラクトの要件に応じたアクセス制御を設定します。
_authorizeUpgrade 関数は、UUPSUpgradeable の upgradeTo 関数から呼び出されるため、_authorizeUpgrade 関数にアクセス制限を設定することで、upgradeTo 関数を実行できるユーザーを制限することができます。
例えば、コントラクトの所有者のみにアップグレードを許可したい場合、 OwnableUpgradeable の onlyOwner 修飾子を付けます。
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract GreeterUpgradeable is UUPSUpgradeable, OwnableUpgradeable {
string private message;
function initialize(string memory _message) public initializer {
__Ownable_init();
message = _message;
}
function greet() public view returns (string memory) {
return message;
}
function setGreeting(string memory _message) public onlyOwner {
message = _message;
}
function _authorizeUpgrade(address newImplementation) internal
virtual override onlyOwner {}
}
OwnableUpgradeable の onlyOwner 修飾子代わりに、 AccessControlUpgradeable の onlyRole 修飾子を付けると、特定の権限を持つユーザーのみアップグレードを許可することができます。その場合、初期化用の関数でアップグレードを許可したいユーザーのアドレスに権限を忘れずに付与しておきましょう。
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
contract GreeterUpgradeable is UUPSUpgradeable, AccessControlUpgradeable {
string private message;
function initialize(
address _admin,
string memory _message
) public initializer {
__AccessControl_init();
_grantRole(DEFAULT_ADMIN_ROLE, _admin);
message = _message;
}
function greet() public view returns (string memory) {
return message;
}
function setGreeting(string memory _message) public
onlyRole(DEFAULT_ADMIN_ROLE) {
message = _message;
}
function _authorizeUpgrade(address newImplementation) internal
virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
}
なお、Transparent Proxyパターンとの違いは、以下の2点です。
継承するコントラクトが、 Initialize から UUPSUpgradeable になります。
ただし、 UUPSUpgradeable は Initialize を継承するため、初期化用の関数の実装方法は同じです。
UUPSUpgradeable の _authorizeUpgrade のオーバーライドが必要になります。
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract GreeterUpgradeable is Initializable, OwnableUpgradeable {
string private message;
function initialize(string memory _message) public initializer {
__Ownable_init();
message = _message;
}
function greet() public view returns (string memory) {
return message;
}
function setGreeting(string memory _message) public onlyOwner {
message = _message;
}
}
3.3. コントラクトのデプロイ
デプロイ用スクリプトでは、hardhatのupgradesの deployProxy 関数を呼び出し、ContractFactoryオブジェクトと初期化用の関数の引数に渡すパラメーターを指定します。
deployProxy 関数の戻り値の address プロパティの値は、プロキシコントラクトのアドレスになります。
import { ethers, upgrades } from "hardhat";
async function main() {
const factory = await ethers.getContractFactory("GreeterUpgradeable");
const contract = await upgrades.deployProxy(factory, ["Hello World!"]);
await contract.deployed();
console.log("GreeterUpgradeable deployed to:", contract.address);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
yarn hardhat run scripts/deploy.ts --network ${NETWORK}
# ${NETWORK}:
# `hardhat.config.ts`の`HardhatUserConfig.networks`で定義したネットワーク
# ("mainnet"や"goerli"、"matic"、"mumbai"など)
OpenZeppelinのUpgradeableプラグインを利用した場合、プラグインの方で自動的にUUPS ProxyパターンとTransparent Proxyパターンを識別するため、どちらのパターンでも同じデプロイ用スクリプトが利用できます。
3.4. コントラクトの検証
EtherscanやPolygonscanでのコントラクト検証は、プロキシコントラクトのアドレスを指定して行います。
yarn hardhat verify ${PROXY_CONTRACT_ADDRESS} --network ${NETWORK}
# ${PROXY_CONTRACT_ADDRESS}:
# 検証したいコントラクトのアドレス
# (UUPS ProxyパターンやTransparent Proxyパターンを利用したコントラクトの場合、
# プロキシコントラクトのアドレスを指定します。)
EtherscanやPolygonscanでコントラクトを操作したい場合、必ずプロキシコントラクトのContractタブにある、Read as ProxyまたはWrite as Proxyから行います。
ちなみに、実装コントラクトのアドレスは、EtherscanやPolygonscanのEventタブにある、 Upgraded イベントの1つ目の引数( implementation )の値となります。
3.5. コントラクトのアップグレード
アップグレード用スクリプトでは、hardhatのupgradesの upgradeProxy 関数を呼び出し、デプロイ済みのプロキシコントラクトのアドレスとContractFactoryを指定します。
upgradeProxy 関数の戻り値の address プロパティの値は、同じプロキシコントラクトのアドレスになります。
import { ethers, upgrades } from "hardhat";
async function main() {
const factory = await ethers.getContractFactory("GreeterUpgradeable");
const contract = await upgrades.upgradeProxy(
"<PROXY_CONTRACT_ADDRESS>", // ← プロキシコントラクトのアドレスを指定します。
factory
);
await contract.deployed();
console.log("GreeterUpgradeable upgraded to:", contract.address);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
yarn hardhat run scripts/upgrade.ts --network ${NETWORK}
こちらもOpenZeppelinのUpgradeableプラグインを利用した場合、プラグインの方で自動的にUUPS ProxyパターンとTransparent Proxyパターンを識別するため、どちらのパターンでも同じアップグレード用スクリプトが利用できます。
4. コントラクトの実装時に気をつけること
4.1. 宣言済みの変数の型や順序を変えたり、削除したりしてはいけない
実装コントラクトからプロキシコントラクトに保持されているデータへ正しくアクセスするため、宣言済みの変数の型や順序を変更したり、削除したりしないようにしましょう。
新たに変数を宣言する場合、末尾の方に追加します。
contract Sample is UUPSUpgradeable {
string var1;
uint256 var2;
// ...
}
contract Sample2 is UUPSUpgradeable {
uint256 var1; // ← ❌ BAD: 宣言済みの変数の型を変更するのはNG
uint256 var2;
// ...
}
// ❌ BAD: 宣言済みの変数の順序を変更するのはNG
contract Sample2 is UUPSUpgradeable {
uint256 var2;
string var1;
// ...
}
contract Sample2 is UUPSUpgradeable {
string var1;
uint8 var3 // ← ❌ BAD: 宣言済みの変数の前に新たに変数を宣言するのはNG
uint256 var2;
// ...
}
contract Sample2 is UUPSUpgradeable {
string var1;
uint256 foo; // ← ❗️ NOTE: 変数の名前を変更しても、データは同じになります。
// ...
}
contract Sample2 is UUPSUpgradeable {
string var1;
uint256 var2;
uint8 var3; // ← ✅ GOOD: 新たに変数を宣言する時は、末尾の方に追加します。
// ...
}
変数を含むコントラクトを継承している場合、コントラクトを別のものに変更したり、継承する順序を変更したり、変数を含む別のコントラクトを新たに継承したり、削除したりしないようにしましょう。
OpenZeppelinなどのライブラリを利用する場合、どのコントラクトを継承するかをある程度決めておくと運用しやすいです。
contract BaseX is Initializable {
string var1;
// ...
}
contract BaseY is Initializable {
uint256 var2;
// ...
}
contract Sample is
UUPSUpgradeable,
BaseX,
BaseY
{
uint8 var3;
// ...
}
contract Sample2 is
UUPSUpgradeable,
OwnableUpgradeable, // ← ❌ BAD: 継承済みのコントラクトを別のものに変更するのはNG
BaseY
{
uint8 var3;
// ...
}
// ❌ BAD: 変数を含むコントラクトの継承する順序を変更するのはNG
contract Sample2 is
UUPSUpgradeable,
BaseY,
BaseX
{
uint8 var3;
// ...
}
contract Sample2 is
UUPSUpgradeable,
//BaseX, // ← ❌ BAD: 継承済みの変数を含むコントラクトを削除するのはNG
BaseY
{
uint8 var3;
// ...
}
contract BaseZ is Initializable {
bytes32 var4;
// ...
}
contract Sample2 is
UUPSUpgradeable,
BaseX,
BaseY,
BaseZ. // ← ❌ BAD: 新たに変数を含むコントラクトを継承するのはNG
{
uint8 var3;
// ...
}
// ✅ GOOD: 新たに変数を含むコントラクトを継承したい場合、
// アップグレード前のコントラクトを継承して実装する手があります。
contract Sample2 is Sample, BaseZ {
uint8 var3;
// ...
}
継承元のコントラクトにて、新たに変数を宣言すると、継承先のコントラクトでデータに正しくアクセスできなくなります。
contract BaseX is Initializable {
string var1;
// ...
}
contract BaseY is Initializable {
uint256 var2;
// ...
}
contract Sample is UUPSUpgradeable, BaseX, BaseY {
uint8 var3;
// ...
}
// ↓
contract BaseX2 is Initializable {
string var1;
// ↓ ❌ BAD: 継承元のコントラクトにて、新たに変数を宣言するのはNG
// (Sample2で、データに正しくアクセスできなくなるため)
string var4;
// ...
}
contract Sample2 is UUPSUpgradeable, BaseX2, BaseY {
uint8 var3;
// ...
}
この問題の解決策として、コントラクトの末尾にuint256型の固定配列の変数を宣言して、今後新たに変数を宣言できるスペースを確保する手があります。
contract BaseX is Initializable {
string var1;
uint256[49] private __gap; // ← 固定配列でスペースを確保
// ...
}
contract BaseY is Initializable {
uint256 var2;
uint256[49] private __gap;
// ...
}
contract Sample is UUPSUpgradeable, BaseX, BaseY {
uint8 var3;
uint256[49] private __gap;
// ...
}
// ↓
// ✅ GOOD
contract BaseX2 is Initializable {
string var1;
string var4;
uint256[48] private __gap; // ← 新たに宣言した分だけ、確保済みの固定配列の要素数を減らします。
// ...
}
contract Sample2 is UUPSUpgradeable, BaseX2, BaseY {
uint8 var3;
uint256[49] private __gap;
// ...
}
4.2. OpenZeppelinなどのコントラクトを利用する時は、全てアップグレード可能なものを継承する
アップグレード可能なコントラクトは、コンストラクタが使用できないため、OpenZeppelinなど、ライブラリからコントラクトを利用する場合、全てアップグレード可能な方を継承するようにします。
コントラクトによっては、コンストラクタの代わりとなる、onlyInitializing 修飾子付きの関数が定義されているため、初期化用の関数の中で、それらの関数を呼び出します。
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract ERC721OwnableUpgradeable is
UUPSUpgradeable,
// ↓ ❌ BAD: アップグレード不可能なコントラクトを継承するのはNG
ERC721,
Ownable
{
// ...
}
import "@openzeppelin-upgradeable/contracts/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin-upgradeable/contracts/access/OwnableUpgradeable.sol";
contract ERC721OwnableUpgradeable is
UUPSUpgradeable,
// ↓ ✅ GOOD: アップグレード可能なコントラクトを継承します。
ERC721Upgradeable,
OwnableUpgradeable
{
function initialize(string memory name_, string memory symbol_) public
initializer {
// コンストラクタの代わりに、onlyInitializing修飾子付きの関数を呼び出します。
__ERC721_init(name_, symbol_);
__Ownable_init();
}
// ...
}
5. 参考リンク
6. おわりに
今回は、OpenZeppelinのUUPS Proxyパターンを利用したアップグレード可能なスマートコントラクトを実装する方法と、Transparent Proxyパターンとの比較を紹介しました。
UUPS Proxyパターンが登場したことで、スマートコントラクトをアップグレードする際のアクセス制限を要件に応じて柔軟にカスタムでき、Transparent Proxyパターンよりガス代を節約できるようになりました。
Turingumでは、実績豊富なプロフェッショナル達が手厚くサポートしながら皆様のビジネスに合ったWeb3の構築を行うことができます。 Web3ビジネスをご検討中の方や、現在Web3に関してお困りの方は、是非お気軽に弊社へご相談ください!
この記事が気に入ったらサポートをしてみませんか?