はじめに
EthereumなどのEVM系のスマートコントラクトをSolidityで実装する場合、スマートコントラクトから別のスマートコントラクトの関数を呼び出す方法は複数あります。その中でも、ライブラリの呼び出しや、Proxyパターンなどで使われているdelegatecall関数による呼び出し方法について、その動作を検証してみました。
私が気になっていたのは、delegatecallで呼び出された関数からさらにdelegatecallで関数を呼び出す(delegatecallを多段呼び出しをする)とどうなるか? で、delegatecallの仕様であるEIP-7: DELEGATECALLを見ても、この辺りの動作が明記されてなかったので、実際にコードを書いて試してみました。
さまざまな関数の呼び出し方
通常は、呼び出し元のコントラクトから、呼び出し先のコントラクトの関数を呼び出す際は、
//直接呼び出し
con.func();
のように直接呼び出していると思いますが、この他に、
// call関数による呼び出し
address(con).call(abi.encodeWithSignature("func()"));
// callcode関数による呼び出し
address(con).callcode(abi.encodeWithSignature("func()"));
// delegatecall関数による呼び出し
address(con).delegatecall(abi.encodeWithSignature("func()"));
などの方法があるのはご存じでしょうか。
それぞれの呼び出し方で、動作の違いは色々あるのですが、大きく次の2点が注目すべき違いとなります。
- ストレージ領域(スマートコントラクトの呼び出し元/先のどちらを使うのか?)
- msg.sender(スマートコントラクトの呼び出し元のアドレスは何になるのか?)
それぞれの違いについて簡単に表にまとめると下記のようになります。
呼び出し方 | ストレージ領域 | msg.sender |
---|---|---|
直接(=callと同じ) | 呼び出し先 | 呼び出し元 |
call | 呼び出し先 | 呼び出し元 |
callcode | 呼び出し元 | 呼び出し元 |
delegatecall | 呼び出し元 | 呼び出し元を呼び出したアドレス(EOAなど) |
今回注目している delegatecall は、ストレージ領域は「呼び出し元」でmsg.senderは「呼び出し元を呼び出したアドレス(EOAなど)」となります。
では、「呼び出し元」から「呼び出し先」をdelegatecallで呼び出した先で、さらにdelegatecallで「呼び出し先の呼び出し先」のコントラクトの関数を呼び出した場合、一体、ストレージ領域はどのコントラクトのものが使われ、msg.senderのアドレスはどこになるのでしょうか。
以下、この件について実際にコードを書いて、動かして試した結果をご紹介していきます。
まずは呼び出し元/呼び出し先だけでの動作確認
まずは呼び出し元と呼び出し先のスマートコントラクト間のみでの動作確認を行ってみます。
下記の実装コードでは、
- 呼び出し元(
First
) - 呼び出し先(
Second
)
というスマートコントラクトを実装していて、First
コントラクトの callTest()
関数を呼び出すことで、First
コントラクトから、Second
コントラクトのsetValue()
関数を呼び出すテストを行っています。
First
コントラクトの callTest()
は、Second
コントラクトのsetValue()
関数をdelegatecall関数ではなく、「直接呼び出し」で呼び出している実装となります。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
import "hardhat/console.sol";
contract Second {
uint public value = 0;
function printValue() public view {
console.log("Second address: %s", address(this));
console.log("Second value: %s", value);
}
function setValue() public {
console.log("Second caller: %s", msg.sender);
value = 1;
}
}
contract First {
uint public value = 0;
function printValue() public view {
console.log("First address: %s", address(this));
console.log("First value: %s", value);
}
function callTest() public {
console.log("First caller: %s", msg.sender);
Second second = new Second();
second.setValue();
second.printValue();
printValue();
}
function delegateCallTest() public {
console.log("First caller: %s", msg.sender);
Second second = new Second();
address(second).delegatecall(abi.encodeWithSignature("setValue()"));
second.printValue();
printValue();
}
}
実行結果は、下記のとおりとなります。
console.log:
First caller: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Second caller: 0x5fbdb2315678afecb367f032d93f642f64180aa3
Second address: 0xa16e02e87b7454126e5e10d957a927a7f5b5d2be
Second value: 1
First address: 0x5fbdb2315678afecb367f032d93f642f64180aa3
First value: 0
First caller
は、First
コントラクトを呼び出したEOAである「0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266」となっています。
Second caller
は、Second
コントラクトを呼び出したFirst
コントラクトのアドレス(First address
の「0x5fbdb2315678afecb367f032d93f642f64180aa3」)になっており、このことから、スマートコントラクトの直接呼び出しの場合は、msg.sender
のアドレスが呼び出し元のスマートコントラクトのアドレスになっていることが分かると思います。
また、Second value
が「1」、First value
が「0」であることから、使用されたストレージ領域は呼び出し先であるSecond
コントラクトであることが分かります。
これらの結果は、先の表で示したものと同じになっています。
では次に、今度はFirst
コントラクトの delegateCallTest()
関数の方を呼び出すことで、Second
コントラクトのsetValue()
関数をdelegatecall関数で呼び出してみます。
実行結果は下記のとおりとなります。
console.log:
First caller: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Second caller: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Second address: 0xa16e02e87b7454126e5e10d957a927a7f5b5d2be
Second value: 0
First address: 0x5fbdb2315678afecb367f032d93f642f64180aa3
First value: 1
First caller
は、先と同じでFirst
コントラクトを呼び出したEOAである「0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266」となっています。
しかし、Second caller
は、Second
コントラクトを呼び出したFirst
コントラクトのアドレス(First address
)ではなく、First caller
と同じEOAになっており、このことから、スマートコントラクトのdelegatecall関数での呼び出しの場合は、msg.sender
のアドレスが呼び出し元を呼び出したアドレス(今回の場合EOA)になっていることが分かると思います。
また、今回はSecond value
が「0」、First value
が「1」であることから、使用されたストレージ領域は呼び出し元であるFirst
コントラクトのものであることが分かります。
これらの結果は、先の表で示したものと同じになっています。
呼び出し元/呼び出し先/呼び出し先の先での動作確認
呼び出し元/呼び出し先でのdelegatecallの動作確認はできましたので、さらにその先の関数をdelegatecallで呼び出した場合の動作確認を行っていきます。
下記の実装コードでは、
- 呼び出し元(
First
) - 呼び出し先(
Second
) - 呼び出し先の先(
Third
)
というスマートコントラクトを実装していて、First
コントラクトの delegateCall2Test()
関数を呼び出すことで、First
コントラクトから、Second
コントラクトのsetValue2()
関数を呼び出し、さらにsetValue2()
関数からThird
コントラクトのsetValue3()
関数を呼び出すテストを行っています。
First
コントラクトの delegateCall2Test()
はSecond
コントラクトのsetValue2()
関数をdelegatecall関数で呼び出し、さらにsetValue2()
関数はsetValue3()
関数をdelegatecall関数で呼び出している実装となります。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
import "hardhat/console.sol";
contract Third {
uint public value = 0;
function printValue() public view {
console.log("Third address: %s", address(this));
console.log("Third value: %s", value);
}
function setValue3() public {
console.log("Third caller: %s", msg.sender);
value = 1;
}
}
contract Second {
uint public value = 0;
function printValue() public view {
console.log("Second address: %s", address(this));
console.log("Second value: %s", value);
}
function setValue2() public {
console.log("Second caller: %s", msg.sender);
Third third = new Third();
address(third).delegatecall(abi.encodeWithSignature("setValue3()"));
third.printValue();
}
}
contract First {
uint public value = 0;
function printValue() public view {
console.log("First address: %s", address(this));
console.log("First value: %s", value);
}
function delegateCall2Test() public {
console.log("First caller: %s", msg.sender);
Second second = new Second();
address(second).delegatecall(abi.encodeWithSignature("setValue2()"));
second.printValue();
printValue();
}
}
実行結果は、下記のとおりとなります。
console.log:
First caller: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Second caller: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Third caller: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Third address: 0xb7a5bd0345ef1cc5e66bf61bdec17d2461fbd968
Third value: 0
Second address: 0xa16e02e87b7454126e5e10d957a927a7f5b5d2be
Second value: 0
First address: 0x5fbdb2315678afecb367f032d93f642f64180aa3
First value: 1
First caller
とSecond caller
は、先と同じでFirst
コントラクトを呼び出したEOAである「0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266」となっています。さらに、Third caller
も、同じEOAになっていることがこの結果から分かります。つまり、delegatecall関数を多段に経由した呼び出し方をしても、msg.sender
はスマートコントラクトの大元の呼び出し元であるアドレス(今回の場合EOA)になることが動作確認により明らかになりました。
また、Third value
が「0」Second value
が「0」、First value
が「1」であることから、使用されたストレージ領域は大元の呼び出し元であるFirst
コントラクトのものであることが分かります。つまり、呼び出し先の先であるThird
コントラクトは、直接の呼び出し元であるSecond
コントラクトのストレージ領域ではなく、その大元の呼び出し元であるFirst
コントラクトのストレージ領域を使うことが、今回の動作確認により明らかになりました。
まとめ
delegatecall が使用するストレージ領域は、呼び出し先のスマートコントラクトのものではなく、呼び出し元のスマートコントラクトのものになりますが、その「呼び出し元」とは、delegatecall関数を直前に呼び出したスマートコントラクトではなく、delegatecall関数を最初に呼び出した(=呼び出しの大元の)スマートコントラクトであることが、これではっきりしました。
Upgradableなスマートコントラクトを実装する際によく使われるProxyパターンではdelegatecallが使われていることが多いですが、そのProxyパターンを使用している際に、さらに自分で実装したスマートコントラクト内でdelegatecallを使って処理を行いたい場合に、使われるストレージ領域がどこのスマートコントラクトのものになるのかが気になり、今回、動作確認してみました。
この検証結果が、みなさんの知識の一助となれば幸いです。