取り扱う話題
Ethereum Virtual Machineは、Ethereumブロックチェーン上でスマートコントラクトを実行する環境です。本記事では、Ethereum Virtual Machineにおけるプロキシパターンを理解する上で重要なパターンの一つであるTransparent Proxy Patternについて、具体的なメリットやデメリットを含めて詳しく解説します。
前提の話題の簡単な説明
今回は↓この記事の内容を前提にしています。
以下に簡単に用語を説明します
Ethereum Virtual Machine
Ethereum Virtual Machineは、Ethereumブロックチェーン上でスマートコントラクトを実行するための環境です。
スマートコントラクト
スマートコントラクトは、ブロックチェーン上で自動的に実行される契約であり、プログラムで記述されており、改ざんや停止が事実上不可能です。
プロキシパターン
(例外はありますが)スマートコントラクトは一度デプロイしたら書き換えることが困難です。この制約を迂回するのがプロキシパターンです。これらは何種類かありますが、本記事ではTransparent Proxy Patternについて理解することを目標とします。
DELEGATECALL
DELEGATECALL
は、Ethereum Virtual Machineのオペコードの一つで、呼び出し元のコントラクトのコンテキストを保持したまま、別のコントラクトのコードを実行します。これにより、プロキシパターンでのスマートコントラクトの柔軟なアップグレードが可能となります。
Ethereum Virtual Machineの全体像
スマートコントラクトは、ブロックチェーン上で自動的に実行される契約であり、プログラムで記述されているため、改ざんや停止が事実上不可能です。Ethereumなどのブロックチェーンで広く利用されています。Transparent Proxy Patternでは、スマートコントラクトを2通りに分けて使います。
ロジック
ロジックは、本当にやりたいことを純粋に記述したスマートコントラクトです。多くの場合で、Transparent Proxy Patternにおいては、更新しない前提であれば単独でも十分に機能します。
プロキシ
プロキシは、ロジックをDELEGATECALLし、ロジックのアドレスを記憶したり更新する役割を持ちます。このアドレスの記憶と更新に関する処理はプロキシに記述されています。さらに、プロキシはストレージの役割も担っています。
実行の流れ
以下のシーケンス図は、デプロイ、転送、更新の各プロセスを示しています。これにより、Transparent Proxy Patternの実行フローが視覚的に理解しやすくなります。
具体的な実装としては↓この2つがわかりやすいと思いました。これらはSolidityで書かれています。
今回は下記の最低限まで機能を削って簡単にしたプロキシを使って説明したいと思います。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
contract Counter{
address impl;
// デプロイするときに1度だけ実行される関数
constructor(address _impl){
// アドレスの初期値を設定する
impl=_impl;
}
// ロジックのアドレスを更新する関数
function upgrade(address _new_impl)external{
// ロジックのアドレスを更新する
impl=_new_impl;
}
fallback()external payable{
// アドレスを得ておく
address implementation=impl;
assembly{
// 呼び出しをDELEGATECALLで転送する
calldatacopy(0,0,calldatasize())
let result:=delegatecall(gas(),implementation,0,calldatasize(),0,0)
// 結果を取り出す
returndatacopy(0,0,returndatasize())
// 結果を返す
switch result
case 0{
revert(0,returndatasize())
}
default{
return(0,returndatasize())
}
}
}
}
デプロイ
一般にデプロイする時の挙動はコンストラクタに記述します。それではプロキシのコンストラクタの例を見ていきます。
// デプロイするときに1度だけ実行される関数
constructor(address _impl){
// アドレスの初期値を設定する
impl=_impl;
}
今回は取り扱いませんが、実用上はEIP-1967で言及されたストレージ領域に格納するものがよく見受けられます。
では最初のロジックがデプロイされてこのコンストラクタが呼び出される過程を見てみます。
流れを説明します。
- 作者はロジックv1をデプロイします
- 作者はプロキシをデプロイします
- 最初はロジックv1に転送するよう初期化します
プロキシはコンストラクタ、すなわちデプロイする時に1度だけ実行される関数を通じて、最初のロジックのアドレスを記憶します。
転送
実際の呼び出しを受け取る部分について見ていきます。該当する部分として例えばこのような実装が考えられます。
// プロキシ側で実装した関数以外が呼ばれたときにこの関数が実行される
fallback()external payable{
// アドレスを得ておく
address impl=implementation();
assembly{
// 呼び出しをDELEGATECALLで転送する
calldatacopy(0,0,calldatasize())
let result:=delegatecall(gas(),impl,0,calldatasize(),0,0)
// 結果を取り出す
returndatacopy(0,0,returndatasize())
// 結果を返す
switch result
case 0{
revert(0,returndatasize())
}
default{
return(0,returndatasize())
}
}
}
ではこのプロキシコントラクトが呼び出された時の挙動を見ていきます。
流れを説明します。
- ユーザーはロジックv1の関数を呼び出すつもりでプロキシを呼び出す
- プロキシはロジックv1を
DELEGATECALL
する- パラメータもそのまま転送する
- ロジックv1に記述した処理が実行されて結果が返される
- この結果をそのままプロキシを実行した結果として返す
更新
Transparent Proxy Patternにおいて、プロキシはロジックのアドレスを更新する役割を持ちます。それでは、その更新する部分を見ていきます。
実用上はこの関数が呼び出されたときには、正当な権限を有していることをチェックするのが一般的です。しかし、本記事の趣旨を逸脱するため今回は省略します。
// ロジックのアドレスを更新する関数
function upgrade(address _new_impl)external{
// ロジックのアドレスを更新する
impl=_new_impl;
}
この関数が呼ばれるのは新しいロジックのアドレスが確定した後です。この新しいロジックのアドレスがプロキシのストレージに書き込まれることによって、今後このプロキシは関数の呼び出しを新しいロジックに転送するようになります。
では、古いロジックをロジックv1、新しいものをロジックv2として表したずを見てみます。
流れを説明します。
- 管理者はロジックv2をデプロイしておく
- プロキシを呼び出してロジックのアドレスを変更する
- 今後の処理はロジックv2のものが実行されるようになる
メリット・デメリット
ご覧の通り、このパターンはとてもかんたんです。おそらくProxy Patternの実例を理解する上で1番簡単なパターンなのではないでしょうか。
一方で、ロジックのアドレスを更新する部分は更新できないです。
例えば、プロジェクトの初期段階ではロジックの変更が頻繁に必要な場合、このパターンは非常に有効です。一方で、ロジックのアドレスを更新する部分にバグがあると修正が困難になります。
参考
Transparent Proxy Patternの詳細については、OpenZeppelinのブログ記事を参照してください。
また、Ethereumの公式ドキュメントではEVMのオペコードや詳細な仕様について学ぶことができます。
Proxy Patternの一般論についてはこの記事で学ぶことができます。
具体的でわかりやすい実装としてこのようなものを紹介しました。
一般的なアドレス用のストレージについてはEIP-1967に記されています。