はじめに(Introduction)
以下をGoogle翻訳で翻訳します。
プロキシパターン
Proxy Patterns
OPENZEPPELIN | 2018年4月19日
この投稿に興味をお持ちいただきありがとうございます。
ブランド変更のプロセスを行っているため、一部の名前が古くなっている場合がありますが、ご容赦ください。
また、この投稿は当社製品の最新バージョンを参照していない可能性があることに注意してください。
最新のガイドについては、ドキュメント サイトをご確認ください。
Elena Nadolinski と Facu Spagnuolo のコラボレーションによります。 ありがとう!
2018 年 12 月の更新:
この記事が最初に公開されて以来、ZeppelinOS ではライブラリで使用するプロキシ パターンを改良することに熱心に取り組んできました。
ここでたどり着いた決定について読んで、これらのパターンのZeppelinOS が監査した実装を確認してください。
イーサリアムの最大の利点の 1 つは、資金移動のすべてのトランザクション、展開されたすべての契約、および契約に対して行われたすべてのトランザクションが、ブロックチェーンと呼ばれる公開台帳上で不変であることです。
これまでに行われたトランザクションを隠したり修正したりする方法はありません。
大きな利点は、イーサリアム ネットワーク上のどのノードでも、すべてのトランザクションの有効性と状態を検証できるため、イーサリアムは非常に堅牢な分散システムになっています。
ただし、最大の欠点は、スマート コントラクトのデプロイ後にソース コードを変更できないことです。
一元化されたアプリケーション (Facebook や Airbnb など) に取り組んでいる開発者は、バグを修正したり新機能を導入したりするために頻繁にアップデートを行うことに慣れています。
これは従来のパターンのイーサリアムでは不可能です。
150,000 ETH が盗まれた悪名高いパリティ ウォレット マルチシグ ハッキングを覚えていますか?
攻撃中、パリティ マルチシグ ウォレット コントラクトのバグが悪用され、注目度の高いウォレットから資金が流出していました。
できる唯一の和解は、ハッカーよりも速く行動し、同じ脆弱性を悪用して残りのウォレットをハッキングし、攻撃後にETHを正当な所有者に再分配することでした。
スマート コントラクトのデプロイ後にソース コードを更新する方法があれば…
プロキシ パターンの導入
Introducing Proxy Patterns
すでにデプロイされているスマート コントラクトのコードをアップグレードすることはできませんが、メイン ロジックがアップグレードされたかのように、新しくデプロイされたコントラクトを使用できるようにするプロキシ コントラクト アーキテクチャをセットアップすることは可能です。
プロキシ アーキテクチャ パターンでは、すべてのメッセージ呼び出しがプロキシ コントラクトを経由し、最新のデプロイされたコントラクト ロジックにリダイレクトされます。
アップグレードするには、新しいバージョンのコントラクトがデプロイされ、新しいコントラクトのアドレスを参照するようにプロキシが更新されます。
Zeppelin は、zeppelin_os を実装する取り組みの一環として、いくつかのプロキシ パターンに取り組んできました。
検討した 3 つのオプションは次のとおりです。
- 継承されたストレージ(Inherited Storage)
- 永続化ストレージ(Eternal Storage)
- 非構造化ストレージ(Unstructured Storage)
3 つのパターンはすべて、低レベルのデリゲート呼び出しに依存します。
Solidity は delegatecall 関数を提供しますが、呼び出しが成功したかどうか true/false を返すだけであり、返されたデータを管理することはできません。
本題に入る前に、理解することが重要な 2 つの重要な概念があります。
- サポートされていないコントラクトへの関数呼び出しが行われると、フォールバック関数が呼び出されます
このようなシナリオを処理するカスタム フォールバック関数を作成できます
プロキシ コントラクトは、カスタム フォールバック関数を使用して、呼び出しを他のコントラクト実装にリダイレクトします - コントラクト A が別のコントラクト B に呼び出しを委任するたびに、コントラクト A のコンテキストでコントラクト B のコードが実行されます
これは、msg.value と msg.sender の値が保持され、ストレージの変更がすべてコントラクト A のストレージに影響することを意味します
すべてのプロキシ パターンで共有される Zeppelin のプロキシ コントラクトは、この特別な理由から、ロジック コントラクトを呼び出した結果の値を返す独自の delegatecall 関数を実装します。
Zeppelin のプロキシ コントラクト コードの使用を計画している場合は、使用するコードを詳細に理解する必要があります。
これがどのように機能するかを正確に調べ、これを実現するために使用されるアセンブリ オペコードを理解しましょう。
(詳細については、Solidity のアセンブリ ドキュメントを参照してください)
[assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize)
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
let size := returndatasize
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
呼び出しを別の Solidity コントラクト関数に委任するには、プロキシが受信した msg.data を関数に渡す必要があります。
msg.data は動的データ構造であるバイト型であるため、msg.data の最初のワード サイズ (32 バイト) に格納される可変サイズを持ちます。
実際のデータだけを抽出したい場合は、最初のワード サイズをステップオーバーして、msg.data の 0x20
(32 バイト) から開始する必要があります。
ただし、代わりにこれを行うために利用するオペコードが 2 つあります。
calldatasize を使用して msg.data のサイズを取得し、calldatacopy を使用してそれを ptr
変数にコピーします。
ptr
変数をどのように初期化するかに注目してください。
Solidity では、位置 0x40
のメモリ スロットは、次に利用可能な空きメモリ ポインタの値が含まれるため特別です。
変数をメモリに直接保存するたび、0x40
の値を確認して、変数をどこに保存するかを検討する必要があります。
変数を保存できる場所がわかったので、calldatacopy
を使用して、呼び出しデータの 0 から始まるサイズ calldatasize
の呼び出しデータを ptr
の場所にコピーできます。
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize)
delegatecall
オペコードを使用するアセンブリ ブロック内の次の行を見てみましょう。
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
パラメーター(Parameters)
-
gas
関数の実行に必要なガスを渡します -
_impl
呼び出しているロジック コントラクトのアドレス -
ptr
データの開始位置を示すメモリ ポインタ -
calldatasize
渡すデータのサイズ - ロジック コントラクトの呼び出しからの戻り値を表すデータ出力の場合は
0
データ出力のサイズがまだ分からないため、変数に割り当てることができないため、これは使用されません
後でもreturndata
オペコードを使用してこの情報にアクセスできます - サイズアウトの場合は
0
他のコントラクトを呼び出す前にそのサイズがわからなかったため、データを保存するための一時変数を作成する機会がなかったため、これは使用されません
後でreturndatasize
オペコードを呼び出すことで、別の方法を使用してこの値を取得できます
次の行では、returndatasize
オペコードを使用して返されたデータのサイズを取得します。
let size := returndatasize
そして、返されたデータのサイズを使用して、ヘルパー オペコード関数 returndatacopy
を使用して、返されたデータの内容を ptr
変数にコピーします。
returndatacopy(ptr, 0, size)
最後に、switch ステートメントは、返されたデータを返すか、何か問題が発生した場合は例外をスローします。
これで、ロジック コントラクトから適切な結果値を取得する方法ができました。
プロキシ コントラクトがどのように機能するかを理解したところで、Zeppelin が提案する 3 つのパターン、つまり継承ストレージ、非構造化ストレージ、および永久ストレージを使用したアップグレード可能性を見てみましょう。
3 つのアプローチは、同じ技術的困難に対処するための異なる方法を持っています。アップグレード可能性のためにプロキシで使用される状態変数をロジック コントラクトで上書きしないようにする方法。
プロキシ アーキテクチャ パターンにおける主な関心事は、ストレージ割り当てをどのように処理するかです。
ストレージに 1 つのコントラクトを使用し、ロジックに別のコントラクトを使用しているため、いずれかが既に使用されているストレージ スロットを上書きする可能性があることに注意してください。
これは、プロキシ コントラクトに、ストレージ スロットの最新のロジック コントラクト アドレスを追跡するための状態変数があり、ロジック コントラクトがそれを認識していない場合、ロジック コントラクトは同じスロットに他のデータを保存し、プロキシの重要な情報を上書きする可能性があります。
Zeppelin の 3 つのアプローチは、プロキシ パターンを介して契約をアップグレードできるようにシステムを設計するさまざまな方法を示しています。
継承ストレージを使用したアップグレード可能性
Upgradeability using Inherited Storage
継承ストレージのアプローチは、プロキシが必要とするストレージ構造をロジック コントラクトに組み込むことに依存しています。
プロキシ コントラクトとロジック コントラクトは両方とも同じストレージ構造を継承し、両方が必要なプロキシ状態変数を確実に格納するようにします。
このアプローチを検討しているときに、ロジック コントラクトのさまざまなバージョンを追跡するために Registry
コントラクトを用意するというアイデアを試しました。
新しいロジック コントラクトにアップグレードするには、それを新しいバージョンとしてレジストリに登録し、プロキシにアップグレードするように依頼する必要があります。
レジストリがあってもストレージ メカニズムには影響しないことに注意してください。 実際、この投稿で示したストレージ パターンのいずれでも実装できます。
初期化の方法(How to Initialize)
-
Registry
コントラクトをデプロイする - コントラクトの初期バージョン (v1) をデプロイします
アップグレード可能なコントラクトを継承していることを確認してください - 初期バージョンのアドレスを
Registry
に登録します -
Registry
コントラクトにUpgradeabilityProxy
インスタンスを作成するよう依頼します -
UpgradeabilityProxy
を呼び出して、コントラクトの初期バージョンにアップグレードします
アップグレード方法(How to Upgrade)
- 初期バージョンから継承した新しいバージョンのコントラクト (v2) をデプロイして、プロキシのストレージ構造とコントラクトの初期バージョンのストレージ構造が確実に保持されるようにします
- 新しいバージョンのコントラクトを
Registry
に登録します -
UpgradeabilityProxy
インスタンスを呼び出して、新しく登録されたバージョンにアップグレードします
重要なポイント(Key Takeaways)
同じ UpgradeabilityProxy
コントラクトを引き続き呼び出すことで、将来デプロイされるロジック コントラクトに、アップグレードされた関数だけでなく、新しい関数や新しい状態変数も導入できます。
永続化ストレージを使用したアップグレード可能性
Upgradeability using Eternal Storage
永続化ストレージパターンでは、ストレージ スキーマは、プロキシ コントラクトとロジック コントラクトの両方が継承する別のコントラクトで定義されます。
ストレージ コントラクトは、ロジック コントラクトが必要とするすべての状態変数を保持します。プロキシもそれらを認識しているため、上書きされることを心配することなく、アップグレードに必要な独自の状態変数を定義できます。
ロジック コントラクトの将来のすべてのバージョンでは、他の状態変数を定義しないことに注意してください。
ロジック コントラクトのすべてのバージョンは、最初に定義された永続化ストレージ構造を常に使用する必要があります。
Zeppelin の labs リポジトリで提供されるこの実装では、プロキシ所有権の概念も導入されています。
プロキシ所有者は、新しいロジック コントラクトを指すようにプロキシをアップグレードできる唯一のアドレスであり、所有権を譲渡できる唯一のアドレスです。
初期化の方法(How to Initialize)
-
EternalStorageProxy
インスタンスをデプロイする - コントラクトの初期バージョン (v1) をデプロイします
-
EternalStorageProxy
インスタンスを呼び出して、初期バージョンのアドレスにアップグレードします - ロジック コントラクトがコンストラクターに依存して初期状態を設定している場合、プロキシのストレージはそれらの値を認識していないため、プロキシにリンクした後で再実行する必要があります
EternalStorageProxy
には、プロキシがアップグレードされた後にセットアップをやり直すためにロジック コントラクト上の関数を呼び出すための関数upgradeToAndCall
があります
アップグレード方法(How to Upgrade)
- コントラクトの新しいバージョン (v2) をデプロイして、永続化ストレージ構造を保持していることを確認します
- 新しいバージョンにアップグレードするには、
EternalStorageProxy
インスタンスを呼び出します。
重要なポイント(Key Takeaways)
トークン ロジック コントラクトに大きなオーバーヘッドを発生させない直感的なアプローチです。
将来のロジック コンタクトでは、既存のメソッドをアップグレードして新しいメソッドを導入できますが、新しい状態変数を導入すべきではありません。
非構造化ストレージを使用したアップグレード可能性
Upgradeability using Unstructured Storage
非構造化ストレージ パターンは継承ストレージに似ていますが、アップグレード可能性に関連する状態変数を継承するためのロジック コントラクトを必要としません。
このパターンでは、プロキシ契約で定義された非構造化ストレージ スロットを使用して、アップグレードに必要なデータを保存します。
プロキシ コントラクトでは、ハッシュ化されたときに、プロキシが呼び出す必要のあるロジック コントラクトのアドレスを格納するのに十分なランダムな格納位置を与える定数変数を定義します。
bytes32 private constant implementationPosition =
keccak256("org.zeppelinos.proxy.implementation");
定数状態変数はストレージ スロットを占有しないため、implementationPosition
がロジック コントラクトによって誤って上書きされる心配はありません。
Solidity がストレージ内で状態変数をレイアウトする方法により、ロジック コントラクトで定義された他のものによって使用されるこのストレージ スロットが衝突する可能性はほとんどありません。
このパターンを使用すると、ロジック コントラクトのバージョンはいずれもプロキシのストレージ構造について知る必要がなくなりますが、将来のすべてのロジック コントラクトは、祖先バージョンによって宣言されたストレージ変数を継承する必要があります。
継承ストレージ パターンと同様に、将来アップグレードされるトークン ロジック コントラクトでは、既存の関数をアップグレードするだけでなく、新しい関数や新しいストレージ変数を導入することもできます。
Zeppelin の labs リポジトリで提供されるこの実装では、プロキシ所有権の概念も使用されています。
プロキシ所有者は、新しいロジック コントラクトを指すようにプロキシをアップグレードできる唯一のアドレスであり、所有権を譲渡できる唯一のアドレスです。
初期化の方法(How to Initialize)
-
OwnedUpgradeabilityProxy
インスタンスをデプロイする - コントラクトの初期バージョン (v1) をデプロイします
-
OwnedUpgradeabilityProxy
インスタンスを呼び出して、初期バージョンのアドレスにアップグレードします - ロジック コントラクトがコンストラクターに依存して初期状態を設定している場合、プロキシのストレージはそれらの値を認識していないため、プロキシにリンクした後で再実行する必要があります
OwnedUpgradeabilityProxy
には、プロキシがアップグレードされた後にセットアップをやり直すためにロジック コントラクト上の関数を呼び出すための関数upgradeToAndCall
があります
アップグレード方法(How to Upgrade)
- コントラクトの新しいバージョン (v2) をデプロイし、以前のバージョンで使用されていた状態変数構造を継承していることを確認します
-
OwnedUpgradeabilityProxy
インスタンスを呼び出して、新しいコントラクトバージョンのアドレスにアップグレードします
重要なポイント(Key Takeaways)
このアプローチは、トークン ロジック コントラクトがプロキシ コントラクト システムの一部であることを認識する必要がないため、優れています。
アップグレード可能性について
On Upgradeability
重要: ロジック コントラクトがコンストラクターに依存して初期状態を設定する場合、プロキシがロジック コントラクトにアップグレードした後でこれをやり直す必要があります。
たとえば、ロジック コントラクトが Zeppelin の Ownable
コントラクト実装を継承するのが一般的です。
ロジック コントラクトが Ownable
から継承すると、コントラクト作成時に所有者を設定する Ownable
のコンストラクターも継承します。
ロジック コントラクトを使用するためにプロキシ コントラクトをリンクすると、プロキシの観点から所有者が誰であるかという価値が失われます。
プロキシ コントラクトをアップグレードする一般的なパターンは、プロキシがロジック コントラクトの初期化メソッドをすぐに呼び出すことです。
初期化メソッドは、従来コンストラクターに入れていたすべてのものを模倣する必要があります。
また、ロジック コントラクトを複数回初期化できないようにフラグを含めることもできます。
ロジック コントラクトは次のようになります。
[contract Token is Ownable {
...
bool internal _initialized;
function initialize(address owner) public {
require(!_initialized);
setOwner(owner);
_initialized = true;
}
...
}
デプロイ戦略に応じて、ヘルパー デプロイヤー コントラクトを使用することも、プロキシとロジック コントラクトを個別にデプロイすることもできます。
個別にデプロイする場合は、次のように upgradeToAndCall
を使用してプロキシをロジック コントラクトにリンクします。
[const initializeData = encodeCall('initialize', ['address'], [tokenOwner]) await proxy.upgradeToAndCall(logicContract.address, initializeData, {
from: proxyOwner
})
結論
Conclusion
プロキシ パターンの概念はしばらく存在していましたが、その複雑さ、セキュリティ脆弱性の導入への懸念、ブロックチェーンの不変性のバイパスに関する論争のため、広く採用されていませんでした。
また、過去のソリューションはかなり柔軟性に欠けており、将来のロジック コントラクトでは、修正および追加できる内容が大幅に制限されます。
ただし、開発者の観点からは、契約をアップグレードする機能が非常に必要であることは明らかです。
Zeppelin は、開発者がアップグレード可能性を組み込んだプロジェクトを設計できるように、調査した 3 つのプロキシ パターンのコードとテストを提供しました。
プロキシ パターンの概念は新しいものではありませんが、その導入はまだ初期段階にあり、より高度な DApp アーキテクチャがこのパラダイムで実現されるのを見るのは興味深いことです。
プロキシ パターンを使用して何かを構築した場合は、Twitter で私と Zeppelin に知らせてから、Zeppelin の Slack チャンネルに参加してそこでも披露してください 🙂
参考文献
Further Reading
EVM 上のツールとサービスの分散プラットフォームである zeppelin_os を実装する Zeppelin の取り組みの一環として、Zeppelin チームは現在、非構造化ストレージのアプローチを進めています。
非構造化ストレージのアプローチには、プロキシに必要なストレージを維持する新しい方法を導入することにより、ロジック コントラクトからの最小限の実装が必要になるという大きな利点があります。
zeppelin_os カーネルの今後のリリースに向けている、Zeppelin の非構造化ストレージを使用という選択について詳しく読んでください。
Zeppelin は、永続化ストレージ に関する詳細な技術ブログも投稿しました。
1 年以上前、Aragon と Zeppelin はチームを組んで、プロキシ ライブラリに関する 2 つのブログ投稿を執筆しました。これらの投稿は、こことここで見つけることができます。
Go-Ethereum のコア開発者であり、ENS の主任開発者である Arachnid (別名 Nick Johnson) は、2 年以上前に公開したこの要旨の中で、Upgradeable および Dispatcher コントラクトに対する自身の見解を書きました。
シンプルなものを構築しようとしており、将来の契約に大きな変更が予想されない場合は、この非常に単純な例を検討することを検討してください。
Solidity のドキュメントは常に役に立ちます。Solidity のデリゲート呼び出し関数とアセンブリのオペコードについてはドキュメントを参照することをお勧めします。
すべての図はこの Figma ファイルで作成されており、独自の図に自由に複製できます 🙂
2018 年 12 月更新: この記事が最初に公開されて以来、ZeppelinOS ではライブラリで使用するプロキシ パターンを改良することに熱心に取り組んできました。
ここでたどり着いた決定について読んで、ZeppelinOS が監査したこれらのパターンの実装を確認してください。
まとめ(Conclusion)
3つのProxy Patternsを考えていたことが分かりました。
その上で、非構造化ストレージを採用したようです。