Delegatecall: 详细且生动的指南
本文详细解释了 delegatecall 的工作原理。以太坊虚拟机(EVM)提供了四个用于在合约之间进行调用的操作码:
CALL (F1)
CALLCODE (F2)
STATICCALL (FA)
DELEGATECALL (F4)
值得注意的是,自 Solidity v5 以来,CALLCODE
操作码已被弃用,并由DELEGATECALL
取代。这些操作码在 Solidity 中有直接的实现,可以作为类型为address
的变量的方法执行。
为了更好地理解 delegatecall 的工作原理,让我们首先回顾一下CALL
操作码的功能。
为了演示 call,请考虑以下合约:
contract Called {
uint public number;
function increment() public {
number++;
}
}
从另一个合约中执行 increment()函数的最直接方法是利用 Called 合约接口。在这个示例中,我们可以通过一个简单的语句 called.increment()来执行该函数,其中 called 是 Called 的地址。但也可以使用低级别的 call 来调用 increment(),如下合约所示:
contract Caller {
// Called 的地址
address constant public calledAddress = 0xd9145CCE52D386f254917e481eB44e9943F39138;
function callIncrement() public {
calledAddress.call(abi.encodeWithSignature("increment()"));
}
}
每个类型为 address 的变量,例如 calledAddress 变量,都有一个名为 call 的方法。该方法期望参数是要在交易中执行的输入数据,即 ABI 编码的 calldata。在上述情况下,输入数据必须对应于increment()
函数的签名,函数选择器为0xd09de08a
。我们使用 abi.encodeWithSignature 方法从函数定义生成此签名。
如果在Caller
合约中执行callIncrement
函数,你会发现Called
中的状态变量number
将增加 1。call
方法不会验证目标地址是否实际对应于现有合约,也不会验证它是否包含指定的函数。
视频中展示了 call 交易的可视化过程:
视频:https://youtu.be/Wh-6hHTRO_I
call
方法返回一个包含两个值的元组。第一个值是一个布尔值,指示交易的成功或失败。第二个值是类型为 bytes 的,包含由call
执行的函数的返回值,经过 ABI 编码(如果有)。
为了检索call
的返回值,我们可以如下修改callIncrement
函数:
function callIncrement() public {
(bool success, bytes memory data) = called.call(
abi.encodeWithSignature("increment()")
);
}
call
方法从不回滚。如果交易不成功
,success 将为 false,程序员需要相应地处理。
让我们修改上述合约以包含对不存在函数的另一个调用,如下所示:
contract Caller {
address public constant calledAddress = 0xd9145CCE52D386f254917e481eB44e9943F39138;
function callIncrement() public {
(bool success, bytes memory data) = called.call(
abi.encodeWithSignature("increment()")
);
if (!success) {
revert("Something went wrong");
}
}
// 调用一个不存在的函数
function callWrong() public {
(bool success, bytes memory data) = called.call(
abi.encodeWithSignature("thisFunctionDoesNotExist()")
);
if (!success) {
revert("Something went wrong");
}
}
}
我故意创建了两个函数:一个具有正确的 increment 函数签名,另一个具有无效签名。第一个函数将返回true
表示success
,而第二个将返回false
。返回的布尔值被显式处理,如果success
为 false,交易将回滚。
我们必须小心跟踪调用是否成功,我们稍后会重新讨论这个问题。
increment
函数的目的是增加名为number
的状态变量。由于 EVM 不关心状态变量,而是在存储槽上操作,所以函数实际上是增加存储的第一个槽中的值,即槽 0。此操作发生在Called
合约的存储中。
回顾如何使用call
方法将帮助我们形成关于如何使用delegatecall
的概念。
一个合约对目标智能合约进行 delegatecall 时,会在自己的环境中执行目标合约的逻辑。
一种思维模型是它复制目标智能合约的代码并自行运行该代码。目标智能合约通常被称为“实现合约”。
动画视频:https://youtu.be/uwBXltZvFA4
与call
一样,delegatecall
也将要由目标合约执行的输入数据(input data)作为参数。
以下是Called
合约的代码,对应于上面的动画视频,它在Caller
的环境中运行:
contract Called {
uint public number;
function increment() public {
number++;
}
}
以及Caller
的代码
contract Caller {
uint public number;
function callIncrement(address _calledAddress) public {
_calledAddress.delegatecall(
abi.encodeWithSignature("increment()")
);
}
}
这个delegatecall
将执行increment
函数;然而,执行将发生一个关键的区别。Caller
合约的存储将被修改,而不是Called
的存储。就像Caller
合约借用了Called
的代码在自己的上下文中执行。
下图进一步说明了delegatecall
如何修改Caller
的存储而不是Called
的存储。
下图说明了使用call
和delegatecall
执行 increment 函数的区别。
发出delegatecall
的合约必须非常小心地预测其存储槽将被修改。前面的示例之所以能完美运行,是因为Caller
没有使用槽 0 中的状态变量。使用 delegatecall
时常见的错误是忘记这一事实。让我们看一个例子。
contract Called {
uint public number;
function increment() public {
number++;
}
}
contract Caller {
// 这里有一个新的存储变量
address public calledAddress = 0xd9145CCE52D386f254917e481eB44e9943F39138;
uint public myNumber;
function callIncrement() public {
called.delegatecall(
abi.encodeWithSignature("increment()")
);
}
}
请注意,在上述更新的合约中,槽0
的内容是Called
合约的地址,而myNumber
变量现在存储在槽1
中。
如果部署提供的合约并执行callIncrement
函数,Caller
存储的槽 0 将被增加,但calledAddress
变量在那里,而不是myNumber
变量。以下视频展示了这个 bug:
让我们在下面说明发生了什么:
因此,在使用delegatecall
时必须谨慎,因为它可能会无意中破坏我们的合约。在上面的例子中,程序员可能并不打算通过callIncrement
函数更改calledAddress
变量。
让我们通过将状态变量myNumber
移动到槽 0 来对Caller
进行一个小改动。
contract Caller {
uint public myNumber;
address public calledAddress = 0xd9145CCE52D386f254917e481eB44e9943F39138;
function callIncrement() public {
called.delegatecall(
abi.encodeWithSignature("increment()")
);
}
}
现在,当执行callIncrement
函数时,myNumber
变量将被递增,因为这是increment
函数的目的。我故意选择了与Called
中的变量不同的变量名,以证明变量名并不重要;重要的是它在哪个槽中。对齐两个合约的状态变量对于 delegatecall 的正常运行至关重要。
delegatecall
最重要的用途之一是将存储数据的合约(如本例中的Caller
)与执行逻辑所在的合约(如Called
)解耦。因此,如果希望更改执行逻辑,只需用另一个合约替换Called
并更新实现合约的引用,而无需触及存储。Caller
不再受限于它拥有的函数,它可以从其他合约中 delegatecall 所需的函数。
如果需要更改执行逻辑,例如将myNumber
的值减去 1 个单位而不是增加,可以创建一个新的实现合约,如下所示:
contract NewCalled {
uint public number;
function increment() public {
number = number - 1;
}
}
不幸的是,不可能更改将被调用的函数的名称,因为这样做会改变其签名。
在创建新的实现合约NewCalled
后,只需部署这个新合约并更改Caller
中的calledAddress
状态变量。当然,Caller 需要有一个机制来更改它发出delegateCall
的地址,我们没有包括这个机制以保持代码简洁。
我们已经成功地修改了 Caller 合约使用的业务逻辑。将数据与执行逻辑分离使我们能够在 Solidity 中创建可升级的智能合约。
在上图中,左边的合约处理数据和逻辑。右边,顶部的合约持有数据,但更新数据的机制在逻辑合约中。要更新数据,需要对逻辑合约进行 delegatecall。
就像call
一样,delegatecall
也返回一个包含两个值的元组:一个表示执行成功的布尔值和通过delegatecall
执行的函数的返回值(以字节形式)。为了了解如何处理这个返回值,让我们写一个新的例子:
contract Called {
function calculateDiscountPrice(uint256 amount, uint256 discountRate) public pure returns (uint) {
return amount - (amount * discountRate)/100;
}
}
contract Caller {
uint public price = 200;
uint public discountRate = 10;
address public called;
function setCalled(address _called) public {
called = _called;
}
function setDiscountPrice() public {
(bool success, bytes memory data) = called.delegatecall(
abi.encodeWithSignature(
"calculateDiscountPrice(uint256,uint256)",
price,
discountRate)
);
if (success) {
uint newPrice = abi.decode(data, (uint256));
price = newPrice;
}
}
}
Called
合约包含计算折扣价格的逻辑。我们通过执行calculateDiscountPrice
函数来利用这个逻辑。这个函数返回一个值,我们必须使用abi.decode
进行解码。在根据这个返回值做出任何决定之前,必须检查函数是否成功执行,否则我们可能会尝试解析一个不存在的返回值或最终解析一个回退原因字符串。
一个关键点是理解成功值何时为true
或false
。本质上,这取决于被执行的函数是否会回退。 有三种方式可以导致执行回退:
如果通过delegatecall
(或call
)执行的函数遇到任何这些情况,它将回退,并且delegatecall
的返回值将为 false。
注意:
一个经常困扰开发者的问题是为什么对不存在的合约进行delegatecall
不会回退并且仍然报告执行成功。根据我们所说的,一个空地址永远不会满足回退的三个条件之一,所以它永远不会回退。
让我们对上面的代码稍作修改,以给出另一个与存储布局相关的 bug 的例子。
Caller
合约仍然通过delegatecall
调用一个实现合约,但现在Called
合约从状态变量中读取一个值。这看起来像是一个小改动,但实际上会导致灾难。你能找出为什么吗?
contract Called {
uint public discountRate = 20;
function calculateDiscountPrice(uint256 amount) public pure returns (uint) {
return amount - (amount * discountRate)/100;
}
}
contract Caller {
uint public price = 200;
address public called;
function setCalled(address _called) public {
called = _called;
}
function setDiscount() public {
(bool success, bytes memory data) =called.delegatecall(
abi.encodeWithSignature(
"calculateDiscountPrice(uint256)",
price
)
);
if (success) {
uint newPrice = abi.decode(data, (uint256));
price = newPrice;
}
}
}
问题在于calculateDiscountPrice
正在读取一个状态变量,特别是槽 0 中的那个。记住,在delegatecall
中,函数是在调用合约的存储中执行的。换句话说,你可能认为你在使用Called
合约中的discountRate
变量来计算新的price
,但实际上你在使用Caller
合约中的 price 变量!存储变量Called.discountRate
和Called.price
占据槽 0。
你将获得 200%的折扣,这相当可观(并且会导致函数回退,因为新计算的价格将变为负数,这对于uint类型变量是不允许的)。
另一个与 delegatecall
相关的棘手问题出现在涉及不可变或常量变量时。让我们来看一个许多经验丰富的 Solidity 程序员容易误解的例子:
contract Caller {
uint256 private immutable a = 3;
function getValueDelegate(address called) public pure returns (uint256) {
(bool success, bytes memory data) = B.delegatecall(
abi.encodewithSignature("getValue()"));
return abi.decode(data, (uint256)); // is this 3 or 2?
}
}
contract Called {
uint256 private immutable a = 2;
function getValue() public pure returns (uint256) {
return a;
}
}
问题是:执行 getValueDelegate
时,返回值是 2 还是 3?让我们来推理一下。
getValueDelegate
函数执行 getValue
函数,该函数应该返回槽 0 中对应的状态变量的值。Caller
中变量 a
的值是 3,所以响应必须是 3。搞定了。令人惊讶的是,正确答案是 2。为什么?!
不可变或常量状态变量不是真正的状态变量:它们不占用槽。当我们声明不可变变量时,它们的值被硬编码在合约字节码中,该字节码在 delegatecall 期间执行。因此,getValue
函数返回硬编码的值 2。
如果我们在 Called
合约中使用 msg.sender
、msg.value
和 address(this)
,所有这些值将对应于 Caller 合约的 msg.sender
、msg.value
和 address(this)
值。让我们记住 delegatecall 的操作方式:一切都在调用合约的上下文中发生。实现合约仅提供要执行的字节码,仅此而已。
让我们在一个例子中应用这个概念。考虑以下代码:
contract Called {
function getInfo() public payable returns (address, uint, address) {
return (msg.sender, msg.value, address(this));
}
}
contract Caller {
function getDelegatedInfo(address _called) public payable returns (address, uint, address) {
(bool success, bytes memory data) = _called.delegatecall(
abi.encodeWithSignature("getInfo()")
);
return abi.decode(data, (address, uint, address));
}
}
在 Called
合约中,我使用了 msg.sender
、msg.value
和 address(this)
,并在 getInfo 函数中返回这些值。在下图中,使用 Remix 执行 getDelegateInfo
,显示了返回的值。
msg.sender
对应于执行交易的账户,具体来说是第一个 Remix 默认账户,即 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
。msg.value
反映了在原始交易中发送的 1 个以太币的值。address(this)
是 Caller 合约的地址,如图左侧所示,而不是 Called 合约的地址。在 Remix 中,我们显示了 msg.sender (0)、msg.value (1) 和 address(this) (2) 的日志值。
msg.data
属性返回正在执行的上下文的 calldata。当 msg.data
在由 EOA 直接通过交易执行的函数中被调用时,msg.data
代表交易的输入数据。
当我们执行 call 或 delegatecall 时,我们将作为参数指定将在实现合约中执行的输入数据。因此,原始 calldata 与由 delegatecall
创建的子上下文中的 calldata 不同,因此 msg.data
也会不同。
下面的代码将用于演示这一点。
contract Called {
function returnMsgData() public pure returns (bytes memory) {
return msg.data;
}
}
contract Caller {
function delegateMsgData(address _called) public returns (bytes memory data) {
(, data) = _called.delegatecall(
abi.encodeWithSignature("returnMsgData()")
);
}
}
原始交易执行 delegateMsgData
函数,该函数需要一个地址类型的参数。因此,输入数据将包含函数签名和一个地址,ABI 编码。
delegateMsgData
函数反过来 delegatecalls returnMsgData
函数。为此,传递给运行时的 calldata 必须包含 returnMsgData
的签名。因此,returnMsgData
内部的 msg.data
的值是其自身的签名,即 0x0b1c837f
。
在下图中,我们可以看到 returnMsgData
的返回值是其自身的签名,ABI 编码。
解码输出是 returnMsgData
函数的签名,ABI 编码为字节。
我们提到可以通过借用实现合约的字节码并在调用合约中执行它来构思 delegatecall。有一个例外,即 CODESIZE
操作码。
假设一个智能合约的字节码中有 CODESIZE
,CODESIZE
返回该合约的大小。Codesize 不会在 delegatecall 期间返回调用者代码的大小——它返回被 delegatecalled 的代码的大小。
为了演示这一特性,我们提供了下面的代码。在 Solidity 中,CODESIZE
可以通过 codesize()
函数在汇编中执行。我们有两个实现合约,CalledA
和 CalledB
,它们仅在局部变量(ContractB
中的 unused
——该变量在 ContractA
中不存在)上有所不同,目的是确保合约大小不同。这些合约通过 Caller 合约的 getSizes
函数使用 delegatecall
调用。
// codesize 1103
contract Caller {
function getSizes(address _calledA, address _calledB) public returns (uint sizeA, uint sizeB) {
(, bytes memory dataA) = _calledA.delegatecall(
abi.encodeWithSignature("getCodeSize()")
);
(, bytes memory dataB) = _calledB.delegatecall(
abi.encodeWithSignature("getCodeSize()")
);
sizeA = abi.decode(dataA, (uint256));
sizeB = abi.decode(dataB, (uint256));
}
}
// codesize 174
contract CalledA {
function getCodeSize() public pure returns (uint size) {
assembly {
size := codesize()
}
}
}
// codesize 180
contract CalledB {
function getCodeSize() public pure returns (uint size) {
uint unused = 100;
assembly {
size := codesize()
}
}
}
// 你可以使用这个合约来检查合约的大小
contract MeasureContractSize {
function measureConctract(address c) external view returns (uint256 size){
size = c.code.length;
}
}
如果 codesize
函数返回的是 Caller
合约的大小,那么通过委托调用 ContractA
和 ContractB
从 getSizes()
返回的值将是相同的。也就是说,它们将是 Caller
的大小,即 1103。然而,如下图所示,返回的值是不同的,这明确表明这些是 CalledA
和 CalledB
的大小。
有人可能会问:如果一个合约发出 delegatecall
给第二个合约,而第二个合约又发出 delegatecall
给第三个合约,会发生什么?在这种情况下,上下文将保持为发起第一个 delegatecall
的合约,而不是中间的合约。
其工作原理如下:
Caller
合约委托调用 CalledFirst
合约中的 logSender()
函数。msg.sender
。CalledFirst
合约除了创建这个日志外,还委托调用 CalledLast
合约。CalledLast
合约也会发出一个事件,同样记录 msg.sender
。下面是描述此流程的图表。
请记住,所有的委托调用只是借用了被委托调用合约的字节码。可以这样想象,这些字节码暂时被“吸收”到调用合约中。当我们这样看时,我们会发现 msg.sender 始终是原始的 msg.sender,因为所有事情都发生在 Caller 内部。请参见下面的动画:https://youtu.be/Xbf5GIQTJvA
下面我们提供一些源代码来测试委托调用的委托调用的概念:
contract Caller {
address calledFirst = 0xF27374C91BF602603AC5C9DaCC19BE431E3501cb;
function delegateCallToFirst() public {
calledFirst.delegatecall(
abi.encodeWithSignature("logSender()")
);
}
}
contract CalledFirst {
event SenderAtCalledFirst(address sender);
address constant calledLast = 0x1d142a62E2e98474093545D4A3A0f7DB9503B8BD;
function logSender() public {
emit SenderAtCalledFirst(msg.sender);
calledLast.delegatecall(
abi.encodeWithSignature("logSender()")
);
}
}
contract CalledLast {
event SenderAtCalledLast(address sender);
function logSender() public {
emit SenderAtCalledLast(msg.sender);
}
}
我们可能会认为 CalledLast
中的 msg.sender
将是 CalledFirst
的地址,因为它是调用 CalledLast
的合约,但这不符合我们的模型,即通过 delegatecall
调用的合约的字节码只是被借用,而上下文始终是执行 delegatecall
的合约。
最终结果是,两个 msg.sender
值都对应于发起 Caller.delegateCallToFirst()
交易的账户。这可以在下图中观察到,我们在 Remix 中执行此过程并捕获日志。
msg.sender 在 CalledFirst 和 CalledLast 中是相同的
一个混淆的来源是,有人可能会描述这个操作为“Caller
委托调用 CalledFirst
,而 CalledFirst
委托调用 CalledLast
。”但这听起来像是 CalledFirst
在进行委托调用——事实并非如此。CalledFirst
提供字节码给 Called
——而该字节码从 Called
发出委托调用给 CalledLast
。
从委托调用中call 调用
让我们引入一个情节转折并修改 CalledFirst 合约。现在,CalledFirst 将使用 call
而不是 delegatecall
调用 CalledLast。
换句话说,CalledFirst 合约需要更新为以下代码:
contract CalledFirst {
event SenderAtCalledFirst(address sender);
address constant calledLast = ...;
function logSender() public {
emit SenderAtCalledFirst(msg.sender);
calledLast.call(
abi.encodeWithSignature("logSender()")
); // this is new
}
}
问题来了:在 SenderAtCalledLast
事件中记录的 msg.sender
会是什么?以下动画说明了会发生什么:
视频: https://youtu.be/PLy3zEdc9t0
当 Caller
通过 delegatecall
调用 CalledFirst
中的一个函数时,该函数在 Caller
的上下文中执行。请记住,CalledFirst
只是“借出”其字节码供 Caller
执行。此时,就像我们在 Caller
合约中执行 msg.sender
,这意味着 msg.sender 是发起交易的地址。
现在,CalledFirst
调用 CalledLast
,但 CalledFirst
在 Caller
的上下文中使用,所以就像 Caller
调用了 CalledLast
。在这种情况下,CalledLast
中的 msg.sender
将是 Caller
的地址。
在下图中,我们观察到 Remix 中的日志。请注意,这次 msg.sender
值是不同的。
CalledLast 中的 msg.sender 是 Caller 的地址
练习: 如果 Caller 调用 CalledFirst 而 CalledFirst 委托调用 CalledLast,并且每个合约记录 msg.sender,那么每个合约将记录哪个消息发送者?
在本节中,我们将使用 YUL 中的 delegatecall
来深入探索其功能。YUL 中的函数与操作码语法非常相似,因此首先查看 DELEGATECALL
操作码的定义是有益的。
DELEGATECALL
从堆栈中获取 6 个参数,依次为:gas、address、argsOffset、argsSize、retOffset 和 retSize,并返回一个值到堆栈,指示操作是否成功(1)或不成功(0)。
每个参数的解释如下(取自 evm.codes):
使用委托调用向合约发送以太币是不允许的(想象一下如果允许的话可能的漏洞!)。另一方面,CALL
操作码允许以太币转移,并包含一个额外的参数来指示应发送多少以太币。
在 YUL 中,delegatecall
函数与 DELEGATECALL
操作码相似,并包含上述相同的 6 个参数。其语法为:
delegatecall(g, a, in, insize, out, outsize).
下面,我们展示一个包含两个执行相同操作的函数的合约,执行一个 delegatecall
。一个是用纯 Solidity 编写的,另一个包含 YUL。
contract DelegateYUL {
function delegateInSolidity( address _address ) public returns (bytes memory data) {
(, data) = _address.delegatecall(
abi.encodeWithSignature("sayOne()")
);
}
function delegateInYUL( address _address ) public returns (uint data) {
assembly {
mstore(0x00, 0x34ee2172) // 在0x00处加载我打算发送到内存中的calldata. 第一个槽将变成 0x0000000000000000000000000000000000000000000000000000000034ee2172
let result := delegatecall(gas(), _address, 0x1c, 4, 0, 0x20) // 第三个参数指示calldata在内存中的起始位置,第四个参数指定其大小(以字节为单位),第五个参数指定返回的calldata(如果有)应该存储在内存中的位置
data := mload(0) // 读取委派调用从内存返回
}
}
}
contract Called {
function sayOne() public pure returns (uint) {
return 1;
}
}
在 delegateInSolidity
函数中,我使用了 Solidity 中的 delegatecall
方法,作为参数传递了通过 abi.encodeWithSignature
方法计算的 sayOne
函数的签名。
如果我们事先不知道返回值的大小,不用担心,我们可以稍后使用 returndatacopy 函数来处理。在另一篇文章中,当我们深入探讨使用 delegatecall 编写可升级合约时,我们将涵盖所有这些细节。
关于转发 gas 的问题说明:我们使用 gas()
函数作为 delegatecall
的第一个参数,它返回可用的 gas。这应该表明我们打算转发所有可用的 gas。然而,自从 Tangerine Whistle 分叉以来,通过 delegatecall
(和其他操作码)转发的 gas 总量被限制为 总可能 gas 的 63/64。换句话说,尽管 gas()
函数返回所有可用的 gas,但只有 63/64 被转发到新的子上下文,而 1/64 被保留。
总结本文,我们学到了什么。Delegatecall
允许在调用合约的上下文中执行其他合约中定义的函数。被调用的合约,也称为实现合约,仅提供其字节码,其存储中的任何内容都不会被更改或获取。
Delegatecall
被用来将存储数据的合约与存放业务逻辑或函数实现的合约分开。这是 Solidity 中最常用的合约可升级模式的基础。然而,正如我们所看到的,delegatecall
必须非常谨慎地使用,因为可能会发生对状态变量的意外更改,可能会导致调用合约无法使用。
对于 Solidity 新手,请参阅我们的免费 Solidity 课程。中级 Solidity 开发人员请参阅我们的 Solidity Bootcamp。 登链社区是RareSkills 官方合作伙伴, 合约开发线下培训请参阅 OpenSpace 集训营
本文由 João Paulo Morais 与 RareSkills 合作撰写。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!