はじめに(Introduction)
以下をGoogle翻訳で翻訳します。
プロキシアップグレードパターン
Proxy Upgrade Pattern
この記事では、OpenZeppelin アップグレードの基本的な構成要素である「非構造化ストレージ」プロキシ パターンについて説明します。
TIP(ヒント)
さらに詳しく知りたい場合は、プロキシ パターンのブログ投稿を参照してください。このブログ投稿では、プロキシの必要性について説明し、この主題に関する技術的な詳細を説明し、OpenZeppelin のアップグレードで考慮された他の可能なプロキシ パターンについて詳しく説明しています。
コントラクトをアップグレードする理由
Why Upgrade a Contract ?
設計上、スマート コントラクトは不変です。
一方、ソフトウェアの品質は、反復リリースを作成するためにソース コードをアップグレードしてパッチを適用できるかどうかに大きく依存します。
ブロックチェーンベースのソフトウェアはテクノロジーの不変性から大きな利益を得ていますが、それでもバグ修正や潜在的な製品改善にはある程度の可変性が必要です。
OpenZeppelin Upgrades は、マルチシグウォレットでも、単純なアドレスでも、複雑なDAOでも、あらゆる種類のガバナンスによって制御できる、使いやすく、シンプルで堅牢な、スマート コントラクトのオプトイン アップグレード メカニズムを提供することで、この明らかな矛盾を解決します。
プロキシ パターンを使用したアップグレード
Upgrading via the Proxy Pattern
基本的な考え方は、アップグレードにプロキシを使用することです。 最初のコントラクトは、ユーザーが直接対話する単純なラッパーまたは「プロキシ」で、ロジックを含む 2 番目のコントラクトとの間のトランザクションの転送を担当します。
理解すべき重要な概念は、プロキシやアクセス ポイントは決して変更されずに、ロジック コントラクトを置き換えることができるということです。
どちらのコントラクトも、コードを変更できないという意味では依然として不変ですが、ロジック コントラクトは別のコントラクトと簡単に交換できます。
したがって、ラッパーは別のロジック実装を指すことができ、そうすることでソフトウェアが「アップグレード」されます。
User ---- tx ---> Proxy ----------> Implementation_v0
|
------------> Implementation_v1
|
------------> Implementation_v2
プロキシ転送
Proxy Forwarding
プロキシが解決する必要のある最も差し迫った問題は、ロジック コントラクト全体のインターフェイスの 1 対 1 マッピングを必要とせずに、プロキシがロジック コントラクトのインターフェイス全体を公開する方法です。
そうなると保守が難しくなり、エラーが発生しやすくなり、インターフェース自体をアップグレードできなくなります。
したがって、動的な転送メカニズムが必要になります。
このようなメカニズムの基本は、以下のコードに示されています。
// This code is for "illustration" purposes. To implement this functionality in production it
// is recommended to use the `Proxy` contract from the `@openzeppelin/contracts` library.
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.8.2/contracts/proxy/Proxy.sol
assembly {
// (1) copy incoming call data
calldatacopy(0, 0, calldatasize())
// (2) forward call to logic contract
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
// (3) retrieve return data
returndatacopy(0, 0, returndatasize())
// (4) forward return data back to caller
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
このコードはプロキシのフォールバック関数に置くことができ、ロジック コントラクトのインターフェイスについて特に何も知らなくても、任意のパラメーター セットを持つ任意の関数への呼び出しをロジック コントラクトに転送します。
基本的に、(1) 呼び出しデータがメモリにコピーされ、(2) 呼び出しがロジック コントラクトに転送され、(3) 呼び出しからロジック コントラクトへの戻りデータが取得され、(4) 返されたデータが転送されます。 発信者に戻ります。
注意すべき非常に重要な点は、このコードは、呼び出し側の状態のコンテキストで呼び出し先のコードを実行する EVM の delegatecall オペコードを利用していることです。
つまり、ロジック コントラクトはプロキシの状態を制御し、ロジック コントラクトの状態は無意味です。
したがって、プロキシはロジック コントラクトとの間でトランザクションを転送するだけでなく、ペアの状態も表します。
状態はプロキシ内にあり、ロジックはプロキシが指す特定の実装内にあります。
非構造化ストレージプロキシ
Unstructured Storage Proxies
プロキシを使用するときにすぐに発生する問題は、変数がプロキシ コントラクトへ格納される方法に関係しています。
プロキシがロジック コントラクトのアドレスを唯一の変数 address public _implementation;
に格納すると仮定します。
ここで、ロジック コントラクトが最初の変数が address public _owner
である基本トークンであると仮定します。
どちらの変数もサイズは 32 バイトで、EVM が認識する限り、プロキシされた呼び出しの結果の実行フローの最初のスロットを占有します。
ロジック コントラクトが _owner
に書き込むとき、それはプロキシの状態のスコープ内で行われ、実際には _implementation
に書き込みます。
この問題は「ストレージ衝突」と呼ばれます。
|Proxy |Implementation |
|--------------------------|-------------------------|
|address _implementation |address _owner | <=== Storage collision!
|... |mapping _balances |
| |uint256 _supply |
| |... |
この問題を克服する方法は数多くありますが、OpenZeppelin Upgrades が実装する「非構造化ストレージ」アプローチは次のように機能します。
プロキシの最初のストレージ スロットに _implementation
アドレスを格納する代わりに、擬似ランダム スロットを選択します。
このスロットは十分にランダムであるため、同じスロットで変数を宣言するロジック コントラクトの確率は無視できます。
プロキシのストレージ内のスロット位置をランダム化する同じ原理が、管理者アドレス (_implementation
の値を更新できる) など、プロキシが持つ可能性のある他の変数にも使用されます。
|Proxy |Implementation |
|--------------------------|-------------------------|
|... |address _owner |
|... |mapping _balances |
|... |uint256 _supply |
|... |... |
|... | |
|... | |
|... | |
|... | |
|address _implementation | | <=== Randomized slot.
|... | |
|... | |
EIP 1967 に従って、ランダム化されたストレージがどのように実現されるかの例:
bytes32 private constant implementationPosition = bytes32(uint256(
keccak256('eip1967.proxy.implementation')) - 1
));
その結果、ロジック コントラクトはプロキシの変数の上書きを気にする必要がありません。
この問題に直面する他のプロキシ実装では、通常、プロキシにロジック コントラクトのストレージ構造を認識させてそれに適応させるか、代わりにロジック コントラクトはプロキシのストレージ構造を認識させてそれに適応させることを意味します。
これが、このアプローチが「非構造化ストレージ」と呼ばれる理由です。 どちらの契約も、もう一方の契約の構造を気にする必要はありません。
実装バージョン間のストレージの衝突
Storage Collisions Between Implementation Versions
説明したように、非構造化アプローチでは、ロジック コントラクトとプロキシの間のストレージの衝突が回避されます。
ただし、異なるバージョンのロジック コントラクト間でストレージの衝突が発生する可能性があります。
この場合、ロジック コントラクトの最初の実装が最初のストレージ スロットに address public _owner
を格納し、アップグレードされたロジック コントラクトが同じ最初のスロットに address public _lastContributor
を格納すると想像します。
更新されたロジック コントラクトが _lastContributor
変数に書き込もうとすると、以前の _owner
の値が格納されていたのと同じ格納位置が使用され、上書きされます。
不適切なストレージ保存:
|Implementation_v0 |Implementation_v1 |
|--------------------|-------------------------|
|address _owner |address _lastContributor | <=== Storage collision!
|mapping _balances |address _owner |
|uint256 _supply |mapping _balances |
|... |uint256 _supply |
| |... |
正しい保管方法:
|Implementation_v0 |Implementation_v1 |
|--------------------|-------------------------|
|address _owner |address _owner |
|mapping _balances |mapping _balances |
|uint256 _supply |uint256 _supply |
|... |address _lastContributor | <=== Storage extension.
| |... |
非構造化ストレージ プロキシ メカニズムでは、この状況を防ぐことはできません。
ロジック コントラクトの新しいバージョンで以前のバージョンを拡張するかどうか、またはストレージ階層が常に追加されるが変更されないことを保証するかどうかは、ユーザー次第です。
ただし、OpenZeppelin Upgrades はそのような衝突を検出し、開発者に適切に警告します。
コンストラクターの警告
The Constructor Caveat
Solidity では、コンストラクター内のコードまたはグローバル変数宣言の一部は、デプロイされたコントラクトのランタイム バイトコードの一部ではありません。
このコードは、コントラクト インスタンスがデプロイされるときに 1 回だけ実行されます。
この結果、ロジック コントラクトのコンストラクター内のコードは、プロキシの状態のコンテキストで実行されることはありません。
言い換えると、プロキシはコンストラクターの存在をまったく意識しません。
それは単に彼らが代理としてそこにいなかったかのようです。
ただし、問題は簡単に解決されます。 ロジック コントラクトは、コンストラクター内のコードを通常の「イニシャライザー」関数に移動し、プロキシがこのロジック コントラクトにリンクするたび、この関数が呼び出されるようにする必要があります。
この初期化関数は 1 回しか呼び出せないように特別な注意を払う必要があります。これは一般的なプログラミングにおけるコンストラクターのプロパティの 1 つです。
これが、OpenZeppelin アップグレードを使用してプロキシを作成するときに、初期化関数の名前を指定してパラメーターを渡すことができる理由です。
initialize
関数が一度だけ呼び出せるようにするため、単純な修飾子が使用されます。
OpenZeppelin アップグレードは、拡張可能な契約を通じてこの機能を提供します。
// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract MyContract is Initializable {
function initialize(
address arg1,
uint256 arg2,
bytes memory arg3
) public payable initializer {
// "constructor" code...
}
}
コントラクトがどのように Initializable
を拡張し、それによって提供される initializer
を実装するかに注目してください。
透過的プロキシと関数の衝突
Transparent Proxies and Function Clashes
前のセクションで説明したように、アップグレード可能なコントラクト インスタンス (またはプロキシ) は、すべての呼び出しをロジック コントラクトに委任することで機能します。
ただし、プロキシには、新しい実装にアップグレードするための upgradeTo(address)
など、独自の関数がいくつか必要です。
ここで、ロジック コントラクトに upgradeTo(address)
という名前の関数がある場合にどのように処理を進めるかという疑問が生じます。その関数の呼び出し時に、呼び出し元はプロキシまたはロジック コントラクトを呼び出すつもりでしたか?
CAUTIION(注意)
競合は、異なる名前の関数間でも発生する可能性があります。
コントラクトのパブリック ABI の一部であるすべての関数は、バイトコード レベルで 4 バイトの識別子によって識別されます。
この識別子は関数の名前とアリティによって異なりますが、わずか 4 バイトであるため、名前の異なる 2 つの異なる関数が同じ識別子を持つ可能性があります。
Solidity コンパイラは、これが同じコントラクト内で発生した場合は追跡しますが、プロキシとそのロジック コントラクト間など、異なるコントラクト間で衝突が発生した場合は追跡しません。
これについて詳しくは、この記事をお読みください。
OpenZeppelin Upgrades がこの問題に対処する方法は、透過的なプロキシ パターンを使用することです。 透過的なプロキシは、呼び出し元のアドレス (つまり、msg.sender
) に基づいて、どの呼び出しが基礎となるロジック コントラクトに委任されるかを決定します。
- 呼び出し元がプロキシの管理者 (プロキシをアップグレードする権限を持つアドレス) の場合、プロキシは呼び出しを委任せず、理解できるメッセージにのみ応答します
- 呼び出し元が他のアドレスの場合、プロキシの機能のいずれかに一致するかどうかに関係なく、プロキシは常に呼び出しを委任します
owner()
および upgradeTo()
関数を備えたプロキシが、owner()
および transfer()
関数を備えた ERC20 コントラクトへの呼び出しを委任すると仮定すると、次の表はすべてのシナリオをカバーしています。
MSG.SENDER | OWNER() | UPGRADETO() | TRANSFER() |
---|---|---|---|
Owner | returns proxy.owner() | returns proxy.upgradeTo() | fails |
Other | returns erc20.owner() | fails | returns erc20.transfer() |
幸いなことに、OpenZeppelin Upgrades はこの状況を考慮しており、各透過プロキシに対して中間 ProxyAdmin コントラクトを使用します。
ノードのデフォルト アカウントから deploy
コマンドを呼び出した場合でも、ProxyAdmin コントラクトが透過プロキシの実際の管理者になります。
これは、透過的なプロキシ パターンの微妙な違いを気にすることなく、ノードのどのアカウントからでもプロキシと対話できることを意味します。
Solidity からプロキシを作成する上級ユーザーのみが、透過的なプロキシ パターンを認識する必要があります。
まとめ
Summary
アップグレード可能な契約を使用する開発者は、この記事で説明する方法でのプロキシについてよく知っている必要があります。
結局のところ、コンセプトは非常にシンプルであり、OpenZeppelin Upgrades は、プロジェクトの開発時に留意する必要がある事項の量を最小限に抑える方法で、すべてのプロキシ メカニズムをカプセル化するように設計されています。
すべては次のリストにまとめられます。
- プロキシとは何かについて基本的な理解がある
- ストレージを変更するのではなく、常に拡張します
- コントラクトはコンストラクターではなくイニシャライザー関数を使用するようにしてください
さらに、OpenZeppelin のアップグレードでは、このリストの項目のいずれかで問題が発生したときに通知されます。
さいごに(Conclusion)
Proxy Upgrade Patternの概要は理解できたと思います。
また、アップグレードのソースは衝突に気を付けるようにする必要がありそうです。