本記事では、Proxy Patternとよばれるスマートコントラクトをアップグレード可能にする実装をHardhatで行います。
背景
「スマートコントラクトのアップグレード方法が存在する」こと自体は知っていたのですが、肝心の「スマートコントラクトのアップグレードの具体的な方法」についてこれまで私は理解していませんでした。
最近ようやくその具体的な方法であるProxy Patternという概念を知ったので、より理解を深めるために、実装を体験してみたいと思った次第です。
Proxy Patternってなに?
スマートコントラクトをアップグレード可能にするための方式です。
実装的には、スマートコントラクトを以下の2つに分割します。
- ロジックが記述されたlogic contract
- ユーザーの代わりにlogic contractを呼び出すProxy Contract
ロジックの追加・修正などのアップグレードを行いたいときは、新たなlogic contractをデプロイし、proxy contractから新たなlogic contractを呼び出せるように設定します。
一度デプロイされた同一のスマートコントラクトに変更を加えることはできませんが、logic contractを入れ替えることで、proxy contractとやり取りするユーザー目線ではスマートコントラクトがアップグレードされたように見えるということだと理解しています。
詳細については以下の記事がおすすめです。Proxy Patternの概要から分類、実装まで記載されています。
日本語記事も案外たくさんあったので、情報収集はしやすい分野なのかもと思いました(尤も最終的に信頼すべきはOpenZeppelin等の公式ドキュメントですが)。
動作環境
- Node.js 20.11.0
- Hardhat 2.19.4
- Solidity 0.8.20
Hardhatプロジェクトの作成
適当なフォルダを作成し、Hardhatプロジェクトを起動します。
npx hardhat run
以下のコマンドで、必要なライブラリをインストールします。
$ npm install @openzeppelin/contracts @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades
詳細な方法は、こちらの記事の「Hardhatプロジェクトの構築」をご覧ください。
logic contractの作成
今回はERC20トークンのコントラクトを作成し、イベントの内容をアップデートしてみます。
まずはアップグレード前のコントラクト(今回はNewTokenV1.jsとしています)を作成します。
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract NewTokenV1 is Initializable, ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable{
// コンストラクタが使えないため、代わりにinitialize関数で初期化を行う
function initialize(address initialOwner) initializer public {
__ERC20_init("NewToken", "NEW");
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
function mint(address _to, uint256 _amount) public onlyOwner {
_mint(_to, _amount);
emit MintEvent(_to,_amount);
}
function _authorizeUpgrade(address newImplementation) internal onlyOwner override
{}
event MintEvent(address indexed to,uint256 indexed amount);
}
次に、アップグレード後のコントラクト(今回はNewTokenV2.jsとしています)を作成します。
変更点は、mint()実行時に発行されるイベントmintEventのみです。
文字列の引数をひとつ追加しています。
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract NewTokenV2 is Initializable, ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable{
// コンストラクタが使えないため、代わりにinitialize関数で初期化を行う
function initialize(address initialOwner) initializer public {
__ERC20_init("NewToken", "NEW");
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
function mint(address _to, uint256 _amount) public onlyOwner {
_mint(_to, _amount);
emit MintEvent(_to,_amount,"mint finished");
}
function _authorizeUpgrade(address newImplementation) internal onlyOwner override
{}
// mintEventに文字列の引数discriptionを追加
event MintEvent(address indexed to,uint256 indexed amount,string discription);
}
作成したコントラクトをコンパイルします。
npx hardhat compile
コンパイルが完了すると、artifactsフォルダ、cacheフォルダが生成されます。
コントラクトのテスト
今回はテストコードを用いて、Proxy Patternによるアップグレードの動作を確認します。
以下のテストコード(今回はNewTokenProxyTestとしています)を作成します。
const {loadFixture} = require("@nomicfoundation/hardhat-toolbox/network-helpers");
const { expect } = require("chai");
const { ethers,upgrades } = require("hardhat");
describe("Fixture", function () {
async function deployFaucetFixture() {
// アカウントを取得
const [owner,other] = await ethers.getSigners();
// NewTokenV1,NewTokenV2それぞれのコントラクトを取得
const NewTokenV1 = await ethers.getContractFactory("NewTokenV1");
const NewTokenV2 = await ethers.getContractFactory("NewTokenV2");
return { NewTokenV1,NewTokenV2,owner};
}
// アップグレード前の実装コントラクト(NewTokenV1)に関するテスト
describe("NewTokenV1", function () {
it("Should emit 'mintEvent' with 2 args", async function () {
const { NewTokenV1, owner } = await loadFixture(deployFaucetFixture);
// NewTokenV1をプロキシ経由でデプロイ
const newTokenV1Instance = await upgrades.deployProxy(NewTokenV1,[owner.address]);
// mint()実行時に、イベントMintEvent(引数2つ)が発行されることを確認
const mintValue = 100;
await expect(newTokenV1Instance.mint(owner.address,mintValue)).to.emit(newTokenV1Instance,"MintEvent").withArgs(owner.address,100) ;
})
})
// アップグレード後の実装コントラクト(NewTokenV2)に関するテスト
describe("NewTokenV2", function () {
it("upgrade newTokenV1 -> newTokenV2", async function () {
const {NewTokenV1, NewTokenV2,owner }= await loadFixture(deployFaucetFixture);
// NewTokenV1をプロキシ経由でデプロイ
const newTokenV1Instance = await upgrades.deployProxy(NewTokenV1,[owner.address]);
// logic contractをNewTokenV1 → NewTokenV2に更新
const newTokenV2Instance = await upgrades.upgradeProxy(await newTokenV1Instance.getAddress(),NewTokenV2)
const mintValue = 100;
// mint()実行時に、イベントMintEvent(引数3つ)が発行されることを確認
await expect(newTokenV2Instance.mint(owner.address,mintValue)).to.emit(newTokenV2Instance,"MintEvent").withArgs(owner.address,100,"mint finished") ;
})
})
});
テストを実行します。
npx hardhat test
Fixture
NewTokenV1
✔ Should emit 'mintEvent' with 2 args (2102ms)
NewTokenV2
✔ upgrade newTokenV1 -> newTokenV2 (148ms)
2 passing (2s)
テストが正常終了したことを確認できました。
これで、NewTokenV1 → NewTokenV2への移行によってMintEventの処理がしっかり変更されていることを確認できました。
以上で作業は終了です。
所感
Hardhatのようなフレームワークを使う上では、コントラクトのアップグレード自体はもとから機能として用意されているので、デプロイに使う関数をちょっと変えるだけ(deploy() → deployProxy())ですぐに実現できたのは非常にありがたいなと感じました。
とはいえ、その裏側で何が起きているかを知っておいて損はないので、良い経験になりました。
また、「アップグレード可能なコントラクトコードにはいろいろと縛りがある」ということを学べたのも大きかったです。変数の定義順を変えてはいけない(ストレージに格納されたデータとの整合性の問題)とかコンストラクタが使用できないとか、けっこう融通が利かないんだなと・・・
今回はイベントをひとつアップグレードしただけなのでそこまで意識していませんでしたが、もっと大規模なアップグレードをかける場合は、徹底的にチェックするべきですね。
参考