🧯

Upgrading smart contractsとは

2022/03/01に公開

OpenZeppelin Upgrades Pluginsを使用してデプロイされたスマートコントラクトは、アドレス、状態、バランスを維持したまま、そのコードを変更するためにアップグレードすることができます。これにより、プロジェクトに新しい機能を繰り返し追加したり、実稼働中に見つけたバグを修正したりすることができます。

このガイド全体を通して、次のことを学びます。

  • アップグレードが重要な理由
  • アップグレードプラグインを使用してボックスをアップグレードします
  • 内部でアップグレードがどのように機能するかを学ぶ
  • アップグレード可能なコントラクトを作成する方法

アップグレードの内容

イーサリアムのスマートコントラクトは、デフォルトでイミュータブル(不変)である。一度作成すると変更することができないため、参加者間で破れないコントラクトとして効果的に機能します。

しかし、シナリオによっては、修正できることが望ましい場合もあります。従来の2者間の契約を考えてみよう。2人が変更することに合意すれば、変更することができるだろう。イーサリアムでは、彼らが見つけたバグを修正したり(ハッカーに資金を盗まれることにつながるかもしれません!)、機能を追加したり、単に強制されるルールを変更するために、スマートコントラクトを変更したいと思うかもしれません。

アップグレードできないコントラクトのバグを修正するには、次のことを行う必要があります。

  1. コントラクトの新しいバージョンを展開します
  2. すべての状態を古いコントラクトから新しいコントラクトに手動で移行します(これはガス料金の点で非常に高額になる可能性があります!)
  3. 新しいコントラクトのアドレスを使用するために、古いコントラクトと相互作用したすべてのコントラクトを更新します
  4. すべてのユーザーに連絡し、新しい展開の使用を開始するように説得します(ユーザーの移行が遅いため、同時に使用されている両方のコントラクトを処理します)

このような混乱を避けるために、私たちは契約のアップグレードをプラグインに直接組み込んでいます。これにより、状態、バランス、アドレスを保持したまま、コントラクトコードを変更することができます。では、実際に見てみましょう。

アップグレードプラグインを使用したアップグレード

OpenZeppelin Upgrades Plugins の deployProxy を使って新しいコントラクトをデプロイすると、そのコントラクトインスタンスを後でアップグレードすることができるようになります。デフォルトでは、コントラクトを最初にデプロイしたアドレスだけが、アップグレードする権限を持っています。

deployProxyは、次のトランザクションを作成します。

  1. 実装コントラクト(私たちのBoxコントラクト)を展開します
  2. ProxyAdminコントラクト(プロキシの管理者)をデプロイします。
  3. プロキシコントラクトをデプロイし、初期化機能を実行します。

先ほどデプロイしたときと同じ設定で、アップグレード可能なバージョンのBoxコントラクトをデプロイして、その動作を確認してみましょう。

// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Box {
    uint256 private _value;

    // Emitted when the stored value changes
    event ValueChanged(uint256 value);

    // Stores a new value in the contract
    function store(uint256 value) public {
        _value = value;
        emit ValueChanged(value);
    }

    // Reads the last stored value
    function retrieve() public view returns (uint256) {
        return _value;
    }
}

まず、アップグレードプラグインをインストールする必要があります。
Hardhat Upgradesプラグインをインストールします。

npm install --save-dev @openzeppelin/hardhat-upgrades

次に、Hardhat が @openzeppelin/hardhat-upgrades プラグインを使用するように設定する必要があります。これを行うには、以下のように hardhat.config.js ファイルにプラグインを追加します。

// hardhat.config.js
...
require('@nomiclabs/hardhat-ethers');
require('@openzeppelin/hardhat-upgrades');
...
module.exports = {
...
};

Box のようなコントラクトをアップグレードするには、まずアップグレード可能なコントラクトとしてデプロイする必要があります。これは、これまで見てきたデプロイ手順とは異なるものです。Box のコントラクトを初期化するには、値 42 を指定して store を呼び出します。

Hardhat には現在ネイティブのデプロイメントシステムがなく、代わりにスクリプトを使用してコントラクトをデプロイしています。

deployProxy を使って、アップグレード可能な Box コントラクトをデプロイするスクリプトを作成します。このファイルは scripts/deploy_upgradeable_box.js という名前で保存します。

// scripts/deploy_upgradeable_box.js
const { ethers, upgrades } = require('hardhat');

async function main () {
  const Box = await ethers.getContractFactory('Box');
  console.log('Deploying Box...');
  const box = await upgrades.deployProxy(Box, [42], { initializer: 'store' });
  await box.deployed();
  console.log('Box deployed to:', box.address);
}

