call 与 delegatecall
一、为什么要用底层调用
我们知道,在一个合约中调用另一个合约的接口,通常使用contractName(address).functionName() 来进行调用,比如:
contract MyContract {
function add(address _counter) public {
// 调用 Counter 合约的方法
Counter(_counter).increment();
}
}
但是,有时我们在编写合约时,还不知道要调用的目标合约的接口,甚至是目标合约还没有创建。这时就无法用上面的方法进行调用。
这个问题该如何解决呢?
你也许知道很多编程语言(如Java和Go)有反射的概念,反射允许在运行时动态地调用函数或方法。地址的底层调用和反射非常类似。
使用address的底层调用功能,是在运行时动态地决定调用目标合约和函数, 因此在编译时,可以不知道具体要调用的函数或方法。
二、底层调用
address类型还有3个底层的成员函数:
<address>.call(bytes memory abiEncodeData) returns (bool, bytes memory)
<address>.delegatecall(bytes memory abiEncodeData) returns (bool, bytes memory)
<address>.staticcall(bytes memory abiEncodeData) returns (bool, bytes memory)
- 其中,
call是常规调用,delegatecall为委托调用,staticcall是静态调用(不修改合约状态, 相当于调用view方法)。
这三个函数都可以用于与目标合约<address>交互,三个函数均接受 abi 编码数据作为参数(abiEncodeData)来调用对应的函数。
这里我们使用底层方法调用一下《手把手教你部署智能合约》中的合约:
contract CallTest {
function makeCallGet(address _counter) public view returns (uint) {
// staticcall调用
bytes memory payload = abi.encodeWithSignature("get()");
(bool success, bytes memory returnData) = address(_counter).staticcall(payload);
// 判断一下
require(success, "Call to target contract failed.");
// 将returnData解析成指定类型(e.g. uint)
(uint res) = abi.decode(returnData, (uint));
return res;
}
function makeCallCount(address _counter) public {
// call调用
bytes memory payload = abi.encodeWithSignature("count()");
(bool success, ) = address(_counter).call(payload);
// 判断一下
require(success, "Call to target contract failed.");
}
}
// https://testnet.routescan.io/address/0xcF10C1b7DA166987a1D9bB81C072C339cb7205fd
使用底层方法调用合约函数时, 当被调用的函数发生异常时(revert),异常不会冒泡到调用者(即不会回退), 而是返回布尔值 false。因此在使用所有这些低级函数时,一定要记得检查返回值。
三、call 与 delegatecall
常规调用 call 与 委托调用 delegatecall 的区别是什么呢?

-
执行上下文:当使用
call函数时,被调用的函数在目标合约的上下文中执行,这意味着它有自己的this和msg.sender。而delegatecall函数则在调用合约(当前合约)的上下文中执行被调用的函数。—— 相当于将函数代码 pull 到当前合约中执行 -
状态存储:
call函数在执行时不会改变调用合约的状态,它只会改变被调用合约的状态。而delegatecall函数则可以改变调用合约的状态,因为它在调用合约的上下文中执行。 -
用途:
call函数通常用于调用其他合约的函数,而delegatecall函数允许一个合约借用另一个合约的代码,在自己的上下文中执行,常用于实现可升级合约和库函数。
四、可升级合约的方案和实践
在区块链中,智能合约一旦部署就无法更改。
然而,业务需求、bug修复、功能优化等往往需要我们更新合约的代码逻辑。这种情况下,可升级合约模式应运而生,允许我们在保留合约状态的同时更新合约逻辑。

4.1 数据分离模式
数据分离模式就是使用一个 Logic 合约负责实现所有业务逻辑,另一个 Storage 合约存储数据。Logic 合约再通过正常的 call(而非 delegatecall)去调用 Storage 合约中定义的 Setter/Getter 函数 来读写数据。

