スマートコントラクトのアップグレード方法まとめ ~不変性と柔軟性の狭間で実現する安全なアップデート手法~
スマートコントラクトのアップグレード方法まとめ ~不変性と柔軟性の狭間で実現する安全なアップデート手法~
この記事は以下の論文をもとに書かれています。
TL;DR
- スマートコントラクトは一度デプロイすると不変であるという神話があるが、実際には様々なアップグレード手法が存在する
- アップグレード手法は「フルアップグレード」と「パーシャルアップグレード」の2つに大別される
- プロキシパターン、ダイヤモンドパターンなどの手法はそれぞれ複雑性、柔軟性、効率性、セキュリティの観点で長所短所がある
- アップグレード可能性とガバナンスは密接に関連しており、適切なガバナンスモデルの選択が重要
- プロジェクトの要件に合った適切なアップグレード戦略を選択することが成功の鍵
はじめに:不変性の神話に挑む
ブロックチェーンの世界では、「一度デプロイされたスマートコントラクトは変更できない」というのが一般的な認識である。確かに、Ethereumなどのブロックチェーンプラットフォームでは、コントラクトコードはブロックチェーン上に永続的に記録され、その不変性がセキュリティと信頼性の基盤となっている。
しかし、現実のソフトウェア開発において、バグの修正や機能の追加・改善の必要性は常に存在する。2016年のDAOハックでは、不変性の制約がいかに大きな問題となりうるかを痛感させられた。約6000万ドル相当のETHが奪われ、最終的にはEthereumのハードフォークという極端な対応が必要となったのだ。
では、不変性と柔軟性という相反する要求をどう両立させるべきだろうか?実は、スマートコントラクトの「アップグレード可能性」を実現するための様々な手法が研究・開発されている。本記事では、それらの手法を体系的に整理し、比較検討する。
スマートコントラクトの基本構成要素
アップグレード手法を理解するには、まずスマートコントラクトの基本構成要素を把握する必要がある。
- アドレス: ブロックチェーン上での一意の識別子
- ロジック: コントラクトの機能を定義するプログラムコード
- ストレージ: コントラクトの状態を保持するデータ
- 実行フロー: トランザクションの処理方法
アップグレード手法は、これら4つの要素をどのように取り扱うかによって特徴づけられる。
アップグレード手法の分類
アップグレード手法は大きく「フルアップグレード」と「パーシャルアップグレード」の2つに分類できる。
フルアップグレードアプローチ
1. コントラクト移行
最も単純なアプローチは、新しいコントラクトを別のアドレスにデプロイし、古いコントラクトから状態を移行する方法だ。
contract OldContract {
uint256 public value;
function setValue(uint256 _value) public {
value = _value;
}
function migrateToNew(address newContract) public {
NewContract(newContract).initialize(value);
// 自己破壊して古いコントラクトを無効化することも可能
selfdestruct(payable(msg.sender));
}
}
contract NewContract {
uint256 public value;
bool public initialized;
function initialize(uint256 _value) public {
require(!initialized, "Already initialized");
value = _value;
initialized = true;
}
// 新機能の追加
function doubleValue() public {
value = value * 2;
}
}
メリット:
- 実装が単純
- 完全に新しいコードに置き換え可能
デメリット:
- アドレスが変更されるため、依存するコントラクトやdAppsの更新が必要
- 大量のデータを持つコントラクトでは移行コストが高い
2. メタモルフィックコントラクト(CREATE2)
Ethereum の CREATE2 オペコードを使用すると、同じアドレスに新しいコントラクトをデプロイすることが可能になる。古いコントラクトを selfdestruct した後、同じアドレスに新しいコントラクトをデプロイする手法だ。
contract Factory {
function deploy(bytes32 salt, bytes memory bytecode) public returns (address) {
address addr;
assembly {
addr := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
}
return addr;
}
}
メリット:
- アドレスが変わらないため、外部依存関係を更新する必要がない
- 完全に新しいコードにアップグレード可能
デメリット:
- selfdestruct を使用するため、状態はすべて失われる
- 複雑なデプロイメントプロセス
パーシャルアップグレードアプローチ
1. 2モジュールアプローチ(プロキシパターン)
最も一般的なアップグレード手法の一つが、ロジックと状態を分離するプロキシパターンだ。ユーザーはプロキシコントラクトとやり取りし、プロキシは実際の処理を実装コントラクトに委任する。
contract Proxy {
address public implementation;
constructor(address _implementation) {
implementation = _implementation;
}
function upgrade(address newImplementation) public {
implementation = newImplementation;
}
fallback() external payable {
address _impl = implementation;
assembly {
// delegatecall を使って実装コントラクトにコールを委任
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) }
}
}
}
このパターンにはいくつかのバリエーションがある:
- Basic Proxy: 上記の例のような基本的なプロキシパターン
- UUPS (Universal Upgradeable Proxy Standard): アップグレードロジックを実装コントラクト側に置くパターン
- EIP-1967: ストレージ衝突を避けるための標準化されたストレージスロット
- Transparent Proxy: 管理者とユーザーのアクセスを分離するパターン
メリット:
- アドレスが変わらない
- 状態を維持したままロジックをアップグレード可能
デメリット:
- ストレージレイアウトの変更が困難
- delegatecall の使用によるセキュリティリスク
2. マルチモジュールアプローチ
より柔軟なアップグレードを可能にするのがマルチモジュールアプローチだ。ロジックを複数のモジュールに分割し、必要なモジュールだけを交換できる。
ダイヤモンドパターン(EIP-2535)
複数の「ファセット」と呼ばれる実装コントラクトを一つのダイヤモンドプロキシから呼び出せるようにするパターン。
contract Diamond {
// 関数セレクタから実装アドレスへのマッピング
mapping(bytes4 => address) public facets;
function diamondCut(bytes4[] memory selectors, address facet) public {
for (uint i = 0; i < selectors.length; i++) {
facets[selectors[i]] = facet;
}
}
fallback() external payable {
bytes4 selector = msg.sig;
address facet = facets[selector];
require(facet != address(0), "Function not found");
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
let result := delegatecall(gas(), facet, ptr, calldatasize(), 0, 0)
let size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
}
メリット:
- 非常に柔軟なアップグレード(関数単位で置き換え可能)
- 大規模システムをモジュール化できる
デメリット:
- 複雑な実装とガバナンス
- 潜在的なセキュリティリスクが増加
アップグレード手法の評価
各アップグレード手法は以下の観点から評価できる:
1. 複雑性
2. 柔軟性
3. 効率性
ガス効率は実装方法や具体的なユースケースによって異なるが、一般的には:
- デプロイコスト: ダイヤモンドパターン > プロキシパターン > コントラクト移行
- 実行コスト: ダイヤモンド ≈ プロキシ > 直接呼び出し(delegatecallのオーバーヘッド)
- アップグレードコスト: コントラクト移行 > ダイヤモンド > プロキシ
4. セキュリティ
すべてのアップグレード手法には潜在的なセキュリティリスクが存在する:
- ストレージ衝突: 実装コントラクトとプロキシコントラクトのストレージレイアウトが衝突するリスク
- 委任呼び出しのリスク: delegatecallはコンテキストを維持するため、適切に実装しないと脆弱性の原因になる
- アップグレード権限の集中: アップグレード権限を持つアドレスが単一障害点となる
ガバナンスと責任あるアップグレード
アップグレード可能性は技術的な問題だけでなく、ガバナンスの問題でもある。誰がアップグレードを決定し、どのようにして承認するかは、分散型システムの信頼性に直接影響する。
ガバナンスモデル
-
集中型ガバナンス
- 開発チームや財団が単独でアップグレード権限を持つ
- 迅速な意思決定が可能だが、信頼が必要
-
タイムロック
- アップグレードが実行されるまでの待機期間を設ける
- ユーザーに「退出の時間」を与える
-
マルチシグウォレット
- 複数の当事者の承認を必要とする
- 単一障害点のリスクを軽減
-
分散型自律組織(DAO)
- トークン保有者による投票でアップグレードを決定
- 最も分散化されたアプローチだが、調整コストが高い
ベストプラクティス
実際のプロジェクトでアップグレード可能性を実装する際のベストプラクティスをいくつか紹介する:
-
適切なアップグレード手法の選択
- 小規模な実験的プロジェクト → シンプルなプロキシパターン
- 大規模な本番システム → ダイヤモンドパターンやハイブリッドアプローチ
-
セキュリティ対策
- ストレージレイアウトの厳格な管理
- アップグレード前の徹底的な監査
- タイムロックやマルチシグの導入
-
透明性の確保
- アップグレード可能性の明示
- アップグレード計画の事前通知
- ソースコードの公開と検証
-
ガバナンスの分散化
- プロジェクトの成熟に伴い、より分散化されたガバナンスへ移行
- 緊急時対応のためのバックドアと、その段階的な廃止
実際のユースケース
実際のプロジェクトがどのようにアップグレード可能性を実装しているか、いくつか例を見てみよう:
-
Uniswap V2 → V3
- 新しいコントラクトを別アドレスにデプロイ(コントラクト移行方式)
- 大規模な機能変更と設計変更のため、完全な再デプロイが選択された
-
OpenZeppelin Upgradeable Contracts
- Transparent Proxyパターンを実装
- 広く採用されている標準的なアップグレード可能なコントラクトのライブラリ
-
Aave
- 初期はUUPSプロキシパターンを採用
- V3では一部の機能にダイヤモンドパターンを導入
-
Compound
- Governor Alphaからブラボーへの移行に2モジュールアプローチを使用
- ガバナンスの進化に合わせたアップグレード戦略
まとめ
スマートコントラクトのアップグレード可能性は、不変性と柔軟性のバランスを取るための重要な概念だ。完璧なアップグレード手法は存在せず、プロジェクトの規模、要件、リスク許容度に応じて適切な手法を選択する必要がある。
技術的な実装だけでなく、ガバナンスモデルの設計も成功の鍵となる。集中型から分散型へと徐々に移行していくアプローチが、多くのプロジェクトで採用されている。
最終的には、「不変性の神話」を超えて、実用的かつ安全なスマートコントラクト開発のためのバランスを見つけることが重要だ。アップグレード可能性は単なる技術的な問題ではなく、分散型システムの信頼性と適応性を両立させるための哲学的な問いでもある。
以上。
Discussion