moon

moon

Build for builders on blockchain
github
twitter

アップグレード可能なスマートコントラクトの作成

スマートコントラクトは一度デプロイされると、変更することができません。したがって、コントラクトコードにbugが発生したり、新しい機能を追加したりする場合でも、修正することはできず、次のことを行う必要があります:

  • 新しいコントラクトを再デプロイする
  • 古いコントラクトから新しいコントラクトにデータを手動で移行する
  • 古いコントラクトと相互作用するすべてのコントラクトを更新する(新しいコントラクトのアドレスを使用するなど)
  • コミュニティに新しいコントラクトアドレスを使用するよう通知し、説得する

コントラクトのアップグレードがこれほど面倒であるなら、統一された解決策が必要です。それにより、データを保持しながらコントラクトコードを変更することができます。

プロキシパターン#

プロキシパターンは、コントラクトデータとロジックを分離し、それぞれ異なるコントラクトに保存します:

  • プロキシコントラクト:ユーザーと対話するスマートコントラクトで、データを保存しています。これはEIP1967 標準のプロキシコントラクトです。
  • 実装コントラクト:プロキシコントラクトが代理するコントラクトで、機能とロジックを提供します。

プロキシコントラクトは、実装コントラクトのアドレスを状態変数として保存します。ユーザーは直接実装コントラクトに呼び出しを送信しません。代わりに、すべての呼び出しはプロキシコントラクトを経由し、プロキシを通じて実装コントラクトに呼び出しを委任し、実装コントラクトから受け取ったデータを呼び出し元に返すか、エラーをロールバックします。

proxy-upgrade.png

プロキシコントラクトはdelegatecall関数を使用して実装コントラクトを呼び出します。delegatecallの特性に基づき、実装コントラクトの状態変数はプロキシコントラクトによって保存されます。

以下のようになります:

contract Robo {
  uint public count;

  function setCount(uint _count) public {
    count = _count;
  }
}

contract RoboProxy {
  function _delegate(address implementation) internal virtual {
    // プロキシメソッド: delegatecallで実装コントラクトのメソッドを実行
  }

  function _implementation() internal view virtual returns (address) {
    // 実装コントラクトRoboのアドレスを返す
  }

  fallback() external {
    _delegate(_implementation());
  }
}

RoboコントラクトのsetCountメソッドを呼び出す必要がある場合、プロキシパターンではそのコントラクトと対話するのではなく、プロキシコントラクトを介して呼び出します。つまり、RoboProxysetCountメソッドを呼び出しますが、RoboProxyにはsetCountメソッドはありません。代わりに、そのfallback関数を実行します(存在しないコントラクトメソッドを呼び出すとfallback関数がトリガーされます)。fallback関数内部では_delegateメソッドが実行されます。

_delegateメソッドは実際のプロキシメソッドであり、その内部でdelegatecallを使用してRoboコントラクトのsetCountメソッドを実行します。

delegatecallの特性に基づき、Roboコントラクトが変数countを定義していても、その値はRoboコントラクトによって保存されるのではなく、RoboProxyコントラクトによって保存されます。

ここまで来ると、RoboProxyは変数countを定義していないのに、どのように保存されているのか混乱するかもしれません。これは実際にはEVMのストレージレイアウトに関係しています。

実際、EVM内で実行される各スマートコントラクトの状態変数の値は、チェーン上に永続的に保存されており、これらの値はストレージスロットに保存されています。各コントラクトには22562^{256}個のストレージスロットがあり、各ストレージスロットのサイズは 32 バイトです。しかし、この大きなストレージスロットはすべて存在するわけではなく、使用されるときにのみ実際にスペースを占有します。

----------------------------------
|               0                |     # slot 0
----------------------------------
|               1                |     # slot 1
----------------------------------
|               2                |     # slot 2
----------------------------------
|              ...               |     # ...
----------------------------------
|              ...               |     # 各スロット32バイト
----------------------------------
|              ...               |     # ...
----------------------------------
|            2^256-1             |     # slot 2^256-1