这种模式的设计目标通常是为了简化升级流程,保证数据不丢失,但它也有很多缺点。
-
交互地址改变:这种模式并没有解决可升级合约_最核心_的问题——地址持久性。当 Logic 合约需要升级时,你必须部署一个新的 Logic 合约 V2,结果用户的交互地址改变了,你需要更新所有前端、其他依赖合约,并告知用户使用新的 V2 地址。
-
Gas消耗大:外部
call调用比delegatecall消耗更高的 Gas,并且比直接在 Logic 合约存储状态消耗高得多。这使得用户的交互成本明显增加。
因此,这种模式在主流可升级合约设计中很少被采用。
4.2 代理模式
代理模式是通过在fallback函数中进行delegatecall实现的。
在代理模式中,用户实际上是与代理合约(Proxy)进行交互,数据是存储在代理合约中,然后代理合约通过 delegatecall 调用实际的逻辑合约(实现业务逻辑)。

当升级合约时,只需要更新Proxy合约中的 Logic 地址,指向一个新的逻辑合约,而不影响Proxy合约维护的数据,也不须要求用户更新交互地址。
4.2.1 透明代理(Transparent Proxy)
在上述的基础代理模式中,需注意确保只有可信的地址(如合约的拥有者或管理员)才可以更新逻辑合约地址。这就延伸出了透明代理。
在透明代理模式中,它区分了管理员地址和普通用户:
-
管理员地址:有权限升级合约,但无法与Logic合约交互。
-
普通用户:可以与Logic合约交互,但无法升级合约。
这种模式防止了普通用户误调用管理功能,从而降低错误风险。
简单透明代理合约例子:
// 透明代理模式
contract TransparentProxy {
address public logicAddress; // 逻辑合约地址
address public adminAddress; // 管理员地址
// 其他状态变量
constructor(address logic) {
logicAddress = logic;
adminAddress = msg.sender;
}
// 只能是管理员才能调用以升级合约
function upgrade(address newLogic) public {
require(msg.sender == adminAddress, "Only admin can upgrade");
logicAddress = newLogic;
}
// 只有普通用户才能调用逻辑合约
fallback() external payable {
require(msg.sender != adminAddress, "Admin not allowed");
assembly {
// Copy msg.data. We take full control of memory in this inline assembly
// block because it will not return to Solidity code. We overwrite the
// Solidity scratch pad at memory position 0.
calldatacopy(0, 0, calldatasize())
// Call the implementation.
// out and outsize are 0 because we don't know the size yet.
let result := delegatecall(gas(), logicAddress, 0, calldatasize(), 0, 0)
// Copy the returned data.
returndatacopy(0, 0, returndatasize())
switch result
// delegatecall returns 0 on error.
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
}
4.2.2 UUPS代理
UUPS(Universal Upgradeable Proxy Standard,通用升级代理标准)模式是透明代理模式的优化,它将upgrade函数不再放在 Proxy 合约中,而是放在 Logic 合约中。
简单 UUPS 代理合约例子:
// 逻辑合约
contract UUPSLogic {
address public logicAddress; // 防止存储冲突
address public adminAddress; // 防止存储冲突
function upgrade(address newLogic) public {
require(msg.sender == adminAddress, "Only admin can upgrade");
logicAddress = newLogic;
}
// 其他function
}
// 代理合约
contract UUPSProxy {
address public logicAddress; // 逻辑合约地址
address public adminAddress; // 管理员地址
constructor(address logic) {
logicAddress = logic;
adminAddress = msg.sender;
}
fallback() external payable {
assembly {
// Copy msg.data. We take full control of memory in this inline assembly
// block because it will not return to Solidity code. We overwrite the
// Solidity scratch pad at memory position 0.
calldatacopy(0, 0, calldatasize())
// Call the implementation.
// out and outsize are 0 because we don't know the size yet.
let result := delegatecall(gas(), logicAddress, 0, calldatasize(), 0, 0)
// Copy the returned data.
returndatacopy(0, 0, returndatasize())
switch result
// delegatecall returns 0 on error.
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
}
在UUPS代理模式中,升级函数upgrade()位于逻辑合约中,管理员在需要升级时进行delegatecall,该函数即可更新代理合约中存储的逻辑合同地址。
UUPS代理合约中不再有升级函数,fallback()函数也不需要再检查调用者是否是管理员,可以节省gas费。OpenZeppelin官方建议转向UUPS代理。
4.2.3 钻石代理(Diamond Proxy, EIP-2535)

4.2.4 存储冲突
为什么会产生存储冲突(Storage Collision)?
-
delegatecall的工作方式:-
执行代码: 执行的是被调用合约(Implementation合约/ Logic合约)的代码。
-
使用上下文: 使用的是调用合约(Proxy Contract / 代理合约)的存储空间、
msg.sender、msg.value等环境信息。
-
-
Solidity 存储机制:
-
Solidity 合约的状态变量(State Variables)是按顺序存储在合约的存储槽(Storage Slots)中的,从槽位 0 开始,依次是槽位 1、槽位 2...
-
一个变量存储在哪个槽位,完全取决于它在合约代码中的声明顺序和类型。
-
-
冲突的产生:
-
当 Proxy 合约使用
delegatecall调用 Logic 合约的函数并尝试修改状态时,Logic 合约的代码会按照它自己的变量声明顺序来计算要写入的存储槽位。 -
然而,实际写入的却是 Proxy 合约的存储。
-
如果 Proxy 合约和 Logic 合约的状态变量声明顺序和类型不完全匹配(即它们的存储布局不一样),那么 Logic 合约本想修改的变量 A,可能会错误地写入到 Proxy 合约的变量 B 所在的存储槽位,导致数据覆盖,这就是存储冲突。
-
示例说明
假设有两个合约:代理合约 (Proxy) 和 逻辑合约 (Logic)。
代理合约 (Proxy) 的存储布局:
-
Slot 0:
address admin -
Slot 1:
uint256 count
逻辑合约 V1 (Logic V1) 的存储布局:
-
Slot 0:
address admin -
Slot 1:
uint256 count
此时,Proxy 和 Logic V1 在 Slot 0 和 Slot 1 的变量类型和顺序都匹配,因此不会有冲突。
逻辑合约 V2 (Logic V2) 的存储布局(升级后):
-
Slot 0:
address admin -
Slot 1:
uint256 newFeatureFlag👈 新增变量 -
Slot 2:
uint256 count
当 Proxy 使用 delegatecall 执行 Logic V2 的代码,尝试修改 count (Logic V2 的 Slot 2) 时:
-
Logic V2 的代码认为
count在 Slot 2。 -
它会尝试写入 Proxy 的 Slot 2。
-
但 Proxy 自身只声明了 Slot 0 和 Slot 1,Slot 2 可能是空的,或者更糟的是,如果 Proxy 在 Slot 2 存储了其他重要数据,那么
count的值就会覆盖这个重要数据。
如何避免存储冲突?
在设计可升级合约(Proxy Pattern)时,避免存储冲突是至关重要的:
-
保持存储布局一致: 确保代理合约和所有逻辑合约(V1, V2, V3...)的前导状态变量必须在完全相同的位置。
-
使用
__gap变量: OpenZeppelin 等标准升级方案会在代理合约和逻辑合约的末尾使用一个填充数组(如uint256[50] __gap;),以确保未来的升级中如果逻辑合约新增了状态变量,它们会填充到这个__gap区域,而不会影响到代理合约已有的存储槽位。 -
使用非结构化存储(Unstructured Storage): 在代理合约中,将 logicAddress 和 adminAddress 存储在一个极高位或使用特定哈希计算的存储槽中,以最大程度地避免与其他逻辑变量发生意外冲突。
最佳实践
EIP-1967 提出了一种标准化的方法来存储关键信息,如 Logic 合约的地址,到固定且已知的存储位置。这主要包括两个方面:
-
逻辑合约地址(implementation address):存储在 bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) 槽位。
-
管理员地址(admin address):存储在 bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) 槽位
contract ProxyWithoutStorageCollision {
bytes32 private constant logicContractSlot = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
bytes32 private constant adminSlot = bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);
constructor(address _logic, address _admin) {
// 设置逻辑合约槽位的值
bytes32 slot = logicContractSlot;
assembly {
sstore(slot, _logic)
}
// 设置管理员槽位的值
slot = adminSlot;
assembly {
sstore(slot, _admin)
}
}
fallback() external payable {
// 取出逻辑合约槽位的值
bytes32 slot = logicContractSlot;
address logic;
assembly {
logic := sload(slot)
}
// delegatecall调用逻辑合约的函数
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), logic, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}

浙公网安备 33010602011771号