イーサリアムネットワークにデプロイされたスマートコントラクトのコードは基本書き直すことはできません。
つまり、Dapps等の土台として機能するコントラクトのプロトコルをアップグレードすることなどが非常に困難になってしまっているのです。
このようなコントラクトのアップグレード問題に対処するために、スマートコントラクトの構成アーキテクチャを工夫することで、ユーザーが同じコントラクトにトランザクションを送信しつつ、コントラクトプロトコルのアップグレードをできるようにする研究開発が複数のプロジェクトで活発に行われています。
そこで、当記事ではアップグレード可能なスマートコントラクトを実現するための具体的なアプローチを解説していきます。また、solidityとEVMの基本的な内容を前提としていきます。
前提知識
まずは、スマートコントラクトのアップグレードを理解するうえで必要となる前提知識の解説をしていきます。
ここでは、以下のことを紹介していきます。
- Storage slot:コントラクトにおけるストレージ保持のスロットについて
- Delegatecall:呼び出し元コントラクトの文脈で外部コントラクトの関数を呼び出す
- Proxyコントラクト:upgradableコントラクトへ関数処理を届けるコントラクト
ストレージスロット
イーサリアムのスマートコントラクトで定義されたストレージ変数がどのように保持されているか解説していきます。
コントラクトのストレージ変数は順番に下図のような2^256個のスロットに割り当てられていきます。それぞれのスロットは32bytesであり、0のポジションから順番に入っていくイメージです。
スロットには2^256もの膨大な数のポジションがあるので、ほぼ無限大だと考えてOKです。例えば、以下のようなシンプルなコントラクトを考えていきましょう。
1 2 3 4 5 6 7 8 9 10 |
contract StorageTest { uint256 a; // slot 0 uint256[2] b; // slot 1,2 struct Item { uint256 amount; uint256 value; } Item c; // slot 3,4 } |
この場合、aがslot 0に、bの配列のそれぞれの要素がslot 1, slot2に、そしてstructのそれぞれがslot3, slot4に割り当てられることになります。
ちなみに、配列長が決まっていない配列の場合は本来のslotポジションに配列長が保持され、配列長のハッシュ値のポジションから配列内のデータが保持されていきます。
mappingについては、本来のslotポジション(p)はゼロのままでhash(key, p)のslotポジションにそのkeyのvalueが保持されることになります。
Delegatecall
Delegatecallとは、外部コントラクトへの呼び出しに対して呼び出し元のコントラクトの文脈で処理する関数です。ここで言う「コントラクトの文脈」とは、msg.senderやmsg.value、コントラクトのストレージのことなどを指しています。
つまり、通常のcallであれば呼び出し先(call先)のコントラクトの文脈で関数を実行するのに対し、delegatecallの場合は外部コントラクトの関数を自身のコントラクトのストレージ文脈で処理することが可能になります。
例えば、以下のようなコントラクトを考えていきましょう。この例では、コントラクトAがコントラクトBのsetN関数をdelegatecallし、関数を処理しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
contract A { uint public n; address public owner; function delegatecallSetN(address _b, uint _n) public { _b.delegatecall(bytes4(keccak256("setN(uint256)")), _n); } } contract B { uint public n; address public owner; function setN(uint _n) public { n = _n; owner = msg.sender; } } |
delegatecallなので、呼び出されたsetN関数はコントラクトAの文脈で処理されます。つまり、msg.senderはコントラクトAにトランザクションを送信したユーザーのアドレスであり、引数_nはコントラクトAのストレージ変数nにセットされます。
一方、これがdelegatecallではなくcallの場合は、msg.senderはコントラクトA自身のアドレスになり、引数_nはコントラクトBのストレージ変数nにセットされます。
このように外部コントラクトで定義されている関数を呼び出す手段としてのcallとdelegatecallは明確に区別されるべきであり、コントラクトのアップグレードではdelegatecall関数が重要な役割を果たします。
ちなみに、delegatecall関数の第一引数bytes4(keccak256(“setN(uint256)”))は関数シグネチャ(function signature)であり、呼び出す関数を指定するために使われます。詳しくは後述します。
しかし、delegatecall関数を安易に使用してしまうと思わぬ脆弱性の原因となるので注意しなければなりません。特に、コントラクトBのようにdelegatecall先のコントラクトで自身のストレージ変数に基づいたロジックで関数を処理している場合は、注意が必要です。
なぜなら、delegatecallでは呼び出し先の関数ロジックを呼び出し元のストレージ文脈で処理されるので、互いのコントラクトのストレージ構造(storage slotの順番)を一致させる必要があるからです。
例えば、以下のようにコントラクトAの変数nとownerの順番を変えただけのコントラクトCを考えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
contract C { address public owner; // slot 0 uint public n; // slot 1 function delegatecallSetN(address _b, uint _n) public { _b.delegatecall(bytes4(keccak256("setN(uint256)")), _n); } } contract B { uint public n; // slot 0 address public owner; // slot 1 function setN(uint _n) public { n = _n; owner = msg.sender; } } |
つまり、コントラクトAとBではともにuint256型nがslot 0で、address型ownerがslot 1に割り当てられていたので問題なくdelegatecallすることができました。一方、コントラクトCではslot 0にaddress型owner、slot 1にuint256型nが割り当てられてしまっています。
なので、setN関数はslot 0に値をセットする関数であるので、コントラクトCのdelegatecallではuint型の値がslot 0であるaddress型ownerにセットされてしまうことになります。
以上のように、storage slotとdelegatecallのロジックを十分に理解せずに安易にdelegatecall関数を使用してしまうと意図せぬ処理が行われる可能性があります。
ライブラリで定義されている関数はdelegatecallで呼び出されますが、ライブラリはストレージ変数を定義できないコントラクトになるので安全にdelegetecallすることができているのです。
Proxyコントラクト
アップグレード可能なコントラクトの文脈で言うProxyコントラクトとは、ユーザーのトランザクション送信をUpgradableコントラクトへ届ける役割のコントラクトのことを言います。つまり、Proxyコントラクトは上述したdelegatecall関数を実行することになります。
より具体的に言うと、ユーザーはProxyコントラクトへトランザクションを送信し、delegatecallによりUpgradableコントラクトの関数を実行するのです。このProxyコントラクト自体はアップグレードしないので、ユーザーはコントラクトがアップグレードしても常に同一のアドレスに対してトランザクションを送信することが可能になります。
さらに、Proxyコントラクトはロジックコントラクトの任意の関数をdelegatecallする必要があります。つまり、Proxyコントラクトにとってはユーザーがどのような関数を実行するか知る術がないのです。
なので、Proxyコントラクトではfallback関数(関数名がない特別な関数)にdelegatecallを記述しています。Proxyコントラクトで定義されていない関数は、全てfallback関数に定義されている処理を実行するので、delegatecall先の任意の関数をfallback関数を介して処理することができます。
ただ、最後にもうひとつだけ問題点があります。
solidityのデフォルトのdelegatecall関数を使っても「処理が成功したかどうか」のtrue/falseしか返してくれないのです。なので、戻り値に呼び出し先関数の戻り値データを返してもらうためにassemblyで改造したものをProxyコントラクトでは使用します。
Proxyコントラクトで定義されるfallback関数を含むdelegatecallは以下の通りです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function () payable public { address _impl = implementation(); require(_impl != address(0)); assembly { 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) } } } |
inline assembly内の操作をひとつずつ見ていきます。
1 |
let ptr := mload(0x40) |
evmのメモリ上の0x40の位置は、次の空メモリのポインタとなるフリーメモリポインタとなります。なので、mload(0x40)でフリーメモリのアドレスをロードしています。つまり、変数ptrには次の空メモリのポインタとなるアドレスを保持していることになります。
0x80以降がフリーメモリとなっており、メモリをアロケートするために0x40〜0x5fの領域にポインタのアドレスを格納していることになります。
(今回の場合はptrを用いずにメモリの0の位置から上書きしてもOKです。むしろ、0にしておいた方がinline assembly blockの後に操作を行えないので安全っぽい。)
1 |
calldatacopy(ptr, 0, calldatasize) |
このcalldatacopy操作は、calldataをメモリにコピーする操作になります。calldata=msg.dataです。calldatasizeはcalldata領域に格納されたデータサイズとなります。(msg.data.lengthと同じです)
calldata領域の0の位置からcalldatasizeのデータサイズをメモリ上のptrポインタにコピーします。(calldata領域は独自の記憶領域を持っているので「0の位置」は「メモリの0の位置」を指しているわけではありません)
1 |
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0) |
delegatecall opcodeの操作になります。それぞれの引数は以下の通りです。
- gas:この呼び出しのために与えるgas量です。gas opcodeは処理のために利用可能なgas量を返します。
- _impl:delegatecall先のコントラクトアドレス
- ptr:上で定義したメモリポインタ。calldataを適用するため。
- calldatasize:msg.data.lengthと同じのデータサイズ
- 0:delegatecall先コントラクトの関数から返される出力データ。現時点ではこのデータは分からないので使わない。
- 0:出力データのサイズ。これ以降、returndetasize opcodeで使用することができる。
resultにはdelegatecallが成功したら1が、失敗したら0が保持されます。
1 |
let size := returndatasize |
delegatecall先のコントラクトの関数を呼び出したときに返されるデータはreturndata領域という独自の記憶領域に保持されます。
returndatasize opcodeによってreturndata領域に格納されているデータサイズをsize変数に保持します。
1 |
returndatacopy(ptr, 0, size) |
returndatacopy操作は上述のcalldatacopyのように、returndata領域から特定のデータサイズをメモリ上にコピーする操作になります。
ここでは、returndata領域の0の位置から全操作で定義したsizeのデータサイズをメモリ上のptrポインタにコピーします。
1 2 3 |
switch result case 0 { revert(ptr, size) } default { return(ptr, size) } |
resultが0の場合はrevert opcodeを呼び、1の場合はptrポインタ先からsize分のデータサイズを返します。
以上のようなassemblyの操作により、delegatecallによって返されるデータを得ることができました。
前提知識の部分がかなり長くなってしまいましたが、ここまで理解できていればスムーズにProxyパターンでスマートコントラクトをアップグレードする方法を理解することができます。
コントラクトをアップグレードするアプローチ
ここまで解説したstorage slot、delegatecall、Proxyコントラクトを前提知識として実際にどのような方法でアップグレード可能なコントラクトを実現しようとしているのかを見ていきましょう。
upgradableコントラクトパターンは細かく分類分けするとかなり多くのパターンに分けることができますが、ここでは現実的かつ、より柔軟なProxyパターンのふたつのアプローチについて解説していきます。
Key-Value外部ストレージパターン
このパターンは、コントラクトをストレージコントラクトと関数のロジックコントラクトのふたつに分けて、後者のロジックコントラクト部分をアップグレード可能にするという方針になります。
ユーザーはProxyコントラクトにトランザクションを送信し、Proxyコントラクトはロジックコントラクトに対してdelegatecallすることになります。
ロジックコントラクトをアップグレードした場合は、新しいロジックコントラクトのアドレスに対してdelegatecallすればOKです。
このパターン全体で見るとストレージ変数は以下の2つに大きく分類することができます。
- upgradabilityに関する変数(ロジックコントラクトのアドレスやproxyのオーナーアドレス)
- upgradable(ロジック)コントラクトに依存する変数
ロジックコントラクトには、前者のupgradabilityに関するストレージ変数は一切保持しません。また、後者のロジックコントラクトで用いたいストレージ変数も後述するようなKey-Valueペア型でコントラクトに保持します。
それでは、なぜそもそもコントラクトをストレージとロジックに分ける必要があるのでしょうか。それは、delegatecall先のコントラクトが自身のストレージに依存したロジックを組んでいた場合、delegatecall元のストレージ構造と整合性を保つ必要性が出てきてしまうからです。
このKey-Value外部ストレージパターンでは、ロジックに依存するストレージを外部のコントラクトで共有して保持することで整合性を保てるようにしています。
しかし、単にストレージとロジックに分けただけでは、ストレージコントラクトはアップグレードできないのでストレージ変数を追加、変更することができません。つまり、ロジックコントラクトをアップグレードしても以前のバージョンのストレージを使用するしかないのです。これでは、ロジックアップグレードの柔軟性が低下してしまうので、ストレージの保持方法を工夫します。
ストレージコントラクトはProxyコントラクトとロジックコントラクトが継承し、以下のように、変数型ごとにKey-Valueペア(keccak256(変数名) => 変数型)で保持します。
1 2 3 4 5 6 7 8 |
contract EternalStorage { mapping(bytes32 => uint256) internal uintStorage; mapping(bytes32 => string) internal stringStorage; mapping(bytes32 => address) internal addressStorage; mapping(bytes32 => bytes) internal bytesStorage; mapping(bytes32 => bool) internal boolStorage; mapping(bytes32 => int256) internal intStorage; } |
ロジックコントラクトでストレージ変数を使用したい場合は、以下のようなset関数やget関数をロジックコントラクト側で用意します。
1 2 3 4 5 6 7 |
function setNumber(uint _n) public { uintStorage[keccak256("number")] = _n; } function getNumber() public { return uintStorage[keccak256("number")]; } |
これらの関数を使用することで、ロジックコントラクトでもストレージ変数に依存したロジックを組むことができます。
ただ、このパターン特有の制限として新たな変数型を追加することができないということがあげられます。
非構造化ストレージパターン
上記のKey-Value外部ストレージパターンでは、ストレージとロジックでコントラクトを分離して保持することで、ロジックに必要なストレージ構造をProxyコントラクトと共有していました。これにより、ロジックコントラクトに対してProxyコントラクトから安全にdelegatecallをすることができています。
一方、この非構造化ストレージパターンではproxyコントラクトでストレージ変数を保持せずに、アップグレードするコントラクトでは通常通りにストレージとロジックを記述します。
proxyコントラクトがストレージ変数を保持していなければ(より正確にはdelegatecall先のコントラクトのストレージ構造に影響を与えるslotにストレージを保持していなければ)、安全にdelegatecallすることができます。
しかし、ProxyコントラクトにはアップグレードコントラクトのアドレスやProxyオーナーのアドレスなどのupgradabilityに関する変数を保持する必要があります。このようなストレージ変数を普通に保持する場合は、upgradableコントラクトはストレージスロット構造に整合性を保たせるために、これらの変数を保持しているコントラクトを継承する必要性が出てきてしまいます。
これを解消するために非構造化ストレージパターンでは、ストレージをslot 0の位置から保持するのではなく、固定のslot位置にポインタすることで解決しています。
つまり、upgradableコントラクトで保持するストレージ変数はslot 0の位置から割り当てられていくので、それに影響しないslot位置に固定してProxyコントラクトのストレージを保持しているのです。前述にしたようにslotは2^256個あるので被ることはありません。
例えば、Proxyオーナーのアドレスをストレージに保持したい場合は、以下のような固定スロットポジションkeccak256(“zoom.proxy.owner”)に保持します。
1 2 3 4 5 6 7 8 9 10 |
bytes32 private constant proxyOwnerPosition = keccak256("zoom.proxy.owner"); ・・・ function setUpgradeabilityOwner(address newProxyOwner) internal { bytes32 position = proxyOwnerPosition; assembly { sstore(position, newProxyOwner) } } |
constant変数は書き換えができない変数なので、ストレージスロットには保持されません。
これにより、proxyコントラクトが保持しているストレージはupgradableコントラクトに影響を与えないスロットポジションに保持されます。
upgradableコントラクトに保持されているストレージがProxyコントラクトのストレージスロットにも保持されることになります。Proxyコントラクトにはそのストレージスロットに対応するストレージ変数は割り当てられていませんが、delegatecallによって処理される関数は、Proxyコントラクトのストレージ文脈で処理されるので正しく実行されます。upgradableコントラクトの開発者体験的にもユーザー体験的にも、Proxyコントラクトのストレージ構造などを考慮する必要はありません。
コントラクトアップグレードの課題
Proxyパターンにおける関数シグネチャの重複
delegatecallで呼び出される関数は、関数シグネチャにより実行される関数が選択されます。関数シグネチャは例えば以下のような関数と引数のハッシュ値の最初の4bytesになります。
1 2 3 |
bytes4(keccak256("setN(uint256)")) // = bytes4(0x3f7a027040277427e6dadea07fbc60d446e25661a15538585204da7b00ee3a34) // = 0x3f7a0270 |
この4bytesのデータを指定することでコントラクトのEVMコードから呼び出すべき関数を探し出すことになります。具体的には、ユーザーがトランザクションのdata要素に関数シグネチャと引数データを渡すことになります。
もし、delegatecall先コントラクトの関数とProxyコントラクトで定義されている関数の関数シグネチャが重複していた場合、fallback関数が処理されないので、後者の定義関数の方が優先して処理されることになり、意図せぬ動作が起きてしまいます。
例えば、proxyOwner()とclash550254402()の関数シグネチャは0x025313a2で一致します。
解決策としては、Proxyオーナー以外からの処理は関数シグネチャが重複してもdelegatecallするようにします。例えば、Proxyコントラクトの関数に与えられるonlyProxyOwner修飾子を以下のように変更することで、実行者がProxyオーナーの場合はその関数を実行し、それ以外のユーザーの場合はfallback関数が実行されるようにすることで上記の問題を回避することができます。
1 2 3 4 5 6 7 |
modifier ifProxyOwner() { if (msg.sender == _proxyOwner()) { _; } else { _fallback(); } } |
ガバナンス問題
ここまでの内容は、「コントラクトをアップグレードするための方法」について解説していきました。しかし、実際には「コントラクトのアップグレードを実行するまでの方法」も大きな課題となります。
つまり、単純にonlyOwner修飾子でownerのみがアップグレード可能にしてしまうと、そのコントラクトを利用しているユーザーにとって不利なアップグレードがowner権限で勝手に行えてしまうことを意味します。これは、プロトコルとして分散性に欠けていると言え、改善すべきことです。
改善策としては、例えば特定の一人のユーザーのみに集中的に権限を与えるのではなく、マルチシグで複数のユーザーの合意をアップグレードに必要とすることで集権性を緩和することができます。
さらに、理想的にはユーザーによる投票でアップグレードを可決するようにするとアップグレードにおける分散性が高まります。例えば、ガバナンストークンを用意し、ガバナンストークンを多く保持しているほど議決権が多く得られるようにします。そうすることで、プロトコルの価値を上昇させることがガバナンストークンの価格上昇につながり、プロトコルをよりよくアップグレードすることへのインセンティブとすることができます。
他にも、Aragon osのように厳密なパーミッション管理を行えるプロトコルを用意し、そのサービス内で特定の役割を委任されている管理者がコントラクトをアップグレードするような方法も考えられます。
以上のようにただのオーナー権限ではなく、コントラクトに関しても分散的なガバナンスが求められます。
以上が、スマートコントラクトのアップグレードを可能にするための具体的なアーキテクチャアプローチです。Zeppelin_osやAragon_osをはじめとしたコントラクトがupgradabilityを得るためのプロトコル開発も活発となっています。さらに、EIPでもProxyパターンなどの規格化が進められています。