本文详细介绍了EVM中的delegatecall操作码,解释了其工作原理、使用场景及潜在问题,并提供了多个代码示例帮助理解。
这篇文章详细解释了 delegatecall
的工作原理。以太坊虚拟机 (EVM) 提供了四个用于在合约之间调用的 opcodes:
CALL (F1)
CALLCODE (F2)
STATICCALL (FA)
DELEGATECALL (F4)
。值得注意的是,CALLCODE
opcode 自 Solidity v5 起已被弃用,取而代之的是 DELEGATECALL
。这些 opcodes 在 Solidity 中有直接的实现,并可以作为 address
类型变量的方法执行。
为了更好地理解 delegatecall
的工作原理,首先让我们回顾一下 CALL
opcode 的功能。
为了演示调用,考虑以下合约:
contract Called {
uint public number;
function increment() public {
number++;
}
}
从另一个合约执行 increment()
函数的最直接方法是利用 Called
合约接口。在这种做法中,我们可以简单地使用语句 called.increment()
来执行该函数,其中 called
是 Called
的地址。但调用 increment()
也可以通过低级调用来实现,如下合约所示:
contract Caller {
address constant public calledAddress = 0xd9145CCE52D386f254917e481eB44e9943F39138; // Called 的地址
function callIncrement() public {
calledAddress.call(abi.encodeWithSignature("increment()"));
}
}
每个 address
类型的变量,例如 calledAddress
变量,都有一个名为 call
的方法。此方法要求一个参数,该参数是要在交易中执行的输入数据,即 ABI 编码的 calldata。在上述情况下,输入数据必须对应于 increment()
函数的签名,具有 函数选择器 0xd09de08a
。我们使用 abi.encodeWithSignature
方法从函数定义生成该签名。
如果你在 Caller
合约中执行 callIncrement
函数,你会看到 Called
中的状态变量 number
会增加 1。call
方法 不验证目标地址实际上是否对应于一个现有合约,也不验证其是否包含指定函数。
调用交易的可视化在下面的视频中:
https://img.learnblockchain.cn/2025/02/26/file.mp4
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://img.learnblockchain.cn/2025/02/26/file.mp4
就像 call
一样,delegatecall
也具有要由目标合约执行的输入数据作为参数。
这是与上面动画对应的 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
变量。
下面的视频展示了这个错误:
https://img.learnblockchain.cn/2025/02/26/file.mp4
让我们在下面说明发生了什么。
因此,在使用 delegatecall
时务必谨慎,因为它可能会意外地破坏我们的合约。在上述示例中,程序员很可能并不想通过 callIncrement
函数修改 calledAddress
变量。
让我们对 Caller
进行一个小改动,将状态变量 myNumber
移至槽 0。
contract Caller {
uint public myNumber;
address public calledAddress = 0xd9145CCE52D386f254917e481eB44e9943F39138;
function callIncrement() public {
called.delegatecall(
abi.encodeWithSignature("increment()")
);
}
}
现在,当执行 callIncrement
函数时,myNumber
变量将被增加,因为这正是 increment
函数的目的。我故意将 Caller
中的变量名称与 Called
中的不同命名,以展示 变量名称并不重要;重要的是它们处于哪个槽中。对两个合约的状态变量进行对齐对于 delegatecall
的正常运行至关重要。
delegatecall
最重要的用途之一是将数据存储的合约(如此例中的 Caller
)与执行逻辑所在的合约(如 Called
)解耦。因此,如果希望更改执行逻辑,只需用另一个合约替换 Called
,并更新对实现合约的引用,而无需触及存储。Caller
不再受到它拥有哪些函数的限制,它可以从其他合约委托调用所需的函数。
如果需要更改执行逻辑,例如,减少 myNumber
的值而不是增加,可以创建一个新的实现合约,如下所示。
contract NewCalled {
uint public number;
function increment() public {
number = number - 1;
}
}
不幸的是,无法更改将被调用的函数名称,因为这样做会更改其签名。
创建完新实现合约 NewCalled
后,只需部署这个新合约并在 Caller
中更改 calledAddress
状态变量。否则,Caller
需要有一个机制来更改它所发起 delegateCall
的地址,这一点为了保持代码的简洁性而未包含。
我们成功地修改了 Caller
合约使用的业务逻辑。将数据与执行逻辑分离使我们能够在 Solidity 中创建可升级的智能合约。
在上图中,左侧的合约同时处理数据和逻辑。而右侧,顶部的合约持有数据,但更新数据的机制保存在逻辑合约中。要更新数据,将对逻辑合约执行一次 delegatecall
。
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
函数利用该逻辑,采用 delegatecall
。此函数返回一个值,我们必须使用 abi.decode
来对其进行解码。在根据此返回值做出任何决策之前,检查该函数是否成功执行至关重要,否则我们可能会尝试解析不存在的返回值或最终解析反转原因字符串。
一个关键点是要理解何时成功值将为 true
或 false
。基本上,它取决于被执行的函数是否会反转。 执行可以反转的三种途径:
如果通过 delegatecall
(或 call
)执行的函数遇到这些情况之一,它将反转,返回值将为 false。
一个经常困惑开发者的问题是,为什么对不存在合约的 delegatecall
不会反转且仍报告执行成功。基于我们所说的,空地址永远不会遇到三种反转条件,因此它永远不会反转。
让我们稍微修改一下上面的代码,给出另一个与存储布局相关的错误示例。
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) = called.delegatecall(
abi.encodeWithSignature("getValue()"));
return abi.decode(data, (uint256)); // 这个是 3 还是 2?
}
}
contract Called {
uint256 private immutable a = 2;
function getValue() public pure returns (uint256) {
return a;
}
}
问题是:当执行 getValueDelegate
时,将返回 2 还是 3?让我们来推理一下。
getValueDelegate
函数执行了 getValue
函数,理论上返回与状态变量相对应的值。delegatecall
,我们应该检查调用合约中的槽,而不是被调用合约中的槽。Caller
中变量 a
的值为 3,因此响应必须为 3。 nail it.惊讶的是,正确的答案是 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
函数中返回这些值。下图是执行 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
表示交易的输入数据。
当我们执行调用或 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
函数随后代表调用 returnMsgData
函数。要完成此操作,传递给运行时的 calldata 必须包含 returnMsgData
的签名。因此,returnMsgData
中的 msg.data
的值是其自身的签名,给定为 0x0b1c837f
。
在下图中,我们可以看到 returnMsgData
的返回值是其自身的签名,经过 ABI 编码。
解码的输出是 returnMsgData
函数的签名,经过 ABI 编码为字节。
我们提到可以将 delegatecall 概念化为借用实现合约的字节码并在调用合约中执行。然而,有一个例外,CODESIZE
opcode。
假设一个智能合约在其字节码中具有 CODESIZE
,CODESIZE
仅返回 该 合约的大小。在 delegatecall
时,codesize 不返回调用者的代码大小——它返回的是 delegatecalled 的代码的大小。
为演示这一属性,我们提供了以下代码。在 Solidity 中,可以通过 codesize()
函数在汇编中执行 CODESIZE
。我们有两个实现合约 CalledA
和 CalledB
,它们仅因局部变量(ContractB
中的 unused
——在 ContractA
中缺距)不同而不同,这用于确保这两个合约的大小不同。通过 Caller
合约的 getSizes
函数对这些合约进行调用。
// 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
函数返回调用者合约的大小,那么通过 delegate调用 ContractA
和 ContractB
的情况下,getSizes()
返回的值将会相同。也就是说,它们将是 Caller
的大小,即 1103。然而,正如下图所示,返回的值是不同的,明确表明它们是 CalledA
和 CalledB
的大小。
人们可能会好奇:如果一个合约发起一个 delegatecall
到第二个合约,该合约又发起一个 delegatecall
到第三个合约,会发生什么?在这种情况下,上下文将保持为发起第一个 delegatecall
的合约,而不是中间合约。
其工作原理如下:
Caller
合约对 CalledFirst
合约中的函数 logSender()
发起 delegatecall。msg.sender
。CalledFirst
合约除了创建此日志外,还 delegatecall 到 CalledLast
合约。CalledLast
合约也发出事件,该事件同样记录 msg.sender
。以下图表描述了此流程。
请记住,所有 delegatecall 要做的只是借用被委托合约的字节码。以这种方式可视化,我们会发现 msg.sender始终是原始 msg.sender,因为一切都发生在 Caller
内。请查看下面的动画:
https://img.learnblockchain.cn/2025/02/26/file.mp4
下面提供一些源代码以测试 delegatecall 到 delegatecall 的概念:
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
的合约。
最终结果是,CalledFirst
和 CalledLast
的 msg.sender
值都对应于发起 Caller.delegateCallToFirst()
的交易的账户。可以在下面的图中观察到,当我们在 Remix 中执行这个过程并捕获日志时。
msg.sender
在 CalledFirst
和 CalledLast
中相同
一个源于混淆的说法是,有人可能会将这个操作描述为“Caller
delegatecalls CalledFirst
和 CalledFirst
delegatecalls CalledLast
。” 但这使得 CalledFirst
似乎在进行 delegatecall——实际上并非如此。CalledFirst
提供了字节码给 Called
——该字节码正在由 Called
发出 delegatecall 到 CalledLast
。
通过 delegatecall 进行调用
让我们引入一个情节扭转并修改 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()")
); // 这是新的
}
}
问题是:在 SenderAtCalledLast
事件中记录的 msg.sender
是什么?下面的动画展示了发生的事情:
https://img.learnblockchain.cn/2025/02/26/file.mp4
当 Caller
通过 delegatecall
调用 CalledFirst
中的函数时,该函数在 Caller
的上下文中执行。请记住,CalledFirst
只是“借用”其字节码由 Caller
执行。此时,就好像我们在 Caller
合约中执行 msg.sender
,这意味着 msg.sender
是发起交易的地址。
现在,CalledFirst
调用 CalledLast
,但 CalledFirst
在 Caller
的上下文中使用,因此就像 Caller
调用 CalledLast
。在这种情况下,CalledLast
中的 msg.sender
将是 Caller
的地址。
在下图中,我们在 Remix 中观察日志。注意这次 msg.sender
的值不同。
msg.sender
在 CalledLast
中是 Caller
地址
练习: 如果 Caller
调用 CalledFirst
,而 CalledFirst
delegatecalls CalledLast
,并且每个合约记录 msg.sender
,那么每个合约将记录哪个消息发送者?
在本节中,我们将使用 delegatecall
在 YUL 中更深入地探讨其功能。YUL 中的函数与 opcode 语法密切相关,因此首先检查 DELEGATECALL
opcode 的定义是有益的。
DELEGATECALL
从堆栈中获取六个参数,按顺序分别是:gas、address、argsOffset、argsSize、retOffset 和 retSize,并返回一个值指示操作是否成功(1)或未成功(0)。
对每个参数的解释如下(取自 evm.codes):
通过 delegatecall 将 ether 发送到合约是不允许的(想象一下,如果允许,会造成什么潜在的利用!)。另一方面,CALL
opcode 允许转账 Ether 并附加一个额外的参数以指示具体转账多少 Ether。
在 YUL 中,delegatecall
函数与 DELEGATECALL
opcode 相似,并包含上述六个参数,语法为:
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) // 将我打算发送的 calldata 加载到 0x00 的内存中。第一个槽将变为 0x0000000000000000000000000000000000000000000000000000000034ee2172
let result := delegatecall(gas(), _address, 0x1c, 4, 0, 0x20) // 第三个参数表示 calldata 在内存中的起始位置,第四个参数指定其字节大小,第五个参数指定如果有应存储的返回 calldata 的位置
data := mload(0) // 从内存中读取 delegatecall 返回值
}
}
}
contract Called {
function sayOne() public pure returns (uint) {
return 1;
}
}
在 delegateInSolidity
函数中,我使用 Solidity 的 delegatecall
方法,作为参数传递 sayOne
函数的签名,这是通过 abi.encodeWithSignature
方法计算出的。
如果我们不知道返回的大小,别担心。后来可以使用 returndatacopy
函数来处理这个问题。在另一篇文章中,当我们进一步深入使用 delegatecall 编写可升级合约时,将涵盖所有这些细节。
关于转发 gas 的问题:我们在 delegatecall
的第一个参数中使用 gas()
函数,它返回可用的 gas。这应该表示我们打算转发所有可用 gas。然而,自 "Tangerine Whistle" 分叉以来,关于通过 delegatecall
(及其他 opcodes)转发的 gas 有一个 63/64 的上限。换句话说,虽然 gas()
函数返回所有可用 gas,但只有 63/64 被转发到新的子上下文,而 1/64 被保留。
总结这篇文章,让我们回顾一下我们所学到的内容。Delegatecall
允许在调用合约的上下文中执行在其他合约中定义的函数。被调用的合约(也称为实现合约)仅提供其字节码,且其内部没有被更改或从其存储中获取任何内容。Delegatecall
被用于将数据存储合约与业务逻辑或函数实现的合约分离。这构成了在 Solidity 中最常用的合约可升级性模式的基础。 然而,正如我们所观察到的,delegatecall
必须谨慎使用,因为状态变量的意外更改可能发生,这可能导致调用合约变得不可用。
对于新手来说,查看我们的免费 Solidity 课程。中级 Solidity 开发者请查看我们的 Solidity Bootcamp。
本文由 João Paulo Morais 与 RareSkills 合作撰写。
最初发布于 5 月 3 日
- 原文链接: rareskills.io/post/deleg...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!