main();

その後、アップグレード可能なコントラクトを展開できます。
runコマンドを使用して、Boxコントラクトをdevelopmentネットワークにデプロイできます。

$ npx hardhat run --network localhost scripts/deploy_upgradeable_box.js
Deploying Box...
Box deployed to: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0

そして、Boxコントラクトと対話することで、初期化時に格納した値をretrieve(取得)することができる。
Hardhatコンソールを使用して、アップグレードされたBoxコントラクトを操作します。
Boxコントラクトをデプロイした時のプロキシコントラクトのアドレスを指定する必要があります。

$ npx hardhat console --network localhost
Welcome to Node.js v12.22.1.
Type ".help" for more information.
> const Box = await ethers.getContractFactory('Box');
undefined
> const box = await Box.attach('0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0');
undefined
> (await box.retrieve()).toString();
'42'

例として、新しい機能として、新しいバージョンのBoxに格納されているvalueをインクリメントする関数を追加したいとします。

// contracts/BoxV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract BoxV2 {
    // ... code from Box.sol

    // Increments the stored value by 1
    function increment() public {
        _value = _value + 1;
        emit ValueChanged(_value);
    }
}

Solidity ファイルを作成した後、先ほどデプロイしたインスタンスを upgradeProxy 関数でアップグレードします。
upgradeProxy は以下のトランザクションを作成します。

  1. 実装コントラクト(BoxV2コントラクト)をデプロイします
  2. ProxyAdminを呼び出して、新しい実装を使用するようにプロキシコントラクトを更新します。

upgradeProxy を使って、Box のコントラクトを BoxV2 を使うようにアップグレードするスクリプトを作成することにします。このファイルを scripts/upgrade_box.js という名前で保存します。Boxコントラクトをデプロイしたときのプロキシコントラクトのアドレスを指定する必要があります。

// scripts/upgrade_box.js
const { ethers, upgrades } = require('hardhat');

async function main () {
  const BoxV2 = await ethers.getContractFactory('BoxV2');
  console.log('Upgrading Box...');
  await upgrades.upgradeProxy('0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0', BoxV2);
  console.log('Box upgraded');
}

main();

その後、アップグレード可能なコントラクトを展開できます。
runコマンドを使用して、developmentネットワーク上のBoxコントラクトをアップグレードします。

$ npx hardhat run --network localhost scripts/upgrade_box.js
Compiling 1 file with 0.8.4
Compilation finished successfully
Upgrading Box...
Box upgraded

完了しました 私たちのBoxインスタンスは、その状態と以前と同じアドレスを維持したまま、コードの最新バージョンにアップグレードされました。新しいアドレスに新しいものをデプロイしたり、古いBoxから新しいBoxにvalue(値)を手動でコピーしたりする必要はありませんでした。

試しに、新しいincrement関数を呼び出して、その後のvalue(値)を確認してみましょう。
Boxコントラクトをデプロイした時のプロキシコントラクトのアドレスを指定する必要があります。

$ npx hardhat console --network localhost
Welcome to Node.js v12.22.1.
Type ".help" for more information.
> const BoxV2 = await ethers.getContractFactory('BoxV2');
undefined
> const box = await BoxV2.attach('0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0');
undefined
> await box.increment();
...
> (await box.retrieve()).toString();
'43'

それでおしまい!Boxvalue(値)とそのアドレスがアップグレード全体でどのように保持されているかに注目してください。また、このプロセスは、ローカルブロックチェーン、テストネット、またはメインネットワークのいずれで作業しているかに関係なく同じです。

OpenZeppelin Upgrades Pluginsがこれをどのように実現するかを見てみましょう。

アップグレードの仕組み

このセクションは他のセクションよりも理論が重くなります。興味がある場合は、スキップして後で戻ってください。

新しいアップグレード可能なコントラクトインスタンスを作成すると、OpenZeppelin UpgradesPluginsは実際に3つのコントラクトをデプロイします。

  1. 作成したコントラクト。これは、ロジックを含む実装コントラクトと呼ばれます。
  2. ProxyAdminがプロキシの管理者になります。
  3. 実際にやり取りするコントラクトである実装コントラクトのプロキシ。
    ここで、プロキシは、すべての呼び出しを実装コントラクトに委任するだけの単純なコントラクトです。 デリゲート呼び出しは通常の呼び出しと似ていますが、すべてのコードが呼び出し先ではなく呼び出し元のコンテキストで実行される点が異なります。 このため、実装コントラクトのコードを転送すると、実際にはプロキシの残高が転送され、コントラクトストレージへの読み取りまたは書き込みは、プロキシ自身のストレージから読み取りまたは書き込みになります。

