Appearance
L3-7: Proxy Contracts(プロキシコントラクト)
1. 問題
スマートコントラクトは一度ブロックチェーンにデプロイされると、バイトコードは永久的に変更不可能です。これはトラストレス性(ルールが変わらないという確信)に必要ですが、深刻な実用上の課題ももたらします。セキュリティバグが修正できず、機能のアップグレードもできず、そして重要なソフトウェアはすべて反復的な改善を必要とします。コントラクトのアドレスとストレージ状態(残高、ユーザーデータ、認可など)を壊さずに、コントラクトのロジックをどのようにアップグレードすればよいでしょうか?
プロキシパターン(Proxy Pattern)はこの問題をエレガントに解決します。コントラクトをプロキシコントラクト(Proxy)とロジックコントラクト(Logic/Implementation)に分割します。プロキシはすべての状態とアドレスを保持しますが、delegatecall を通じて関数呼び出しをロジックコントラクトに転送します。アップグレード時にはロジックコントラクトのアドレスを置き換えるだけで、プロキシのアドレスとストレージはそのまま維持されます。ただし、このパターンには3つの主要なバリエーション(UUPS、Transparent、Beacon)が導入され、それぞれがガス効率、デプロイコスト、ストレージ安全性において異なるトレードオフを持ちます。
2. 理由
アップグレード可能性は、プロフェッショナルな Solidity 開発において最も重要なアーキテクチャ上の決定の一つです。ほぼすべての DeFi プロトコル(Uniswap、Aave、MakerDAO)が何らかの形のプロキシパターンを使用しています。ERC-1967 はストレージスロットの位置を標準化し、プロキシとロジックコントラクト間のストレージ衝突を回避します。プロキシコントラクトを理解することは、単なる技術要件ではなく、Solidity コンパイラが内部でどのようにストレージを割り当てるか、delegatecall が実行コンテキストをどのように変更するか、そしてなぜコンストラクタがプロキシパターンで使用できないかを深く理解するための演習でもあります。
プロキシのアップグレードにはガバナンスの問題も伴います。誰がコントラクトをアップグレードする権限を持つのか?DAO 投票、タイムロック、それとも単一の管理者か?誤ったアップグレードは壊滅的な結果をもたらす可能性があります。最近の Compound のガバナンス脆弱性は、アップグレード後に不正な報酬分配ロジックが導入されたことが原因でした。したがって、プロキシのセキュリティモデルとアップグレード権限管理を理解することは、アップグレード可能なコントラクトをデプロイする前提条件です。
3. 解決策
ProxyContracts.sol は UUPS(Universal Upgradeable Proxy Standard)プロキシパターンを実装しており、3つの主要なコントラクトで構成されています。
ERC1967Proxy(プロキシコントラクト):ユーザーが対話するコントラクトです。コンストラクタ内で、ERC-1967 で指定されたストレージスロットにロジックコントラクトのアドレスをオンチェーンで書き込みます。
IMPLEMENTATION_SLOT = keccak256("eip1967.proxy.implementation") - 1ADMIN_SLOT = keccak256("eip1967.proxy.admin") - 1
fallback() 関数は、インラインアセンブリを使用して任意の呼び出しをロジックコントラクトに delegatecall します。calldatacopy で calldata をコピーし、delegatecall でプロキシのコンテキストでロジックコードを実行し、returndatacopy + return で結果を返します。
UUPSLogic_V1(ロジックコントラクト V1):ビジネスロジック(setValue()、getValue())と UUPS アップグレード関数(upgradeTo())を含みます。重要なポイントは、upgradeTo() がロジックコントラクト内に存在し(プロキシ内ではなく)、sstore(IMPLEMENTATION_SLOT, newImplementation) を通じてプロキシのストレージに直接書き込むことです。initialize() 関数がコンストラクタの代わりとなります。プロキシのコンストラクタはロジックコントラクトに delegatecall しないため、初期化は別途初期化呼び出しによって行う必要があります。
UUPSLogic_V2(ロジックコントラクト V2):V1 を継承し、V1 のストレージの末尾に新しい変数(string public name)を追加します。ポイントはストレージレイアウトの互換性です。新しい変数は追加(appending)によってのみ追加し、挿入(inserting)してはいけません。upgradeTo() を継承しているため、アップグレードロジックを再実装する必要はありません。
3つのプロキシパターンの比較:
- UUPS:アップグレードロジックがロジックコントラクト内にあり、ガス代が最も低い(プロキシが
msg.senderが admin かどうかをチェックする必要がない)が、アップグレード関数が削除されるリスクがある - Transparent Proxy:アップグレードロジックがプロキシコントラクト内にあり、プロキシが
msg.senderが admin かどうかをチェックして呼び出しの転送かアップグレードの実行かを決定する。毎回の呼び出しに追加の admin チェックのオーバーヘッドが発生する - Beacon Proxy:複数のプロキシが1つの Beacon コントラクトを共有し、Beacon がロジックコントラクトのアドレスを保持する。Beacon をアップグレードすればすべてのプロキシがアップグレードされ、デプロイコストが最も低いが中央集権化のリスクがある
参考ソースファイル:src/level3/ProxyContracts.sol
4. 遭遇した落とし穴
- ストレージレイアウトの衝突(Storage Collision):V1 から V2 へのアップグレード時に、V2 が途中に新しい変数を挿入すると、既存の変数がシフトして誤ったデータを読み取ったり、以前の変数を上書きしたりする
- constructor vs initializer:ロジックコントラクトでコンストラクタを使用して状態を初期化する。Solidity のコンパイルは通るが、コンストラクタはロジックコントラクトのデプロイ時に実行され、プロキシのコンテキストでは有効にならない
initialize()が複数回呼び出し可能:保護機能(OpenZeppelin のinitializer修飾子など)がない場合、悪意のある行為者がinitialize()を複数回呼び出してコントラクトの状態をリセットできる- アップグレード関数の上書き:UUPS において、V2 が
upgradeTo()を正しく継承または再定義しない場合、アップグレード関数が意図せず上書きされ、コントラクトが永久にアップグレード不可になる可能性がある selfdestructと delegatecall:ロジックコントラクトがselfdestructを含む場合、実行時に破壊されるのはプロキシコントラクトのコードである- 関数セレクタの衝突:プロキシコントラクトの admin 関数とロジックコントラクトの通常の関数が同じ4バイトセレクタを持つ可能性があり、予期しない動作を引き起こす
5. 落とし穴の原因
ストレージレイアウトの衝突は、プロキシのアップグレードにおいて最も影響が大きく、かつ最も見つけにくい問題です。Solidity コンパイラは変数の宣言順にストレージスロットを割り当てます。V1 では owner がスロット0、value がスロット1にあります。V2 が string public name; address public owner; uint256 public value; と宣言すると、name がスロット0に割り当てられ、owner のデータを上書きします。正しい方法は追加です:address public owner; uint256 public value; string public name;(name はスロット2を取得)。
さらに見つけにくいのは継承チェーン内のストレージレイアウトです。V1 が contract A を継承し(A がスロット0-2を使用)、V1 がスロット3-4を使用する場合、V2 は完全に同じ継承順序を維持しなければならず、新しく継承したコントラクトの変数はすべての既存変数の後に追加するだけにしなければなりません。OpenZeppelin の @openzeppelin/upgrades プラグインは、コンパイラが生成するストレージレイアウト JSON を使用してアップグレードの互換性を検証します。
initialize() に保護がない理由は、Solidity のコンストラクタが delegatecall コンテキストで実行されないためです。initialize() は手動で呼び出され、関数内に initializer 修飾子(再呼び出しを防ぐフラグを設定する)を追加しない限り、誰でもコントラクトを再初期化できます。OpenZeppelin の Initializable コントラクトは、内部の _initialized フラグを通じてこの問題を処理します。
6. 落とし穴の解決方法
ストレージレイアウトの安全性:追加の原則を厳密に守ります。OpenZeppelin のストレージギャップパターンを使用します:
solidity
contract UUPSLogic_V1 {
// ... ビジネス変数 ...
uint256[50] private __gap; // 将来のアップグレード用に50スロットを予約
}
V2 は新しい変数が占めるスロット数を __gap から差し引き、常に総スロット数を一定に保ちます。
initialize の保護:V1 に一度限りの初期化チェックを追加します:
solidity
function initialize(address _owner) public {
if (owner != address(0)) revert AlreadyInitialized();
owner = _owner;
}
OpenZeppelin の initializer 修飾子を使用すると、より安全な保護を提供します(ビット操作で初期化状態を追跡)。
関数セレクタの衝突:Transparent Proxy は admin 呼び出しを別のコードパスにルーティングすることで解決します。msg.sender == admin の場合、呼び出しはプロキシ内で直接処理されます(転送されません)。これにより、admin 関数(upgradeTo など)とロジックコントラクト関数のセレクタ衝突を回避します。
インラインアセンブリの安全性:ERC1967Proxy の fallback() 内で delegatecall の後に戻り値とデータ長をチェックします。returndatasize() により有効な戻りデータのみがコピーされます。ETH を受け取る receive() には、ロジックコントラクトに転送するのではなく、独立した処理を提供します。
7. 技術ポイント
| 技術ポイント | 説明 |
|---|---|
| delegatecall | プロキシのコンテキストでロジックコントラクトのコードを実行し、ストレージ操作はプロキシアドレス上で行われる |
| ERC-1967 | 標準化されたストレージスロット:IMPLEMENTATION_SLOT と ADMIN_SLOT |
| UUPS | アップグレードロジックがロジックコントラクト内にあり、ガス代が最も低い |
| Transparent Proxy | アップグレードロジックがプロキシ内にあり、毎回の呼び出しに admin チェックがある |
| Beacon Proxy | 複数プロキシが Beacon を共有し、すべてのプロキシを一括アップグレード |
| ストレージレイアウト互換性 | 新しい変数は追加のみ可能で、挿入や削除は不可 |
| initializer | コンストラクタの代わり。再呼び出し防止が必要 |
sstore vs 変数代入 | アセンブリを使用して ERC-1967 スロットに直接書き込み、通常のストレージ領域を占有しない |
| fallback() アセンブリ | calldatacopy → delegatecall → returndatacopy → return/revert |