はじめに
Ethereumにおいてスマートコントラクトは不変です。
しかし、バグがある場合やビジネス上要件を追加する必要がある場合など、スマートコントラクトを更新しなければならないケースは多々存在します。
そのため、スマートコントラクトを見かけ上Upgradeable(更新可能)にみえるようにするために、現在はProxyパターンが広く用いられています。
今回は、そのProxyパターンの種類や実装方法を紹介します。
Proxyパターンとは
メインのコントラクトとユーザーとの間にProxyコントラクトが存在する構成のパターンです。
ユーザーはProxyコントラクトにアクセスし、Proxyコントラクトがロジックを含むメインの実装コントラクトに対してトランザクションを転送します。
この実装パターンでは、プロキシコントラクトは変更されず常に同じアドレスとなりますが、Proxyコントラクトが参照するロジックコントラクトを別の実装コントラクトに変更することができるため、全体としてアップグレード可能なコントラクトと見做せるようになります。
Proxyパターンの種類
Openzeppelinでは、以下の3つのProxyパターンがサポートされています。
- Transparent Proxy
- UUPS Proxy
- Beacon Proxy
Transparent ProxyパターンとUUPS Proxyパターンは単一のコントラクトをUpgradeableにするパターンで、Beacon Proxyパターンは単一の実装で複数のコントラクトをUpgradableにするパターンです。
それぞれの詳細は以下の通りです。
1. Transparent Proxyパターン
Transparent Proxyパターンでは、Proxyコントラクトに対する全ての呼び出しを実装コントラクトに委譲し実行します(delegate call)。
それにより処理の呼び出し元とデータの格納先はProxyコントラクト、実際に実行されるロジックは実装コントラクトのメソッドということになります。
処理の委譲先である実装コントラクトのアドレスを変更するには、ProxyコントラクトにupgradeTo
メソッドを用意する必要があるのですが、委譲先のメソッドと委譲元であるProxyコントラクトのupgradeTo
メソッドの識別子が重複して不正利用されてしまう可能性があるため、Transparent Proxyパターンでは以下のように呼び出し先を分けています。
- Adminがコントラクトを呼び出す場合
- Proxyコントラクト内のメソッドを呼び出す。
- Admin以外のユーザーがコントラクトを呼び出す場合
- 委譲先のメソッドを呼び出す。
2. UUPS Proxyパターン
基本的にはTransparent Proxyパターンと同じ構成になっています。
唯一の相違点は、コントラクトをアップグレードするためのupgradeTo
メソッドの実装場所です。
Transparent Proxyパターンでは、upgradeTo
メソッドはProxyコントラクトに含まれますが、UUPS Proxyパターンでは、実装コントラクトに含まれます。
そのためUUPS Proxyパターンでは、更新時にupgradeTo
メソッドを削除してUpgradeableではないコントラクトにすることができます。
ただし、upgradeTo
メソッドの呼び出しの権限制御を正しく行わないと誰でもコントラクトを更新できてしまうので、注意が必要です。
3. Beacon Proxyパターン
ProxiesはTransparent ProxyパターンとUUPS Proxyパターンとは異なり、単一の実装で複数のコントラクトをUpgradableにするパターンです。
このパターンでは、UniswapのPoolのように同じ実装を持ったコントラクトが複数デプロイされるようなケースに使われます。
コントラクトがデプロイされるたびにProxyコントラクトが作成されますが、全て同じBeacon Proxyコントラクトを参照するため、Proxyコントラクトが参照する実装コントラクトを変更することで、複数のProxyコントラクトの実装を1回のトランザクションで更新することができます。
なおBeacon Proxyパターンのパターンでも、upgradeTo
メソッドをBeacon Proxyに実装すればTransparent Proxyパターン、実装コントラクトに実装すればUUPS Proxyパターンと同様の特徴を持つことになります。
Proxyパターンの実装方法
ライブラリを使って自動的にUpgradeableなコントラクトにするパターンと自前実装するパターンがあるので、それぞれご紹介します。
それぞれOpenzeppelinのライブラリを使ってコントラクトを実装します。
1. ライブラリで自動的にUpgradeableにする方法
OpenzeppelinはTruffleやHardhatを使ってコントラクトをUpgradeableにするライブラリを提供しています。
比較的少ない作業だけでUpgradeableなコントラクトを実装・管理できますが、この方法では必ずTransparent Proxyパターンになります。
実装方法の公式ドキュメントはこちら。
ここでは、Hardhatによる実装方法を簡単に説明します。
1) セットアップ
必要なライブラリをインストールします。
$ npm install --save-dev @openzeppelin/hardhat-upgrades
$ npm install --save-dev @nomiclabs/hardhat-ethers ethers
合わせてhardhat.config.js
を更新して下記モジュールを登録します。
require('@openzeppelin/hardhat-upgrades');
2) コントラクトの実装
実装コントラクトの実装には、通常のコントラクトの実装にはないいくつかの制限事項があります。
制限事項1: constructorは使用できない
Proxyコントラクはデプロイ済みの実装コントラクトをdelegatecallで呼び出しているだけなので、constructorの処理を呼び出すことができません。そのため、constructorに代わって別途初期化用のファンクションを用意する必要があります。
Openzeppelinでは初期化用にInitializable.solというコントラクトが用意されており、これを使うことで初回の初期化時にしか呼べないようにする制御などが簡単に実装できます。
制限事項2: function外で変数に値をセットできない
上記1と同じ理由ですが、変数の初期値はデプロイ時に設定されるので、代わりに初期化用のファンクション内で設定する必要があります。
制限事項3: 変数の宣言順や型を変更してはならない
変更前と後のコントラクトは別のコントラクトなので、それぞれでストレージの格納先を管理しています。
しかしどちらもdelegatecallにより同じProxyコントラクトをストレージレイヤーとして使用しているため、ストレージの衝突が起きる可能性があります。
そのため、ストレージの格納位置を変えないためにも変数の宣言順や型を変更しないようにする必要があります。
これらの制限事項を守ると、下記のように実装できます。
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract MyContract is Initializable {
uint256 public x;
function initialize(uint256 _x) public initializer {
x = _x;
}
}
3) デプロイ
Hardhat scriptを用いて作られたデプロイスクリプトにてupgrades.deployProxy
ファンクションを呼び出すことで、対象のコントラクトをUpgradeableにします。
const { ethers, upgrades } = require("hardhat");
async function main() {
const MyContract = await ethers.getContractFactory("MyContract");
const myContract = await upgrades.deployProxy(MyContract, [10]);
await myContract.deployed();
console.log("MyContract deployed to:", myContract.address);
}
main();
4) 再デプロイ(Upgrade)
再デプロイでは新たにデプロイスリプトを用意します。
使用するメソッドはupgrades.upgradeProxy
です。
upgrades.upgradeProxy
の引数には、デプロイ時にログ出力したMyContractのコントラクトアドレスを設定する必要があります。
const { ethers, upgrades } = require("hardhat");
async function main() {
const MyContract = await ethers.getContractFactory("MyContract");
await upgrades.upgradeProxy(<my contract address>, MyContract);
console.log("MyContract upgraded");
}
main();
2. 自前実装を行う方法
ライブラリを使ったパターンではライブラリが自動でProxyコントラクトのデプロイと実装コントラクトとの紐付けをやってくれていましたが、今回の方法では自分自身でそれらの作業を行います。
この方法では、実装次第でTransparent ProxyパターンとUUPS Proxyパターンのどちらのパターンでも実装できますが、ここではUUPS Proxyパターンで実装をします。
1) Proxyコントラクトを実装する
ERC1967Proxy.sol
をProxyコントラクトとしてそのまま使用します。
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
2) 実装コントラクトを実装する
実装コントラクトでは、UUPSUpgradeable.sol
を継承します。
upgradeTo
メソッドの権限制御を行うため、_authorizeUpgrade
をオーバーライドする必要があります。
ここではOwnable.sol
を使ってコントラクトオーナーのみが呼び出せるようにします。
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract MyContract is UUPSUpgradeable, Initializable, Ownable {
uint256 public x;
function initialize(uint256 _x) public initializer {
x = _x;
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
3) デプロイスクリプトを作成する
Hardhatのスクリプトを使ってデプロイします。
実行する処理の流れは以下の通りです。
- MyContractをデプロイする。
- ERC1967Proxyをデプロイする。
- 引数にはMyContractのアドレスと、デプロイ実行時にMyContractのinitializeを呼ぶためにエンコードした情報を渡す。
- ERC1967Proxyのコントラクトアドレスで、MyContractのインスタンスを作成し、コントラクトを呼び出す。
実際の実装がこちら。
const hre = require("hardhat");
async function main() {
const MyContract = await hre.ethers.getContractFactory("MyContract");
const myContract = await MyContract.deploy();
await myContract.deployed();
console.log("MyContract deployed to:", myContract.address);
const ERC1967Proxy = await hre.ethers.getContractFactory("ERC1967Proxy");
const data = MyContract.interface.encodeFunctionData('initialize',[10]);
const erc1967Proxy = await ERC1967Proxy.deploy(myContract.address, data);
await erc1967Proxy.deployed();
console.log("ERC1967Proxy deployed to:", erc1967Proxy.address);
const myContractProxy = await ethers.getContractAt(
'MyContract',
erc1967Proxy.address,
);
const x = await myContractProxy.x();
console.log('x is', x.toString());
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
これを実行すると下記のようなログが出力され、MyContractが初期化されていることと、MyContractのファンクションがProxy経由で呼び出せていることが分かります。
$ npx hardhat run --network hardhat scripts/deploy.js
MyContract deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
ERC1967Proxy deployed to: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
x is 10
まとめ
現在Openzeppelinが提供する自動的にコントラクトをUpgradeableにするライブラリではTransparent Proxyパターンのみをサポートしていますが、Openzeppelin自身はUUPS Proxyパターンを使用することを推奨しています。
理由としては、
- Transparent ProxyパターンはProxyコントラクトに更新処理とAdmin管理の処理が存在するが、UUPS ProxyパターンにはないためProxyコントラクトが軽量で複製しやすい
- UUPS Proxyパターンでは更新処理を実装コントラクトに含まれることで将来Upgradeableではないコントラクトに更新することができるため、より汎用性のあるパターンであると言える
という2点があります。
ただし、Transparent Proxyパターンには更新処理の権限制御が不要で簡単に導入できるというメリットがあるため、どちらのパターンも用途に応じて使い分けることができそうです。