したがって、プロキシメソッドを実行する際には、count変数の値がプロキシコントラクトのストレージスロットに保存されます。

この特別なストレージ構造のために、潜在的なストレージ衝突の問題が発生しやすくなります。

ストレージ衝突#

プロキシコントラクトと実装コントラクト間のストレージ衝突#

|Proxy                   |Implementation |
|------------------------|---------------|
|address implementation  |address var1   | <- ストレージ衝突
|                        |mapping var2   |
|                        |uint256 var3   |
|                        |...            |

変数var1implementationはどちらもコントラクトの最初の変数であるため、値は最初のストレージスロットslot 0に保存されます。また、同じタイプを持つため、同じストレージスペースを占有します。プロキシメソッドdelegatecallを介してvar1に値を設定すると、実際にはその値がプロキシコントラクトのストレージスペースに書き込まれ、書き込まれる位置はちょうどimplementation変数が占めるスペースです。したがって、implementation変数の値は設定されたvar1の値になります。

プロキシコントラクトと実装コントラクト間のストレージ衝突の問題を解決するために、ランダムなストレージスロットを使用します。つまり、巨大なストレージスペースの中からランダムにストレージスロットを選択してimplementation変数を保存します。

|Proxy                   |Implementation |
|------------------------|---------------|
|    ..                  |address var1   |
|    ..                  |mapping var2   |
|    ..                  |uint256 var3   |
|    ..                  |    ..         |
|    ..                  |    ..         |
|address implementation  |    ..         | <-  ランダムスロット

EIP-1967に基づき、実装コントラクトアドレスを保存するストレージスロットの位置は次のようになります:

bytes32 private constant implementationPosition = bytes32(uint256(
  keccak256('eip1967.proxy.implementation')) - 1
));
// 値は0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

実装アドレスにアクセスまたは変更する必要があるたびに、このストレージスロットを読み書きします。

異なるバージョンの実装コントラクト間のストレージ衝突#

新しい実装コントラクトにアップグレードする際に、新しい状態変数が実装コントラクトに追加される場合、それはストレージレイアウトに追加されるべきであり、元のストレージレイアウトに無作為に挿入されるべきではありません。

|ImplementationV1 |ImplementationV2|
|-----------------|----------------|
|address foo      |address baz     | <- ストレージ衝突
|mapping bar      |address foo     | 
|                 |mapping bar     |
|                 |...             |

bazの挿入により、元のfooの値が乱れてしまいます。

正しい方法は、元の変数の位置を変更せずに変数を追加することです。

|ImplementationV1 |ImplementationV2|
|-----------------|----------------|
|address foo      |address foo     | 
|mapping bar      |mapping bar     | 
|                 |address baz     | <- 追加
|                 |...             |

コントラクトの初期化#

プロキシコントラクトはすべてのデータを保存しているため、データ初期化のコードも実行する必要があります。しかし、初期化コードは通常、実装コントラクトのコンストラクタに書かれており、コンストラクタはデプロイ時に一度だけ実行されるため、プロキシコントラクトは初期化コードを実行できず、データが初期化されません。

OpenZeppelinの解決策は、初期化コードを実装コントラクトのコンストラクタからinitialize関数に移動させることです。

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

contract RoboToken is Initializable {
    // `initializer`修飾子はこのメソッドが一度だけ実行されることを保証します
    function initialize(
        address arg1,
        uint256 arg2,
        bytes memory arg3
    ) public payable initializer {
        // 初期化コード...
    }
}

その後、プロキシコントラクトがinitialize関数を実行します。

プロキシメソッドの実装#

プロキシコントラクト内で実装コントラクトのメソッドを実行するのは、delegatecallを使用して行われます。

_implementation.delegatecall(abi.encodeWithSignature("setValue()", 10));

または

_implementation.delegatecall(abi.encodeWithSelector(Robo.setValue.selector, 10));

しかし、これらの 2 つの方法は、実装コントラクトの具体的なメソッド名を知っている必要があり、汎用性がありません。

そこで、インラインアセンブリという呼び出し方法が登場します。実装は次のようになります。

