当你的合约需要和链上其他合约交互时,该怎么做?本篇介绍:
在讲调用之前,先认识两个特殊函数。它们是合约的"后门入口",在接收 ETH 和处理合约中不存在的函数调用时非常重要。
当合约收到纯 ETH 转账(没有附带任何数据)时,receive 会被触发。
receive() external payable {
// 处理收到的 ETH
}
语法特点:没有 function 关键字,没有参数,没有返回值,必须是 external payable。
当调用合约时,如果找不到匹配的函数,就会触发 fallback。
// 基础形式
fallback() external payable {}
// 带参数形式(可以访问 calldata 并返回数据)
fallback(bytes calldata input) external payable returns (bytes memory) {
return abi.encode(input.length);
}
调用合约
│
▼
msg.data 是否为空?
/ \
是 否
/ \
▼ ▼
receive 存在? 函数签名匹配?
/ \ / \
是 否 是 否
/ \ / \
receive fallback 执行函数 fallback
简单记忆:纯转账走 receive,其他兜底走 fallback。
两样东西:合约地址 + 调用接口。
接口告诉编译器:这个地址上的合约有哪些函数可以调用。
interface IERC20 {
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
// ...其他函数
}
有了接口,就可以开始调用了。
这是最常用、最推荐的方式,类型安全,代码清晰。
import "./IERC20.sol";
contract MyContract {
function getBalance(address token, address account) public view returns (uint256) {
return IERC20(token).balanceOf(account);
}
}
核心语法:IERC20(token) 把地址"包装"成接口类型,然后就能像调用本地函数一样调用它。
如果你有目标合约的完整代码,可以直接用合约类型。
import "./ERC20.sol";
contract MyContract {
function getBalance(address token, address account) public view returns (uint256) {
return ERC20(token).balanceOf(account);
}
}
本质上和接口调用一样——都是告诉编译器"这个地址有哪些函数"。
区别:接口只声明函数签名,合约类型包含完整实现。如果只需要调用,用接口更轻量。
当你需要多次调用同一个合约时,可以把引用存为状态变量。
import "./IERC20.sol";
contract MyContract {
IERC20 public token; // 存储合约引用
constructor(address _token) {
token = IERC20(_token);
}
function getBalance(address account) public view returns (uint256) {
return token.balanceOf(account);
}
function doTransfer(address to, uint256 amount) public {
token.transfer(to, amount);
}
}
好处:代码更简洁,不用每次都传地址。
当你不知道目标合约的接口时,可以用底层的 call。
contract MyContract {
function getBalance(address token, address account) public returns (uint256) {
(bool success, bytes memory data) = token.call(
abi.encodeWithSignature("balanceOf(address)", account)
);
require(success, "Call failed");
return abi.decode(data, (uint256));
}
}
call 是 address 类型的底层方法,特点是:
什么时候用 call?
一般情况下,优先用接口调用,更安全更清晰。
| 方式 | 需要接口/合约代码 | 类型安全 | 使用场景 |
|---|---|---|---|
| 接口调用 | 需要接口 | ✅ | 日常开发首选 |
| 合约类型调用 | 需要完整代码 | ✅ | 有源码时可用 |
| 存储引用 | 需要接口 | ✅ | 频繁调用同一合约 |
| call 调用 | 不需要 | ❌ | ABI 未知或需要底层控制 |
本篇我们学习了:
receive 和 fallback 的触发逻辑下一篇,我们深入 call 的底层细节:如何构造 calldata、三种 ABI 编码方式的区别,以及如何正确处理返回值。
(bool success, bytes memory data) = targetAddress.call{
value: 0.001 ether, // 附带的 ETH(可选)
gas: 100000 // 指定 gas 上限(可选)
}(
calldata // 要发送的数据
);
各部分含义:
| 部分 | 说明 |
|---|---|
targetAddress |
目标合约地址 |
value |
附带的 ETH,目标函数需要是 payable |
gas |
转发的 gas 上限,不写则转发大部分剩余 gas |
calldata |
函数选择器 + ABI 编码的参数 |
success |
调用是否成功(目标没有 revert) |
data |
目标函数的返回值(原始 bytes) |
重要:call 永远不会 revert。即使目标合约出错,也只是返回 success = false。你必须自己检查并处理失败情况。
当你调用 token.transfer(to, amount) 时,EVM 收到的不是函数名,而是一串字节:
0xa9059cbb ← 函数选择器(4 字节)
000000000000000000000000recipient_address_here ← 参数1: to
0000000000000000000000000000000000000000000000000000000000000064 ← 参数2: amount
这串字节就是 calldata,由两部分组成:
bytes4(keccak256("transfer(address,uint256)"))用函数签名字符串构造:
bytes memory data = abi.encodeWithSignature(
"transfer(address,uint256)", // 函数签名
to, // 参数1
amount // 参数2
);
注意:签名字符串中参数类型用逗号分隔,不能有空格,也不包含返回值类型。
用函数选择器构造:
bytes memory data = abi.encodeWithSelector(
IERC20.transfer.selector, // 4 字节选择器
to,
amount
);
两者本质相同:
abi.encodeWithSignature(sig, args...)
≡
abi.encodeWithSelector(bytes4(keccak256(bytes(sig))), args...)
Solidity 0.8.11 引入,类型安全:
bytes memory data = abi.encodeCall(
IERC20.transfer, // 函数指针,通常来自接口、合约名的函数成员
(to, amount) // 参数元组
);
关键区别:编译器会检查参数类型是否匹配函数定义。如果你传错类型,编译时就会报错。
| 方式 | 类型检查 | 重构友好 | 推荐场景 |
|---|---|---|---|
encodeWithSignature |
❌ | ❌ | 快速调试、实验 |
encodeWithSelector |
❌ | ❌ | 只有 selector 时 |
encodeCall |
✅ | ✅ | 生产代码首选 |
为什么 encodeCall 更好?
// 假设接口改了:transfer(address,uint256) → transfer(address,uint128)
// encodeWithSignature:编译通过,运行时静默失败
abi.encodeWithSignature("transfer(address,uint256)", to, amount);
// encodeCall:编译报错,立即发现问题
abi.encodeCall(IERC20.transfer, (to, amount));
call 返回的 data 是原始 bytes,需要用 abi.decode 解析:
(bool success, bytes memory returnData) = token.call(data);
require(success, "Call failed");
bool result = abi.decode(returnData, (bool));
实际开发中的坑:有些非标准 ERC20 代币的 transfer 不返回值,但执行成功。直接 abi.decode 会失败。
兼容写法:
function _parseBoolReturn(bool success, bytes memory returnData) internal pure returns (bool) {
if (!success) return false;
if (returnData.length == 0) return true; // 兼容非标准 ERC20
return abi.decode(returnData, (bool));
}
call 返回 success = false 的情况:
revert注意:调用一个不存在的函数,如果目标合约有 fallback,success 可能是 true!
正确的错误处理:
(bool success, bytes memory returnData) = target.call(data);
if (!success) {
if (returnData.length > 0) {
// 转发 revert 原因
assembly {
revert(add(returnData, 32), mload(returnData))
}
} else {
revert("Call failed without reason");
}
}
除了调用函数,call 也是发送 ETH 的推荐方式:
// 纯转账,不调用任何函数
(bool success, ) = recipient.call{value: 1 ether}("");
require(success, "Transfer failed");
为什么不用 transfer 或 send?因为它们有 2300 gas 限制,可能导致接收方的 receive 函数执行失败。
下面这个合约完整演示了四种转账方式,可以部署后实际对比:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "./IERC20.sol";
contract TransferViaCall {
event LowLevelCall(bool success, bytes returnData);
// 1)接口直接调用
function transferViaInterface(address token, address recipient, uint256 amount) external returns (bool) {
return IERC20(token).transfer(recipient, amount);
}
// 2)encodeWithSignature
function transferViaSignature(address token, address recipient, uint256 amount) external returns (bool) {
bytes memory data = abi.encodeWithSignature("transfer(address,uint256)", recipient, amount);
(bool success, bytes memory returnData) = token.call(data);
emit LowLevelCall(success, returnData);
return _parseBoolReturn(success, returnData);
}
// 3)encodeWithSelector
function transferViaSelector(address token, address recipient, uint256 amount) external returns (bool) {
bytes memory data = abi.encodeWithSelector(IERC20.transfer.selector, recipient, amount);
(bool success, bytes memory returnData) = token.call(data);
emit LowLevelCall(success, returnData);
return _parseBoolReturn(success, returnData);
}
// 4)encodeCall(推荐)
function transferViaEncodeCall(address token, address recipient, uint256 amount) external returns (bool) {
bytes memory data = abi.encodeCall(IERC20.transfer, (recipient, amount));
(bool success, bytes memory returnData) = token.call(data);
emit LowLevelCall(success, returnData);
return _parseBoolReturn(success, returnData);
}
/// @dev 兼容标准和非标准 ERC20
function _parseBoolReturn(bool success, bytes memory returnData) internal pure returns (bool) {
if (!success) return false;
if (returnData.length == 0) return true; // 非标准 ERC20
return abi.decode(returnData, (bool));
}
}
TransferViaCall 合约转一些代币returnDatareturnData 是 32 字节,解码后为 truereturnData 可能为空(长度为 0),但 success = true这就是为什么 _parseBoolReturn 要先检查长度——直接 decode 空数据会报错。
本篇我们学习了:
call 的完整语法和各参数含义abi.encodeCallTransferViaCall 实验验证所学下一篇,我们学习如何创建合约:create 和 create2 的区别,以及如何预测合约地址。
EVM 提供两个操作码来创建合约:
| 操作码 | 地址计算方式 | 特点 |
|---|---|---|
CREATE |
部署者地址 + nonce | 地址不可预测 |
CREATE2 |
部署者地址 + salt + initcode | 地址可预测 |
语法很简单,就是 new 一个合约:
Contract x = new Contract{value: _value}(params);
Contract:要创建的合约名x:返回的合约实例(本质是地址)value:附带的 ETH(构造函数需要是 payable)params:构造函数参数// Pair.sol
contract Pair {
address public factory;
address public token0;
address public token1;
constructor() {
factory = msg.sender;
}
function initialize(address _token0, address _token1) external {
require(msg.sender == factory, "FORBIDDEN");
token0 = _token0;
token1 = _token1;
}
}
// PairFactory.sol
import "./Pair.sol";
contract PairFactory {
mapping(address => mapping(address => address)) public getPair;
function createPair(address tokenA, address tokenB) external returns (address) {
Pair pair = new Pair(); // 创建新合约
pair.initialize(tokenA, tokenB); // 初始化
getPair[tokenA][tokenB] = address(pair);
getPair[tokenB][tokenA] = address(pair);
return address(pair);
}
}
问题:每次调用 createPair,得到的地址都不同,无法提前预测。
CREATE2 的地址由四个因素决定:
地址 = keccak256(0xff ++ deployer ++ salt ++ keccak256(initcode))[12:]
| 因素 | 说明 |
|---|---|
0xff |
固定前缀 |
deployer |
部署者地址(通常是工厂合约) |
salt |
32 字节的盐值,由开发者指定 |
initcode |
创建代码 + 构造函数参数 |
只要这四个因素相同,地址就相同——可以在部署前预测地址。
语法
Contract x = new Contract{salt: salt}(params);
只需加一个 salt 参数。
contract PairFactory {
mapping(address => mapping(address => address)) public getPair;
function createPair(address tokenA, address tokenB) external returns (address) {
// 排序,确保同一对 token 的 salt 一致
(address token0, address token1) = tokenA < tokenB
? (tokenA, tokenB)
: (tokenB, tokenA);
// 用 token 地址生成 salt
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
// 使用 CREATE2 部署
Pair pair = new Pair{salt: salt}();
pair.initialize(tokenA, tokenB);
getPair[tokenA][tokenB] = address(pair);
getPair[tokenB][tokenA] = address(pair);
return address(pair);
}
}
无需调用 createPair,就能计算出 Pair 的地址:
function predictPair(address tokenA, address tokenB) external view returns (address) {
(address token0, address token1) = tokenA < tokenB
? (tokenA, tokenB)
: (tokenB, tokenA);
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
// initcode = 创建代码 + 构造函数参数
bytes32 initCodeHash = keccak256(
abi.encodePacked(type(Pair).creationCode) // Pair 无构造参数
);
// CREATE2 地址公式
return address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0xff),
address(this), // deployer
salt,
initCodeHash
)))));
}
为什么地址转换要写成 address(uint160(uint256(...)))?
CREATE2 计算出的是 32 字节哈希(bytes32),但以太坊地址只有 20 字节(160 bit)。合约地址取的是哈希的低 20 字节。
Solidity 不允许直接把 bytes32 强转成 address——这样做会有歧义:取高 20 字节还是低 20 字节?所以必须显式告诉编译器:
bytes32 h = keccak256(...);
uint256(h) // 第一步:把 bytes32 当作 256 位整数
uint160(uint256(h)) // 第二步:截断为 160 位(自动保留低 160 bit)
address(uint160(...)) // 第三步:把 160 位整数解释为 address
这是 Solidity 中"截断取低位"的标准写法,在处理哈希结果时经常用到。
两个容易混淆的概念:
| 概念 | 说明 | 获取方式 |
|---|---|---|
| initcode | 部署时执行的代码,包含构造函数 | type(C).creationCode |
| runtime code | 部署后存储在链上的代码 | type(C).runtimeCode |
initcode 执行完毕后,返回 runtime code,后者才是用户实际调用的代码。
CREATE2 地址计算用的是 initcode 的哈希。
如果合约有构造函数参数:
bytes memory initcode = abi.encodePacked(
type(Pair).creationCode,
abi.encode(constructorArg1, constructorArg2)
);
bytes32 initCodeHash = keccak256(initcode);
1.反事实部署
先计算地址 → 用户向该地址转账 → 之后再部署合约。
账户抽象钱包常用这种模式:用户先拿到钱包地址收款,真正需要时才部署钱包合约。
2.跨链确定性部署
在多条链上用相同参数部署,得到相同地址。跨链协议需要这个特性。
3.合约重建
销毁合约后,用相同参数可以在同一地址重新部署(需要 selfdestruct)。
1. 同地址只能部署一次
同样的 deployer + salt + initcode 组合,第二次部署会失败。
2. value 不影响地址
new Pair{salt: salt, value: 1 ether}() 中的 value 不参与地址计算。
3. deployer 是工厂合约
在 PairFactory.createPair() 中调用 new Pair{salt: ...}(),deployer 是 PairFactory 的地址,而不是调用 createPair 的用户。
4. 任何因素变化都会改变地址
工厂地址、salt、合约代码、构造参数——任何一个变化,地址都会不同。
如果需要部署任意字节码,可以用 assembly:
// CREATE
function rawCreate(bytes memory bytecode) external returns (address addr) {
assembly {
addr := create(0, add(bytecode, 0x20), mload(bytecode))
if iszero(addr) { revert(0, 0) }
}
}
// CREATE2
function rawCreate2(bytes memory bytecode, bytes32 salt) external returns (address addr) {
assembly {
addr := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
if iszero(addr) { revert(0, 0) }
}
}
参数含义:create(value, offset, size) / create2(value, offset, size, salt)
2024 年坎昆升级(EIP-6780)限制了 selfdestruct:
这意味着"销毁后重建"的模式基本不再可行。
本篇我们学习了:
CREATE 和 CREATE2 的区别address(uint160(uint256(...))))至此,合约间调用系列完结。你已经掌握了:
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!