【完全保存版】EIP1967についてしっかり学ぼう
こちらは、EIP1967の翻訳・編集をした記事です。
0 はじめに
代理コントラクト(以下、プロキシコントラクト)の委任は、更新可能性(以下、アップグレーダブル)とガス消費量の節約の両方のために広く使用されています。
これらのプロキシは、delegatecallを使用して呼び出されるロジックコントラクト(インプリメンテーションコントラクトまたはマスターコピーとも呼ばれる)に依存しています。
これにより、プロキシは永続的な状態(ストレージとバランス)を保持しつつ、コードはロジックコントラクトに委任されます。
プロキシとロジックコントラクトの間でストレージの使用が衝突しないように、ロジックコントラクトのアドレスは通常、コンパイラによって割り当てられることがないことが保証された特定のストレージスロット(例えば、OpenZeppelin契約の0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc)に保存されます。
このEIPは、プロキシ情報を保存するための標準スロットのセットを提案します。
これにより、ブロックエクスプローラーのようなクライアントは、この情報を適切に抽出し、エンドユーザーに表示することが可能となり、ロジックコントラクトは必要に応じてこの情報に基づいて行動することが可能となります。
1 動機
アップグレードをサポートし、デプロイメントのガスコストを削減する手段として、委任プロキシが広く使用されています。
OpenZeppelin Contracts, Gnosis, AragonOS, Melonport, Limechain, WindingTree, Decentralandなど、これらのプロキシの例が見られます。
しかし、プロキシからロジックアドレスを取得するための共通インターフェースの欠如により、この情報に基づいて行動する共通ツールを構築することは不可能です。
典型的な例はブロックエクスプローラーです。
ここでは、エンドユーザーはプロキシ自体ではなく、下層のロジックコントラクトと対話したいと考えています。
プロキシからロジックコントラクトアドレスを取得する共通の方法があれば、ブロックエクスプローラーはプロキシコントラクトではなく、ロジックコントラクトのABIを表示できます。
エクスプローラーは、コントラクトの区別されたスロットのストレージをチェックして、それが実際にプロキシであるかどうかを判断し、その場合はプロキシとロジックコントラクトの両方の情報を表示します。
例として、0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48がEtherscan上でどのように表示されるか見てみましょう。
別の例としては、自身がプロキシ化されていることを明確に認識し、それに基づいて行動するロジックコントラクトがあります。
これにより、ロジックの一部としてコードの更新を可能にすることができます。
一般的なストレージスロットを使用することで、これらのユースケースは使用されている具体的なプロキシ実装に独立して可能となります。
2 仕様
プロキシのモニタリング(監視)は多くのアプリケーションのセキュリティにとって重要です。
したがって、実装スロットと管理スロットの変更を追跡する能力は必要不可欠です。
残念ながら、ストレージスロットの変更を追跡するのは容易ではありません。
したがって、これらのスロットのいずれかを変更する関数は、対応するイベントを発行すべき(SHOULD)です。
これには、初期化(0x0から、最初の非ゼロ値まで)のすべてが含まれます。
プロキシ固有の情報のための提案されたストレージスロットは次のとおりです。
追加の情報については、必要に応じて後続のERCでさらにスロットを追加できます。
1 ロジックコントラクトアドレス
このプロキシが委任するロジックコントラクトのアドレスを保持します。
ビーコンが代わりに使用される場合、空であるべき(SHOULD)です。
このスロットの変更は、以下のイベントによって通知されるべき(SHOULD)です。
event Upgraded(address indexed implementation);
2 ビーコンコントラクトアドレス
このプロキシが依存するビーコンコントラクトのアドレス(フォールバック)を保持します。
直接ロジックアドレスが使用される場合は空であるべき(SHOULD)であり、ロジックコントラクトスロットが空の場合にのみ考慮されるべきです。
このスロットの変更は以下のイベントによって通知されるべき(SHOULD)です。
event BeaconUpgraded(address indexed beacon);
ビーコンは、複数のプロキシのロジックアドレスを一か所で保持するために使用され、単一のストレージスロットを変更することで複数のプロキシをアップグレードすることを可能にします。
ビーコンコントラクトは以下の関数を実装する必要があります(MUST)
function implementation() returns (address)
ビーコンベースのプロキシコントラクトはロジックコントラクトスロットを使用しません。
代わりに、彼らはビーコンコントラクトスロットを使用して、それらが接続されているビーコンのアドレスを保存します。
ビーコンプロキシが使用するロジックコントラクトを知るために、クライアントは以下を行うべき(SHOULD)です。
ビーコンロジックストレージスロットのビーコンのアドレスを読み取る
ビーコンコントラクト上でのimplementation()関数を呼び出す。
ビーコンコントラクトのimplementation()関数の結果は、呼び出し元(msg.sender)に依存してはならない(SHOULD NOT)。
3 管理者アドレス
このプロキシのロジックコントラクトアドレスをアップグレードすることが許可されたアドレスを保持します(任意)。
このスロットの変更は以下のイベントによって通知されるべき(SHOULD)です。
event AdminChanged(address previousAdmin, address newAdmin);
3 根拠
このEIPは、プロキシコントラクトの公開メソッドではなく、ロジックコントラクトアドレスのストレージスロットを標準化します。
これは、プロキシがロジックコントラクトと潜在的に衝突する可能性のある関数をエンドユーザーに公開するべきではないという理由からです。
関数のセレクタには4バイトしか使わないため、異なる名前の関数間で衝突が発生する可能性があることに注意してください。
これは予期しないエラー、あるいは攻撃につながる可能性があり、プロキシが呼び出しを妨害して自身の値で応答するため、プロキシ化されたコントラクトへの呼び出しは予期した値とは異なる結果を返す可能性があります。
Nomic LabsによるEthereumプロキシの悪意あるバックドアから。
プロキシの公開関数が潜在的に悪用可能であるという事実は、ロジックコントラクトアドレスを異なる方法で標準化する必要性を生じさせます。
選択されたストレージスロットの主な要件は、コンパイラによって任意のコントラクト状態変数を格納するために選択されてはならないことです。
そうでなければ、ロジックコントラクトは自身の変数に書き込むときに、誤ってプロキシのこの情報を上書きしてしまう可能性があります。
Solidityは、コントラクトの継承チェーンが直列化された後で、宣言された順序に基づいて変数をストレージにマップします。
最初の変数が最初のスロットに割り当てられ、以下同様です。
例外は、キーとストレージスロットの連結のハッシュに格納される、動的配列とマッピングの値です。
Solidity開発チームは、新しいバージョン間でストレージレイアウトを維持することを確認しました。
VyperはSolidityと同じ戦略に従っているようです。
他の言語で書かれたコントラクト、または直接アセンブリで、衝突が生じる可能性があることに注意してください。
彼らは、コンパイラによって割り当てられた状態変数と衝突しないことが保証されるように選択されており、ストレージインデックスで始まらない文字列のハッシュに依存しています。
さらに、ハッシュの事前画像が知られないように、-1のオフセットが追加され、可能な攻撃の可能性をさらに減らします。
4 参照実装
以下、EIP1967に載っている参照実装を翻訳し、そのまま載せます。
また、_delegate関数をその下で取り上げました。
/**
* @dev このコントラクトはアップグレード可能なプロキシを実装します。
* 呼び出しは変更可能な実装アドレスに委任されるため、アップグレードが可能です。
* このアドレスは、https://eips.ethereum.org/EIPS/eip-1967[EIP1967]で指定された場所のストレージに格納されているため、
* プロキシの背後にある実装のストレージレイアウトと衝突することはありません。
*/
contract ERC1967Proxy is Proxy, ERC1967Upgrade {
/**
* @dev 初期の実装を _logic で指定して、アップグレード可能なプロキシを初期化します。
* もし _data が空でなければ、それは _logic へのデリゲートコールでデータとして使用されます。
* これは通常、エンコードされた関数呼び出しとなり、Solidityのコンストラクタのようにプロキシのストレージを初期化することを可能にします。
*/
constructor(address _logic, bytes memory _data) payable {
assert(_IMPLEMENTATION_SLOT == bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1));
_upgradeToAndCall(_logic, _data, false);
}
/**
* @dev 現在の実装アドレスを返します。
*/
function _implementation() internal view virtual override returns (address impl) {
return ERC1967Upgrade._getImplementation();
}
}
/**
* @dev この抽象コントラクトはhttps://eips.ethereum.org/EIPS/eip-1967[EIP1967] に対して、
* ゲッターとアップデート関数の発火を提供します。
*/
abstract contract ERC1967Upgrade {
// これは"eip1967.proxy.rollback"のkeccak-256ハッシュから1を引いています。
bytes32 private constant _ROLLBACK_SLOT = 0x4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd9143;
/**
* @dev 現在の実装のアドレスとなるストレージスロット。
* これは "eip1967.proxy.implementation" の keccak-256 ハッシュから1を引いたもので、
* コンストラクタで検証されます。
*/
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
/**
* @dev 実装がアップグレードされたときに発行されます。
*/
event Upgraded(address indexed implementation);
/**
* @dev 現在の実装アドレスを返します。
*/
function _getImplementation() internal view returns (address) {
return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value;
}
/**
* @dev EIP1967実装スロットに新しいアドレスを保存します。
*/
function _setImplementation(address newImplementation) private {
require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");
StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation;
}
/**
* @dev 実装のアップグレードを実行します。
*
* {Upgraded} イベントを発行します。
*/
function _upgradeTo(address newImplementation) internal {
_setImplementation(newImplementation);
emit Upgraded(newImplementation);
}
/**
* @dev 追加のセットアップコールを伴う実装のアップグレードを実行します。
*
* {Upgraded} イベントを発行します。
*/
function _upgradeToAndCall(
address newImplementation,
bytes memory data,
bool forceCall
) internal {
_upgradeTo(newImplementation);
if (data.length > 0 || forceCall) {
Address.functionDelegateCall(newImplementation, data);
}
}
/**
* @dev UUPSプロキシのセキュリティチェックと追加のセットアップコールを伴う実装のアップグレードを実行します。
*
* {Upgraded} イベントを発行します。
*/
function _upgradeToAndCallSecure(
address newImplementation,
bytes memory data,
bool forceCall
) internal {
address oldImplementation = _getImplementation();
// 初期のアップグレードとセットアップコール
_setImplementation(newImplementation);
if (data.length > 0 || forceCall) {
Address.functionDelegateCall(newImplementation, data);
}
// すでに進行中でなければロールバックテストを実行
StorageSlot.BooleanSlot storage rollbackTesting = StorageSlot.getBooleanSlot(_ROLLBACK_SLOT);
if (!rollbackTesting.value) {
// 新しい実装からのupgradeToを使用してロールバックをトリガー
rollbackTesting.value = true;
Address.functionDelegateCall(
newImplementation,
abi.encodeWithSignature("upgradeTo(address)", oldImplementation)
);
rollbackTesting.value = false;
// ロールバックが効果的だったかを確認
require(oldImplementation == _getImplementation(), "ERC1967Upgrade: upgrade breaks further upgrades");
// 最後に新しい実装にリセットし、アップグレードをログに記録
_upgradeTo(newImplementation);
}
}
/**
* @dev 契約の管理者となるストレージスロット。
* これは "eip1967.proxy.admin" の keccak-256 ハッシュから1を引いたもので、コンストラクタで検証されます。
*/
bytes32 internal constant _ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
/**
* @dev 管理者アカウントが変更されたときに発行されます。
*/
event AdminChanged(address previousAdmin, address newAdmin);
/**
* @dev 現在の管理者を返します。
*/
function _getAdmin() internal view returns (address) {
return StorageSlot.getAddressSlot(_ADMIN_SLOT).value;
}
/**
* @dev EIP1967の管理者スロットに新しいアドレスを保存します。
*/
function _setAdmin(address newAdmin) private {
require(newAdmin != address(0), "ERC1967: new admin is the zero address");
StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;
}
/**
* @dev プロキシの管理者を変更します。
*
* {AdminChanged} イベントを発行します。
*/
function _changeAdmin(address newAdmin) internal {
emit AdminChanged(_getAdmin(), newAdmin);
_setAdmin(newAdmin);
}
/**
* @dev このプロキシの実装を定義するUpgradeableBeacon契約のストレージスロット。
* これは bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1)) で、コンストラクタで検証されます。
*/
bytes32 internal constant _BEACON_SLOT = 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50;
/**
* @dev ビーコンがアップグレードされたときに発行されます。
*/
event BeaconUpgraded(address indexed beacon);
/**
* @dev 現在のビーコンを返します。
*/
function _getBeacon() internal view returns (address) {
return StorageSlot.getAddressSlot(_BEACON_SLOT).value;
}
/**
* @dev EIP1967のビーコンスロットに新しいビーコンを保存します。
*/
function _setBeacon(address newBeacon) private {
require(Address.isContract(newBeacon), "ERC1967: new beacon is not a contract");
require(
Address.isContract(IBeacon(newBeacon).implementation()),
"ERC1967: beacon implementation is not a contract"
);
StorageSlot.getAddressSlot(_BEACON_SLOT).value = newBeacon;
}
/**
* @dev 追加のセットアップコールを伴うビーコンのアップグレードを実行します。
* 注意:これはビーコンのアドレスをアップグレードするもので、ビーコンに含まれる実装をアップグレードするものではありません
* (そのためには{UpgradeableBeacon-_setImplementation}を参照してください)。
*
* {BeaconUpgraded} イベントを発行します。
*/
function _upgradeBeaconToAndCall(
address newBeacon,
bytes memory data,
bool forceCall
) internal {
_setBeacon(newBeacon);
emit BeaconUpgraded(newBeacon);
if (data.length > 0 || forceCall) {
Address.functionDelegateCall(IBeacon(newBeacon).implementation(), data);
}
}
}
/**
* @dev この抽象契約は、EVMの命令 delegatecallを使用して全ての呼び出しを別の契約に委任するフォールバック関数を提供します。
* 私たちは、プロキシの背後にある_実装_と呼ぶこの二つ目の契約を指定するために、仮想的な {_implementation}関数をオーバーライドする必要があります。
* さらに、実装への委任は手動で{_fallback}関数を通じて、または異なる契約を通じて{_delegate}関数を通じてトリガーすることができます。
* 委任された呼び出しの成功と戻りデータは、プロキシの呼び出し元に戻されます。
*/
abstract contract Proxy {
/**
* @dev 現在の呼び出しを implementationに委任します。
* この関数はその内部呼び出しサイトには戻らず、直接外部の呼び出し元に戻ります。
*/
function _delegate(address implementation) internal virtual {
assembly {
// msg.dataをコピーします。このインラインアセンブリブロックではメモリを完全に制御します。
// なぜなら、これはSolidityコードには戻らないからです。私たちは
// メモリ位置0でのSolidityスクラッチパッドを上書きします。
calldatacopy(0, 0, calldatasize())
// 実装を呼び出します。
// outとoutsizeは、まだサイズがわからないため、0です。
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
// 返されたデータをコピーします。
returndatacopy(0, 0, returndatasize())
switch result
// delegatecallはエラー時に0を返します。
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
/**
* @dev これは仮想関数であり、フォールバック関数と{_fallback}が委任するアドレスを返すようにオーバーライドする必要があります。
*/
function _implementation() internal view virtual returns (address);
/**
* @dev 現在の呼び出しを、_implementation()が返すアドレスに委任します。
*
* この関数は、内部の呼び出しサイトには戻らず、直接外部の呼び出し元に戻ります。
*/
function _fallback() internal virtual {
_beforeFallback();
_delegate(_implementation());
}
/**
* @dev 呼び出しデータが一致しない他の契約関数が存在しない場合に、_implementation()が返すアドレスに呼び出しを委任するフォールバック関数が実行されます。
*/
fallback() external payable virtual {
_fallback();
}
/**
* @dev 呼び出しデータが空の場合、_implementation()が返すアドレスに呼び出しを委任するフォールバック関数が実行されます。
*/
receive() external payable virtual {
_fallback();
}
/**
* @dev 実装にフォールバックする前に呼び出されるフック。手動で _fallback を呼び出す一部として発生するか、Solidityの fallback または receive 関数の一部として発生します。
*
* オーバーライドする場合は super._beforeFallback() を呼び出すべきです。
*/
function _beforeFallback() internal virtual {}
}
/**
* @dev 特定のストレージスロットに基本型を読み書きするためのライブラリ。
* ストレージスロットは、アップグレード可能な契約を取り扱う際に、ストレージの競合を避けるためによく使われます。
* このライブラリは、インラインアセンブリを必要とせずにそのようなスロットへの読み書きを支援します。
* このライブラリの関数は、読み書きに使用できる value メンバーを含む Slot 構造体を返します。
*/
library StorageSlot {
struct AddressSlot {
address value;
}
struct BooleanSlot {
bool value;
}
struct Bytes32Slot {
bytes32 value;
}
struct Uint256Slot {
uint256 value;
}
/**
* @dev slotに位置するメンバーvalueを持つAddressSlotを返します。
*/
function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r.slot := slot
}
}
/**
* @dev slotに位置するメンバーvalueを持つBooleanSlotを返します。
*/
function getBooleanSlot(bytes32 slot) internal pure returns (BooleanSlot storage r) {
assembly {
r.slot := slot
}
}
/**
* @dev slotに位置するメンバーvalueを持つBytes32Slotを返します。
*/
function getBytes32Slot(bytes32 slot) internal pure returns (Bytes32Slot storage r) {
assembly {
r.slot := slot
}
}
/**
* @dev slotに位置するメンバーvalueを持つUint256Slotを返します。
*/
function getUint256Slot(bytes32 slot) internal pure returns (Uint256Slot storage r) {
assembly {
r.slot := slot
}
}
_delegate関数について
まずは、calldatacopyを使って、calldataをメモリにコピーします。
これは、次のdelegatecallを行うために、一時的に行われています。
次に、delegatecallを行います。
これは、呼び出し元のコントラクト(proxy)で呼び出し先の関数を実行しています。
呼び出し先の情報は、上のcalldatacopyでメモリにコピーされていました。
最後にreturndatacopyで返り値をメモリにコピーします。
/**
* @dev 特定のストレージスロットに基本型を読み書きするためのライブラリ。
* ストレージスロットは、アップグレード可能な契約を取り扱う際に、ストレージの競合を避けるためによく使われます。
* このライブラリは、インラインアセンブリを必要とせずにそのようなスロットへの読み書きを支援します。
* このライブラリの関数は、読み書きに使用できる value メンバーを含む Slot 構造体を返します。
*/
library StorageSlot {
struct AddressSlot {
address value;
}
struct BooleanSlot {
bool value;
}
struct Bytes32Slot {
bytes32 value;
}
struct Uint256Slot {
uint256 value;
}
/**
* @dev slotに位置するメンバーvalueを持つAddressSlotを返します。
*/
function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r.slot := slot
}
}
/**
* @dev slotに位置するメンバーvalueを持つBooleanSlotを返します。
*/
function getBooleanSlot(bytes32 slot) internal pure returns (BooleanSlot storage r) {
assembly {
r.slot := slot
}
}
/**
* @dev slotに位置するメンバーvalueを持つBytes32Slotを返します。
*/
function getBytes32Slot(bytes32 slot) internal pure returns (Bytes32Slot storage r) {
assembly {
r.slot := slot
}
}
/**
* @dev slotに位置するメンバーvalueを持つUint256Slotを返します。
*/
function getUint256Slot(bytes32 slot) internal pure returns (Uint256Slot storage r) {
assembly {
r.slot := slot
}
}
5 セキュリティ考慮事項
このERCは、選択されたストレージスロットがSolidityコンパイラによって割り当てられないことに依存しています。
これにより、実装コントラクトが誤ってプロキシの操作に必要な情報のいずれかを上書きしないことが保証されます。
したがって、コンパイラによって割り当てられたスロットと衝突しないように、高いスロット番号の場所が選択されました。
また、既知の事前画像がない場所が選ばれました。
これは、悪意あるキーを用いたマッピングへの書き込みがそれを上書きできないようにするためです。
プロキシ固有の情報を修正しようとするロジックコントラクトは、特定のストレージスロットに書き込むことでこれを意図的に行う必要があります(UUPSの場合など)。
6 著作権
CC0を経由して放棄された著作権および関連する権利。
7 引用
この文書を以下のように引用してください:
Santiago Palladino (@spalladino), Francisco Giordano (@frangio), Hadrien Croubois (@Amxx), "ERC-1967: Proxy Storage Slots," Ethereum Improvement Proposals, no. 1967, April 2019. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-1967.
サポートをしていただけたらすごく嬉しいです😄 いただけたサポートを励みに、これからもコツコツ頑張っていきます😊