これにより、コントラクトの状態とコードを分離できます。プロキシが状態を保持し、実装コントラクトがコードを提供します。 また、プロキシを別の実装コントラクトに委任するだけで、コードを変更することもできます。

次に、アップグレードには次の手順が含まれます。

  1. 新しい実装コントラクトを展開します。
  2. 実装アドレスを新しいアドレスに更新するトランザクションをプロキシに送信します。

スマートコントラクトのユーザーは常にプロキシと対話し、プロキシはアドレスを変更しません。 これにより、ユーザーに変更を要求することなく、アップグレードを展開したりバグを修正したりできます。ユーザーはいつもと同じアドレスを操作し続けるだけです。

コントラクトアップグレードの制限

スマートコントラクトはアップグレード可能にすることができますが、Solidity言語のいくつかの制限を回避する必要があります。これらは、契約の初期バージョンとアップグレードするバージョンの両方を作成するときに表示されます。

初期化

アップグレード可能なコントラクトにconstructorを含めることはできません。初期化コードの実行を支援するために、OpenZeppelin Contractsは、メソッドをinitializerとしてタグづけできるInitializable基本コントラクトを提供し、メソッドを1回だけ実行できるようにします。

// contracts/AdminBox.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

contract AdminBox is Initializable {
    uint256 private _value;
    address private _admin;

    // Emitted when the stored value changes
    event ValueChanged(uint256 value);

    function initialize(address admin) public initializer {
        _admin = admin;
    }

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

    // Stores a new value in the contract
    function store(uint256 value) public {
        require(msg.sender == _admin, "AdminBox: not admin");
        _value = value;
        emit ValueChanged(value);
    }

    // Reads the last stored value
    function retrieve() public view returns (uint256) {
        return _value;
    }
}

このコントラクトをデプロイする際には、initializer関数名の指定(initializeのデフォルトでない場合のみ)と、使用する管理者アドレスの指定が必要です。

// scripts/deploy_upgradeable_adminbox.js
const { ethers, upgrades } = require('hardhat');

async function main () {
  const AdminBox = await ethers.getContractFactory('AdminBox');
  console.log('Deploying AdminBox...');
  const adminBox = await upgrades.deployProxy(AdminBox, ['0xACa94ef8bD5ffEE41947b4585a84BdA5a3d3DA6E'], { initializer: 'initialize' });
  await adminBox.deployed();
  console.log('AdminBox deployed to:', adminBox.address);
}

main();

すべての実用的な目的で、initializerはconstructorとして機能します。 ただし、これは通常の関数であるため、すべての基本コントラクト(存在する場合)のinitializersを手動で呼び出す必要があることに注意してください。

initializerだけでなく、constructorも含まれていることにお気づきでしょうか。このconstructorは、実装コントラクトを初期化した状態で残しておくためのもので、これはある種の潜在的な攻撃に対する緩和策になります。

アップグレード可能なコントラクトを作成する際のこれとその他の注意事項の詳細については、アップグレード可能なコントラクトの作成ガイドをごらんください。

アップグレード

技術的な制約により、コントラクトを新しいバージョンにアップグレードする際、そのコントラクトのストレージレイアウトを変更することはできません。

つまり、コントラクト内ですでにステート変数を宣言している場合、それを削除したり、型を変更したり、その前に別の変数を宣言したりすることはできません。Boxの例では、valueの後にしか新しい状態変数を追加できないことを意味する。

// contracts/Box.sol
contract Box {
    uint256 private _value;

    // We can safely add a new variable after the ones we had declared
    // 宣言した変数の後に、新しい変数を安全に追加することができます
    address private _owner;

    // ...
}

幸い、この制限は状態変数にのみ影響します。必要に応じて、コントラクトの機能とイベントを変更できます。

この制限の詳細については、「コントラクトの変更」ガイドを参照してください。

テスト

アップグレード可能なコントラクトをテストするには、プロキシを介した相互作用をテストするためのより高いレベルのテストを作成するとともに、実装コントラクトの単体テストを作成する必要があります。デプロイするときと同じように、テストでdeployProxyを使用できます。

アップグレードする場合は、upgradeProxyを使用してアップグレードした後、プロキシを介した相互作用をテストするためのより高いレベルのテストを作成するとともに、新しい実装コントラクトの単体テストを作成し、アップグレード間で状態が維持されることを確認する必要があります。

次のステップ

スマートコントラクトをアップグレードする方法を知り、プロジェクトを繰り返し開発できるようになったので、いよいよテストネットと本番に挑みます! 万が一バグが発生しても、コントラクトを修正して変更するためのツールがあるので安心です。

Discussion