function _delegate(address implementation) internal virtual {
  assembly {
    // calldataをメモリにコピー
    // calldataの最初の4バイトは関数セレクタで、その後に関数パラメータが続きます。関数セレクタを通じて呼び出す関数をマッチさせることができます
    calldatacopy(0, 0, calldatasize())

    // delegatecallでimplementationのメソッドを呼び出す
    let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

    // 戻りデータをコピー
    returndatacopy(0, 0, returndatasize())
    
    // 0を返すとエラーを示す
    switch result
    case 0 {
      revert(0, returndatasize())
    }
    default {
      return(0, returndatasize())
    }
  }
}

プロキシコントラクトの実装では、いくつかのインラインアセンブリ関数が使用されています。

  • calldatacopy(t, f, s)msg.dataを位置fからメモリ位置tcalldatasize()の長さのデータをコピーします
  • delegatecall(g, a, in ,insize, out, outsize):アドレスaを呼び出し、入力データはメモリ位置[in...in+insize]のデータで、結果はメモリ[out...out+outsize]に出力されます
  • returndatacopy(t, f, s):出力データを位置fからメモリ位置treturndatasize()の長さでコピーします
  • revert(p, s):実行を終了し、ロールバックし、メモリ位置[p...p+s]のデータを返します
  • return(p, s):正常に結果を返し、メモリ位置[p...p+s]のデータを返します

透明プロキシと UUPS プロキシ#

透明プロキシ(Transparent)と UUPS プロキシは、プロキシパターンの異なる実装方法であり、本質的には大きな違いはありません。両者は同じアップグレードインターフェースを使用し、実装コントラクトに委任します。

透明プロキシ#

透明プロキシパターンでは(EIP-1967)、アップグレードはプロキシコントラクトによって処理されます。本質的には、プロキシコントラクト内の実装コントラクトアドレス変数を新しい実装コントラクトアドレスに変更することです。たとえば、upgradeTo(address newImpl)のような関数を呼び出して新しい実装コントラクトにアップグレードします。しかし、このロジックがプロキシコントラクト内にあるため、この種のプロキシをデプロイするコストは非常に高くなります。

contract Robo {
  uint public value;
  function setValue(uint _value) public {}
}

contract RoboProxy {
  function _delegate(address implementation) internal virtual {}

  function getImplementationAddress() public view returns (address) {}
  fallback() external {}

  upgradeTo(address newImpl) external {
     // implementationがあるストレージスロットを取得して値を設定
  }
}

UUPS プロキシ#

UUPS パターンはEIP1822で初めて提案されました。透明モードとは異なり、UUPS ではアップグレードロジックは実装コントラクト自体によって処理されます。実装コントラクトにはアップグレードロジックのメソッドと通常のビジネスロジックが含まれます。アップグレードロジックを含む汎用標準インターフェースを継承することで、任意の実装コントラクトを UUPS 標準に適合させることができます。たとえば、OpenZeppelin のUUPSUpgradeableインターフェースを継承します。

contract Robo is UUPSUpgradeable {
  uint private value;

  function setValue(uint _value) public {}

  upgradeTo(address newImpl) external {
  	// プロキシコントラクト内でdelegatecallを使用してupgradeToメソッドを呼び出し、newImplの値をプロキシコントラクトに保存します
  }
}

contract RoboProxy {
  function _delegate(address implementation) internal virtual {  }
  function getImplementationAddress() public view returns (address) {  }
  fallback() external {  }
}

OpenZeppelin を使用してアップグレード可能なスマートコントラクトを作成する#

hardhatOpenZeppelinを使用してアップグレード可能なスマートコントラクトを作成する際には、大幅に作業量を削減できます。プロキシコントラクトがどのように実装されるか、初期化メソッドをどのように実行するかを気にする必要はなく、実装コントラクトのロジックを書くことに集中するだけです。

初期化#

hardhatでプロジェクトを初期化した後、OpenZeppelin 関連のパッケージをインストールします。

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

hardhatの設定ファイルを構成します。

