👐

OpenZeppelinのContracts Upgradeableを使ってコントラクトをアップグレード出来るようにする

2021/12/08に公開

Ethereumにデプロイするコントラクトは基本的にimmutableで変更できない。しかしながら当然バグの修正などが発生するので変更できた方が当たり前ながら便利。

だがコントラクトを作り直してアップグレードしようとすると、既存コントラクトから新しいコントラクトへの状態のマイグレートやすでに存在している利用者のアプリのコントラクトIDを差し替えてアップデートするように促したりなどかなり煩雑な作業が必要になる。バグ修正したいのにとてもじゃないが気軽にパッチなんて当てられない。

このあたりの処理を極力簡便化してくれるのがOpenZeppelinのContracts Upgradableである。
https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable

基本的な思想としてはコントラクトを 修正 ではなく 拡張 することで解決する方向性。

使い方

とりあえずhardhatを使っている場合の話。まずは必要なライブラリのインストール。

インストールと設定

yarn add @openzeppelin/contracts-upgradeable
yarn add -D @openzeppelin/hardhat-upgrades

hardhat.config.jsに下記を追加。

require("@openzeppelin/hardhat-upgrades");

コントラクトとデプロイ

次にまずバージョン1として、こういうコントラクトがあったとする。重要なのはInitializableを継承しているところとinitializeメソッドを定義しているところ。upgradeableの仕組み的にconstructをうまくハンドリングできないので関連する全てのコントラクトにinitializeメソッドが必要になる。

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.3;

import "hardhat/console.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract GreeterV1 is Initializable {
   string greetingV1;

   /// @custom:oz-upgrades-unsafe-allow constructor
   constructor() initializer {}

   function initialize() public initializer {
     greetingV1 = "Hello, V1 World!"
   }

   function helloV1() public view returns(string memory) {
     return greetingV1;
   }
}

そんで下記のようなscriptで上記のコントラクトをデプロイする。

const { ethers, upgrades } = require("hardhat");

async function main() {
  const Greeter = await ethers.getContractFactory("GreeterV1");
  const greeter = await upgrades.deployProxy(Greeter, [], {
    initializer: "initialize",
  });
  console.log("Deploying...: ", greeter.address);
  await greeter.deployed();
  console.log("Greeter deployed to:", greeter.address);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

実行が終わるとコントラクトIDが表示されるはず。それが今後使い続けるコントラクトIDになる。

アップグレードしたい機能のデプロイ

例えばgreetingV2という状態とhelloV2()というメソッドを追加したいという場合は下記のようなコントラクトになる。追加した状態とメソッドに関しては言わずもがな。ポイントはGreeterV1を継承しているところ。これによってGreeterV1で定義しているgreetingV1という状態にもアクセスできるようになっている。

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.3;

import "hardhat/console.sol";

contract GreeterV2 is GreeterV1 {
  bool initializedV2;
  string greetingV2;
  
  function initializeV2() public initializer {
    require(!initializedV2);
    initializedV2 = true;
    greetingV2 = "Hello, V2 World!"
  }
  
  function helloV2() public view returns(string memory) {
    require(initializedV2);
    return greetingV2;
  }
  
  function returnV1StateFromV2() public view returns(string memory) {
    return greetingV1;
  }
}

これをデプロイするには下記のようなscriptを使う。PROXY_CONTRACT_IDのところに先ほどデプロイした時に手に入れたコントラクトIDを記入すればOK。これでGreeterV2のコードがデプロイできる。

const { ethers, upgrades } = require("hardhat");
const PROXY_CONTRACT_ID = ""; // 最初にデプロイした時のコントラクトID。

async function main() {
  const Greeter = await ethers.getContractFactory("GreeterV2");
  const greeter = await upgrades.upgradeProxy(PROXY_CONTRACT_ID, Greeter);
  console.log("Deploying...: ", greeter.address);
  await greeter.deployed();
  console.log("Greeter deployed to:", greeter.address);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

フロントエンドから呼び出す時

最初にデプロイした時に生成されたコントラクトIDに対して、hardhatでデプロイした時にコンパイルされて生成されたABIファイルを使って呼び出しを行えば良い。この辺は普通のコントラクトの呼び出し方と変わりない。

注意

constructorを定義できない

upgradeableを使うとconstructorを定義できない。なのでInitializerを継承してinitializeメソッドを定義する。constructorで行っていた処理はここに記述する。ERC20やERC721のライブラリを使って継承している場合もそれら全てのコードでconstructorが呼ばれない。なのでERC20Upgradableなど同じくOpenZeppelinが作っているUpgradeable対応のライブラリを使う必要がある。詳しくは下記を読む。

状態の上書きなど

例えばV1のコントラクトでstring xという状態を定義していた時に、V2ではint xのような型は違うが同名の状態を定義してしまうと怒られるなど、状態名や状態を定義する順番に関してややこしい制約がある。とりあえず同じ状態名を避けることと状態は上から順に定義していくことを意識していれば大きな間違いはしなさそう。詳しくは下記を読んで確認しておくと良い。

https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#modifying-your-contracts

その他

Contract Upgradeableがどういう仕組みで動いているのかについては下記を読むとわかる。

https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies

ざっくりいうと前段に「Proxy専用のコントラクト」がいて、その後ろに「V1,V2...のコントラクト」を配置する。そしてProxyのみが状態を保持し、V1,V2...のコントラクトはロジックのみ担当する。こうすることで状態はProxyにあり普遍でありながらアップデートで新たにデプロイされたコントラクトも追加できるという感じ。コントラクト間でアドレスの衝突をどう避けるのかとか面倒な問題が結構あるけどその辺をどう解決してるのかは上のドキュメントに書いてある。

リンク

Discussion