Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

Ethereumにおけるアップグレード可能なコントラクトの開発

こんにちは、ブロックチェーンチームの id:charines です。 今回はアップグレード可能なスマートコントラクトの開発事例について紹介します。 コントラクト開発者のみなさんの参考になればと思います。

アップグレード機能の必要性

ブロックチェーン上に展開されたコントラクトはオフチェーンのアプリケーションと異なり、通常は後から実装を修正したり機能を追加することはできません。しかしアップグレード可能なコントラクトとして設計することで、変更の余地を持たせることが可能になります。

例えば弊チームでは NFT をウェブコンソールから生成できる、「ユニキスガレージ」というサービスを開発・運用しています。 以前このブログでも紹介した ERC-2981 への対応 では、このサービスからデプロイされるコントラクトに、NFT が他のマーケットプレイスで再販売されたときロイヤリティの情報をマーケットプレイスへ提供するという機能を追加しました。 しかしこの機能追加以前にコントラクトをデプロイしたクライアントは、この機能の恩恵を受けることができません。 このようなケースでもアップグレード機能を実装していれば、今後デプロイされるコントラクトが常に最新の機能を利用できるようになります。

アップグレード可能なコントラクトの仕組み

アップグレード可能なコントラクトはプロキシと呼ばれる仕組みによって実装され、これは機能実装にあたるロジックコントラクトと、ロジックコントラクトへの参照やストレージを持つプロキシコントラクトの組によって構成されます。 プロキシコントラクトはロジックコントラクトの関数を delegatecall することで自身がその関数を実装しているかのように振る舞うため、参照先のロジックコントラクトを変更することでアップデートが可能になります。

ただしアップグレード可能であることはその性質上、コントラクトの中央集権性を高めてしまうため慎重に行う必要もあります。例えば ERC-721 コントラクトの実装において、NFT の移転に関する関数をアップグレードすれば、NFT の所有権を操作することも可能になってしまいます。 弊チームではプロダクトの性質を考慮した上で、それでもユーザに利便性を提供することに価値があると考え今回の実装を行うことにしました。

今回行った実装

実は弊チームではコントラクトをデプロイする際のガス代を節約するために、アップグレード可能ではなかったものの以前からプロキシの仕組みを使用していました。 このコントラクトは古いバーションの OpenZeppelin で実装されていたため、今回もチームで利用実績のある OpenZeppelin をベースにしつつ、現行のバージョンで書き直す方針としました。

コントラクト実装

OpenZeppelin はアップグレード可能な ERC-721 コントラクトをERC721Upgradeableとして公開しています。さらにOpenZeppelin Contracts Wizardというウェブアプリケーションが提供されており、アップグレード機能を含め追加したい機能を選択していくだけで基本的な ERC-721 コントラクトを作れるため、これでベースを作っていきます。

ここに独自機能を追加していくのですが、ベース部分は今後の機能追加などを行った際にも書き直さずに使い回したいので、今回は Abstract Contracts として実装することとしました。 以下は OpenZeppelin Contracts Wizard で生成された実装を Abstract Contracts 化するにあたっての主要な差分です。

- contract BaseERC721V1 is Initializable, ERC721Upgradeable, ERC721PausableUpgradeable, AccessControlUpgradeable, ERC721BurnableUpgradeable, UUPSUpgradeable {
+ abstract contract BaseERC721V1 is Initializable, ERC721Upgradeable, ERC721PausableUpgradeable, AccessControlUpgradeable, ERC721BurnableUpgradeable, UUPSUpgradeable {
      bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
      bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
      bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE");

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

-     function initialize(address defaultAdmin, address pauser, address minter, address upgrader)
-         initializer public
+     function __BaseERC721V1(address defaultAdmin, address pauser, address minter, address upgrader)
+         internal onlyInitializing
      {
          ...

また同様に独自機能についても、独立した各機能をアップグレード後も再利用できるように Abstract Contracts として実装し、それらを継承したロジックコントラクトを最終的にデプロイする構造にしました。 ディレクトリ構成は以下のようになります。

contracts
├── tokens
│   └── BaseERC721V1.sol
├── features
│   ├── FeatureA.sol
│   └── FeatureB.sol
└── logics
    └── ERC721LogicV1.sol

デプロイするロジックコントラクトは logics/ERC721LogicV1.sol で、 tokens/features/ の各機能を継承しています。

プロキシをデプロイする仕組みの実装

OpenZeppelin はプロキシコントラクトを安全にデプロイするために Hardhat や Truffle 向けのプラグインを使用することを推奨しています。しかし弊チームのプロダクトが SaaS である都合上 HTTP サーバからのデプロイが必要であり、開発環境として構成されている Hardhat などとの相性はあまり良くありません。 そこで今回はプラグインを利用せずに、 @openzeppelin/upgrades-core で提供されているバイトコードをそのまま使用してプロキシをデプロイする方針にしました。これは OpenZeppelin が提供するプラグインの内部で利用されているのと同じものです。

一方でプラグインにはデプロイ時に実装が安全にアップグレードできることを検証したり、アップグレード時に以前の実装と互換性があることを検証するなどの機能があり、プラグインを全く利用しない場合これらの恩恵を受けられません。そこでコントラクト開発のリポジトリのテストでのみプラグインを使用したデプロイやアップグレードのテストを行うことで、これらの安全性を担保できるようにしました。

まとめ

アップグレード可能なコントラクトの仕組みや設計、デプロイ方法など開発の一連の流れを紹介しました。

コントラクトのアップグレードは中央集権的になってしまうなどの側面もあるので良く考えて導入する必要がある技術ではありますが、新しい機能を後から追加できるというのは利便性の面で非常に大きなメリットです。 ぜひコントラクト開発を行う際は検討してみて下さい。