import { HardhatUserConfig } from 'hardhat/config'
import '@nomicfoundation/hardhat-toolbox'
import '@openzeppelin/hardhat-upgrades'

const config: HardhatUserConfig = {
  solidity: '0.8.15'
}

export default config

コントラクトの作成#

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

contract Robo is Initializable {
  uint public value;
  
  // 初期化メソッド
  function initialize(uint _value) public initializer {
    value = _value;
  }

  function increaseValue() external {
    ++value;
  }
}

デプロイスクリプト#

@openzeppelin/hardhat-upgradesライブラリを使用してコントラクトをデプロイします。

import { ethers, upgrades } from 'hardhat'

// yarn hardhat run scripts/deploy_robo.ts --network localhost
async function main () {
  const Robo = await ethers.getContractFactory('Robo')
  
  // コントラクトをデプロイし、初期化メソッドを呼び出します
  const robo = await upgrades.deployProxy(Robo, [10], {
    initializer: 'initialize'
  })
  
  // プロキシコントラクトアドレス
  const proxyAddress = robo.address
  // 実装コントラクトアドレス
  const implementationAddress = await upgrades.erc1967.getImplementationAddress(robo.address)
  // proxyAdminコントラクトアドレス
  const adminAddress = await upgrades.erc1967.getAdminAddress(robo.address)

  console.log(`proxyAddress: ${proxyAddress}`)
  console.log(`implementationAddress: ${implementationAddress}`)
  console.log(`adminAddress: ${adminAddress}`)
}

main().catch((error) => {
  console.error(error)
  process.exitCode = 1
})

ローカルターミナルで実行します。

$ yarn hardhat node

別のターミナルで次のコマンドを実行して、ローカルネットワークにコントラクトをデプロイします。

$ yarn hardhat run scripts/deploy.ts --network localhost

実際にデプロイされるコントラクトは 3 つです。

  • プロキシコントラクト
  • 実装コントラクト
  • ProxyAdminコントラクト

ProxyAdminコントラクトはプロキシコントラクトを管理するために使用され、コントラクトのアップグレードや所有権の移転を含みます。

コントラクトをアップグレードする手順は次のとおりです。

  • 新しい実装コントラクトをデプロイする
  • ProxyAdminコントラクト内のアップグレード関連のメソッドを呼び出し、新しい実装コントラクトアドレスを設定します。

以上のようにupgrades.deployProxyでデプロイされたコントラクトは、デフォルトでは透明プロキシモードを使用しています。UUPS プロキシモードを使用したい場合は、明示的に指定する必要があります。

// コントラクトをデプロイし、初期化メソッドを呼び出します
const robo = await upgrades.deployProxy(Robo, [10], {
  initializer: 'initialize',
  kind: 'uups'
})

同時にコントラクトもUUPSUpgradeableを継承する必要があります。

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

contract Robo is Initializable, UUPSUpgradeable {
  uint public value;

  function initialize(uint _value) public initializer {
    value = _value;
  }

  function increaseValue() external {
    ++value;
  }
  
  function _authorizeUpgrade(address) internal override {}
}

UUPS プロキシモードのコントラクトをコンパイルしてデプロイすると、実際には 2 つのコントラクトのみがデプロイされます。

  • プロキシコントラクト
  • 実装コントラクト

この時のアップグレードコントラクトの手順は次のとおりです。

  • 新しい実装コントラクトをデプロイする
  • プロキシコントラクト内のupgradeToメソッドを呼び出し、新しい実装コントラクトアドレスを設定します。

アップグレードコントラクト#

新しいバージョンのコントラクトを実装した際、最初のデプロイ時のようにdeployProxyを呼び出す必要はなく、upgradeProxyを呼び出すだけで済みます。

async function main () {
  // 最初のデプロイ時のプロキシコントラクトのアドレス
  const proxyAddress = '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0'

  const RoboV2 = await ethers.getContractFactory('RoboV2')
  // upgradeProxyでコントラクトをアップグレードします
  await upgrades.upgradeProxy(proxyAddress, RoboV2)
}

main().catch((error) => {
  console.error(error)
  process.exitCode = 1
})
読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。