コントラクトプロキシのアップグレード#
基本的な呼び出し(Call / DelegateCall)#
- Call:ターゲットコントラクトの関数を呼び出し、ターゲットコントラクトのコンテキストで実行されるが、呼び出し元コントラクトのストレージ状態は変更されない
- DelegateCall:ターゲットコントラクトの関数を呼び出し、呼び出し元コントラクトのコンテキストで実行され、呼び出し元コントラクトのストレージ状態が変更される
基本的な概念#
-
EVM は状態変数を気にせず、ストレージスロット上で操作を行う。すべての変数の状態変化は、ストレージスロット上の内容の変更に対応しているため、スロットの衝突に注意が必要であり、現在のコントラクトとターゲットコントラクトのスロット配置が衝突してはいけない
-
あるコントラクトがターゲットのスマートコントラクトに delegatecall を行うと、そのコントラクトの環境内でターゲットコントラクトのロジックが実行される(ターゲットコントラクトのコードが現在のコントラクトにコピーされて実行されるのと同等)
コントラクトの作成#
コントラクトを作成する主な方法はいくつかあります:
- 直接デプロイ
contractA = new contractA(....)
- ファクトリパターン、ファクトリコントラクトを通じて操作し、類似のコントラクトをバッチで作成するのに役立ち、追加のロジックや制限を実現できる
contract ContractFactory {
function createContract(uint _value) public returns (address) {
SimpleContract newContract = new SimpleContract(_value);
return address(newContract);
}
}
- create2 オペコード:コントラクトアドレスを事前に予測できるため、遅延デプロイなどのより複雑なデプロイ戦略を実現できる
contract Create2Factory {
function deployContract(uint _salt, uint _value) public returns (address){
return address(new SimpleContract{salt: bytes32(_salt)}(_value));
}
function predictAddress(uint _salt, uint _value) public view returns (address) {
bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(_value));
bytes32 hash = keccak256(
abi.encodePacked(
bytes1(0xff),
address(this),
_salt,
keccak256(bytecode)
)
);
return address(uint160(uint(hash)));
}
}
- 最小プロキシパターン(EIP-1167):非常に軽量なプロキシコントラクトを作成し、既にデプロイされた実装コントラクトを指し示すことで、ガスを大幅に節約できる
contract MinimalProxy {
function clone(address target) external returns (address result) {
// ターゲットコントラクトのアドレスをbytes20に変換
bytes20 targetBytes = bytes20(target);
// 最小プロキシのバイトコード
bytes memory minimalProxyCode = abi.encodePacked(
hex"3d602d80600a3d3981f3363d3d373d3d3d363d73",
targetBytes,
hex"5af43d82803e903d91602b57fd5bf3"
);
// createを使用して新しいプロキシコントラクトをデプロイ
assembly {
result := create(0, add(minimalProxyCode, 0x20), mload(minimalProxyCode))
}
}
}
- プロキシパターン(アップグレード可能なコントラクト):コントラクトアドレスを変更せずにコントラクトロジックを更新できるアップグレード可能なコントラクトを作成し、長期的なメンテナンスと更新が必要な複雑なシステムに適している
contract UpgradeableContract is Initializable {
uint public value;
function initialize(uint _value) public initializer {
value = _value;
}
}
contract ProxyFactory {
function deployProxy(address _logic, address _admin, bytes memory _data) public returns (address) {
TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(_logic, _admin, _data);
return address(proxy);
}
}
コントラクトのアップグレードにおける注意点#
変数の問題#
-
アップグレードコントラクト時に変数を安易に変更しないこと、型や定義順序など、新しく追加したコントラクト変数は、末尾に
bytes32(uint(keccak256("eip1967.proxy.implementation")) - 1)
を追加して、スロットを衝突しない位置に指定することができる -
コントラクト内の変数は
initialize
関数内で初期化される必要があり、コンストラクタで初期化してはいけない。例えば
contract MyContract {
uint256 public hasInitialValue = 42;
}
は次のように変更する必要がある:
contract MyContract is Initializable {
uint256 public hasInitialValue;
function initialize() public initializer {
hasInitialValue = 42; // 初期値を初期化関数で設定
}
}
コンストラクタの問題#
- 実装コントラクトが直接初期化されるのを防ぐために、プロキシパターンでは、初期化関数を呼び出せるのはプロキシコントラクトのみで、実装コントラクト自体ではないようにする必要がある。通常、コンストラクタの代わりに
initialize
という普通の関数を定義するが、普通の関数は再度呼び出すことができるため、この関数が一度だけ呼び出されることを保証する必要がある。 - 親コントラクトを継承している場合、親コントラクトの初期化関数も初期化時に一度だけ呼び出されることを保証する必要がある。
- コンストラクタ内で
disableInitializers
関数を呼び出すことができる:constructor() { _disableInitializers();}
その目的は:
- 初期化コンストラクタを無効にし、悪意のある攻撃者が実装コントラクトの初期化関数を直接呼び出すのを防ぎ、状態が意図せずまたは悪意を持って変更されるのを防ぐ
- アップグレード中に、新しい実装コントラクトは初期化されるべきではない。なぜなら、それは以前のバージョンの状態を継承するからである。初期化器を無効にすることで、アップグレード中にコントラクトが意図せず再初期化されるのを防ぐ
selfdestruct の問題#
プロキシコントラクトを通じて、底層のロジックコントラクトと直接やり取りすることはなく、悪意のある行為者がロジックコントラクトに直接トランザクションを送信しても問題ない。なぜなら、ストレージ状態の資産はプロキシコントラクトによって管理されており、底層のロジックコントラクトのストレージ状態の変更はプロキシコントラクトに影響を与えない。しかし、注意が必要な点がある:
もしロジックコントラクトが selfdestruct
操作をトリガーした場合、ロジックコントラクトは破棄され、プロキシコントラクトは引き続き使用できなくなる。なぜなら、 delegatecall
は既に破棄されたコントラクトを呼び出すことができないからである。
同様に、ロジックコントラクトに外部から制御される delegatecall
が含まれている場合、これは悪用される可能性がある。攻撃者はこの delegatecall
を selfdestruct
を含む悪意のあるコントラクトを指すように仕向けることができる。 delegatecall
によって呼び出された悪意のあるコントラクトの selfdestruct
はロジックコントラクトのコンテキストで実行され、実際にはそれを呼び出したコントラクト(この場合はロジックコントラクト)を破棄することになる。
例を挙げると:
// 攻撃者は implementation を MaliciousContract のアドレスに設定し、delegateCall 関数を呼び出して destroy 関数の呼び出しデータを渡すことができる
// これにより LogicContract が MaliciousContract の destroy 関数を実行し、LogicContract 自身を破棄する
contract LogicContract {
address public implementation;
function setImplementation(address _impl) public {
implementation = _impl;
}
function delegateCall(bytes memory _data) public {
(bool success, ) = implementation.delegatecall(_data);
require(success, "Delegatecall failed");
}
}
contract MaliciousContract {
function destroy() public {
selfdestruct(payable(msg.sender));
}
}
したがって、ロジックコントラクト内で selfdestruct
または delegatecall
を使用することを禁止する必要がある。 イーサリアムコミュニティは selfdestruct
を完全に削除する可能性について議論している
透明プロキシと UUPS プロキシ#
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
// シンプルなアップグレード可能なコントラクトで、管理者はアップグレード関数を通じてロジックコントラクトのアドレスを変更し、コントラクトのロジックを変更できる。
contract SimpleUpgrade {
address public implementation; // ロジックコントラクトのアドレス
address public admin; // adminアドレス
string public words; // 文字列、ロジックコントラクトの関数を通じて変更可能
// コンストラクタ、adminとロジックコントラクトのアドレスを初期化
constructor(address _implementation) {
admin = msg.sender;
implementation = _implementation;
}
// fallback関数、呼び出しをロジックコントラクトに委任
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
// アップグレード関数、ロジックコントラクトのアドレスを変更、adminのみが呼び出せる
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
これは最もシンプルなアップグレード可能なプロキシコントラクトで、 delegatecall
を通じてすべての呼び出しをロジックコントラクトに委任し、同時に upgrade()
関数を定義してコントラクトのプロキシアップグレードを実現している。しかし、ここには問題がある。 delegatecall
を通じて呼び出される引数はすべて関数セレクタ(selector)であり、これは関数シグネチャのハッシュの最初の 4 バイトであるため、こうした衝突を避けるためのメカニズムが必要であり、これが透明プロキシと UUPS プロキシの登場を促す。
透明プロキシ#
透明プロキシのロジックは非常にシンプルである:管理者は「関数セレクタの衝突」により、ロジックコントラクトの関数を呼び出す際に誤ってプロキシコントラクトのアップグレード関数を呼び出す可能性がある。したがって、管理者の権限を制限し、ロジックコントラクトの関数を呼び出させないようにすれば、衝突を解決できる:
- 管理者はツールとして機能し、コントラクトのアップグレードのためにプロキシコントラクトのアップグレード関数のみを呼び出すことができ、コールバック関数を通じてロジックコントラクトを呼び出すことはできない。
- 他のユーザーはアップグレード関数を呼び出すことはできないが、ロジックコントラクトの関数を呼び出すことができる。
OpenZeppelin の実装 TransparentUpgradeableProxy を参考にすることができる。
UUPS プロキシ#
透明プロキシのロジックはシンプルだが、もう一つの問題がある。ユーザーが関数を呼び出すたびに、管理者であるかどうかのチェックが追加され、より多くのガスを消費する。これが別のソリューションである UUPS プロキシ
の登場を促す。
UUPS(universal upgradeable proxy standard、汎用アップグレード可能プロキシ)は、アップグレード関数をロジックコントラクト内に配置する。これにより、アップグレード関数と他の関数に「セレクタの衝突」が存在する場合、コンパイル時にエラーが発生する。
参考リンク: UUPS
まとめ#
通常のアップグレード可能コントラクト、透明プロキシ、UUPS の違い:
標準 | アップグレード関数がある場所 | 「セレクタの衝突」があるか | 欠点 |
---|---|---|---|
アップグレード可能プロキシ | プロキシコントラクト | ある | セレクタの衝突 |
透明プロキシ | プロキシコントラクト | ない | ガスがかかる |
UUPS | ロジックコントラクト | ない | より複雑 |