スマートコントラクトは一度デプロイされると、変更することができません。したがって、コントラクトコードにbug
が発生したり、新しい機能を追加したりする場合でも、修正することはできず、次のことを行う必要があります:
- 新しいコントラクトを再デプロイする
- 古いコントラクトから新しいコントラクトにデータを手動で移行する
- 古いコントラクトと相互作用するすべてのコントラクトを更新する(新しいコントラクトのアドレスを使用するなど)
- コミュニティに新しいコントラクトアドレスを使用するよう通知し、説得する
コントラクトのアップグレードがこれほど面倒であるなら、統一された解決策が必要です。それにより、データを保持しながらコントラクトコードを変更することができます。
プロキシパターン#
プロキシパターンは、コントラクトデータとロジックを分離し、それぞれ異なるコントラクトに保存します:
- プロキシコントラクト:ユーザーと対話するスマートコントラクトで、データを保存しています。これはEIP1967 標準のプロキシコントラクトです。
- 実装コントラクト:プロキシコントラクトが代理するコントラクトで、機能とロジックを提供します。
プロキシコントラクトは、実装コントラクトのアドレスを状態変数として保存します。ユーザーは直接実装コントラクトに呼び出しを送信しません。代わりに、すべての呼び出しはプロキシコントラクトを経由し、プロキシを通じて実装コントラクトに呼び出しを委任し、実装コントラクトから受け取ったデータを呼び出し元に返すか、エラーをロールバックします。
プロキシコントラクトは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
メソッドを呼び出す必要がある場合、プロキシパターンではそのコントラクトと対話するのではなく、プロキシコントラクトを介して呼び出します。つまり、RoboProxy
のsetCount
メソッドを呼び出しますが、RoboProxy
にはsetCount
メソッドはありません。代わりに、そのfallback
関数を実行します(存在しないコントラクトメソッドを呼び出すとfallback
関数がトリガーされます)。fallback
関数内部では_delegate
メソッドが実行されます。
_delegate
メソッドは実際のプロキシメソッドであり、その内部でdelegatecall
を使用してRobo
コントラクトのsetCount
メソッドを実行します。
delegatecall
の特性に基づき、Robo
コントラクトが変数count
を定義していても、その値はRobo
コントラクトによって保存されるのではなく、RoboProxy
コントラクトによって保存されます。
ここまで来ると、RoboProxy
は変数count
を定義していないのに、どのように保存されているのか混乱するかもしれません。これは実際にはEVM
のストレージレイアウトに関係しています。
実際、EVM
内で実行される各スマートコントラクトの状態変数の値は、チェーン上に永続的に保存されており、これらの値はストレージスロットに保存されています。各コントラクトには個のストレージスロットがあり、各ストレージスロットのサイズは 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 |
| |... |
変数var1
とimplementation
はどちらもコントラクトの最初の変数であるため、値は最初のストレージスロット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
からメモリ位置t
にcalldatasize()
の長さのデータをコピーしますdelegatecall(g, a, in ,insize, out, outsize)
:アドレスa
を呼び出し、入力データはメモリ位置[in...in+insize]
のデータで、結果はメモリ[out...out+outsize]
に出力されますreturndatacopy(t, f, s)
:出力データを位置f
からメモリ位置t
にreturndatasize()
の長さでコピーします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 を使用してアップグレード可能なスマートコントラクトを作成する#
hardhat
でOpenZeppelin
を使用してアップグレード可能なスマートコントラクトを作成する際には、大幅に作業量を削減できます。プロキシコントラクトがどのように実装されるか、初期化メソッドをどのように実行するかを気にする必要はなく、実装コントラクトのロジックを書くことに集中するだけです。
初期化#
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
})