Yige

Yige

Build

Solidity コントラクトのアップグレード

コントラクトプロキシのアップグレード#

基本的な呼び出し(Call / DelegateCall)#

  • Call:ターゲットコントラクトの関数を呼び出し、ターゲットコントラクトのコンテキストで実行されるが、呼び出し元コントラクトのストレージ状態は変更されない
  • DelegateCall:ターゲットコントラクトの関数を呼び出し、呼び出し元コントラクトのコンテキストで実行され、呼び出し元コントラクトのストレージ状態が変更される

基本的な概念#

  • EVM は状態変数を気にせず、ストレージスロット上で操作を行う。すべての変数の状態変化は、ストレージスロット上の内容の変更に対応しているため、スロットの衝突に注意が必要であり、現在のコントラクトとターゲットコントラクトのスロット配置が衝突してはいけない

  • あるコントラクトがターゲットのスマートコントラクトに delegatecall を行うと、そのコントラクトの環境内でターゲットコントラクトのロジックが実行される(ターゲットコントラクトのコードが現在のコントラクトにコピーされて実行されるのと同等)

コントラクトの作成#

コントラクトを作成する主な方法はいくつかあります:

  1. 直接デプロイ
contractA = new contractA(....)
  1. ファクトリパターン、ファクトリコントラクトを通じて操作し、類似のコントラクトをバッチで作成するのに役立ち、追加のロジックや制限を実現できる
contract ContractFactory {
	function createContract(uint _value) public returns (address) {
		SimpleContract newContract = new SimpleContract(_value);
		return address(newContract);
	}
}
  1. 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)));
	}
}
  1. 最小プロキシパターン(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))
		}
	}
}
  1. プロキシパターン(アップグレード可能なコントラクト):コントラクトアドレスを変更せずにコントラクトロジックを更新できるアップグレード可能なコントラクトを作成し、長期的なメンテナンスと更新が必要な複雑なシステムに適している

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; // 初期値を初期化関数で設定
	}
}

コンストラクタの問題#

  1. 実装コントラクトが直接初期化されるのを防ぐために、プロキシパターンでは、初期化関数を呼び出せるのはプロキシコントラクトのみで、実装コントラクト自体ではないようにする必要がある。通常、コンストラクタの代わりに initialize という普通の関数を定義するが、普通の関数は再度呼び出すことができるため、この関数が一度だけ呼び出されることを保証する必要がある。
  2. 親コントラクトを継承している場合、親コントラクトの初期化関数も初期化時に一度だけ呼び出されることを保証する必要がある。
  3. コンストラクタ内で disableInitializers 関数を呼び出すことができる: constructor() { _disableInitializers();}

その目的は:

  • 初期化コンストラクタを無効にし、悪意のある攻撃者が実装コントラクトの初期化関数を直接呼び出すのを防ぎ、状態が意図せずまたは悪意を持って変更されるのを防ぐ
  • アップグレード中に、新しい実装コントラクトは初期化されるべきではない。なぜなら、それは以前のバージョンの状態を継承するからである。初期化器を無効にすることで、アップグレード中にコントラクトが意図せず再初期化されるのを防ぐ

selfdestruct の問題#

プロキシコントラクトを通じて、底層のロジックコントラクトと直接やり取りすることはなく、悪意のある行為者がロジックコントラクトに直接トランザクションを送信しても問題ない。なぜなら、ストレージ状態の資産はプロキシコントラクトによって管理されており、底層のロジックコントラクトのストレージ状態の変更はプロキシコントラクトに影響を与えない。しかし、注意が必要な点がある:

もしロジックコントラクトが selfdestruct 操作をトリガーした場合、ロジックコントラクトは破棄され、プロキシコントラクトは引き続き使用できなくなる。なぜなら、 delegatecall は既に破棄されたコントラクトを呼び出すことができないからである。

同様に、ロジックコントラクトに外部から制御される delegatecall が含まれている場合、これは悪用される可能性がある。攻撃者はこの delegatecallselfdestruct を含む悪意のあるコントラクトを指すように仕向けることができる。 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ロジックコントラクトないより複雑

参考リンク#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。