Solidity 合约间调用详解

当你的合约需要和链上其他合约交互时,该怎么做?本篇介绍:

  1. 如何调用其他合约;
  2. 底层 call 的用法和 calldata 构造;
  3. 如何在合约中创建新合约;

(一):四种方式调用已部署合约

前置知识:receive 和 fallback

在讲调用之前,先认识两个特殊函数。它们是合约的"后门入口",在接收 ETH 和处理合约中不存在的函数调用时非常重要。

receive:专门接收 ETH

当合约收到纯 ETH 转账(没有附带任何数据)时,receive 会被触发。

receive() external payable {
    // 处理收到的 ETH
}

语法特点:没有 function 关键字,没有参数,没有返回值,必须是 external payable

fallback:兜底函数

当调用合约时,如果找不到匹配的函数,就会触发 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 调用

当你不知道目标合约的接口时,可以用底层的 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));
    }
}

calladdress 类型的底层方法,特点是:

  • 需要自己构造 calldata
  • 需要自己检查 success
  • 需要自己解析返回值

什么时候用 call?

  • 目标合约的 ABI 未知
  • 需要更灵活的控制(如指定 gas、附带 ETH)

一般情况下,优先用接口调用,更安全更清晰。

四种方式对比

方式 需要接口/合约代码 类型安全 使用场景
接口调用 需要接口 日常开发首选
合约类型调用 需要完整代码 有源码时可用
存储引用 需要接口 频繁调用同一合约
call 调用 不需要 ABI 未知或需要底层控制

小结

本篇我们学习了:

  1. receivefallback 的触发逻辑
  2. 四种调用已部署合约的方式
  3. 日常开发推荐使用接口调用

下一篇,我们深入 call 的底层细节:如何构造 calldata、三种 ABI 编码方式的区别,以及如何正确处理返回值。

(二):底层调用与 calldata 详解

call 的完整语法

(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。你必须自己检查并处理失败情况。

calldata 是什么?

当你调用 token.transfer(to, amount) 时,EVM 收到的不是函数名,而是一串字节:

0xa9059cbb                                                        ← 函数选择器(4 字节)
000000000000000000000000recipient_address_here                    ← 参数1: to
0000000000000000000000000000000000000000000000000000000000000064  ← 参数2: amount

这串字节就是 calldata,由两部分组成:

  1. 函数选择器(4 字节):bytes4(keccak256("transfer(address,uint256)"))
  2. ABI 编码的参数:按顺序编码的参数值

三种构造 calldata 的方式

1. abi.encodeWithSignature

用函数签名字符串构造:

bytes memory data = abi.encodeWithSignature(
    "transfer(address,uint256)",  // 函数签名
    to,                           // 参数1
    amount                        // 参数2
);

注意:签名字符串中参数类型用逗号分隔,不能有空格,也不包含返回值类型

2. abi.encodeWithSelector

用函数选择器构造:

bytes memory data = abi.encodeWithSelector(
    IERC20.transfer.selector,  // 4 字节选择器
    to,
    amount
);

两者本质相同:

abi.encodeWithSignature(sig, args...) 
≡ 
abi.encodeWithSelector(bytes4(keccak256(bytes(sig))), args...)

3. abi.encodeCall(推荐)

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
  • gas 不足
  • 目标地址没有代码
  • 其他执行错误

注意:调用一个不存在的函数,如果目标合约有 fallbacksuccess 可能是 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

除了调用函数,call 也是发送 ETH 的推荐方式:

// 纯转账,不调用任何函数
(bool success, ) = recipient.call{value: 1 ether}("");
require(success, "Transfer failed");

为什么不用 transfersend?因为它们有 2300 gas 限制,可能导致接收方的 receive 函数执行失败。

动手实验:TransferViaCall

下面这个合约完整演示了四种转账方式,可以部署后实际对比:

// 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));
    }
}

实验步骤

  1. 部署一个 ERC20 代币合约(合约可参考:IERC20.solERC20.sol
  2. 给自己 mint 一些代币
  3. TransferViaCall 合约转一些代币
  4. 调用四个 transfer 函数,观察事件日志中的 returnData

观察结果

  • 标准 ERC20returnData 是 32 字节,解码后为 true
  • 非标准 ERC20returnData 可能为空(长度为 0),但 success = true

这就是为什么 _parseBoolReturn 要先检查长度——直接 decode 空数据会报错。

小结

本篇我们学习了:

  1. call 的完整语法和各参数含义
  2. calldata 的结构:选择器 + ABI 编码参数
  3. 三种构造 calldata 的方式,推荐 abi.encodeCall
  4. 如何正确处理返回值和调用失败
  5. 通过 TransferViaCall 实验验证所学

下一篇,我们学习如何创建合约:createcreate2 的区别,以及如何预测合约地址。

(三):创建合约的两种方式:create 与 create2

两种创建方式

EVM 提供两个操作码来创建合约:

操作码 地址计算方式 特点
CREATE 部署者地址 + nonce 地址不可预测
CREATE2 部署者地址 + salt + initcode 地址可预测

CREATE:基础创建

语法很简单,就是 new 一个合约:

Contract x = new Contract{value: _value}(params);
  • Contract:要创建的合约名
  • x:返回的合约实例(本质是地址)
  • value:附带的 ETH(构造函数需要是 payable
  • params:构造函数参数

示例:简易 Pair 工厂

// 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:确定性创建

CREATE2 的地址由四个因素决定:

地址 = keccak256(0xff ++ deployer ++ salt ++ keccak256(initcode))[12:]
因素 说明
0xff 固定前缀
deployer 部署者地址(通常是工厂合约)
salt 32 字节的盐值,由开发者指定
initcode 创建代码 + 构造函数参数

只要这四个因素相同,地址就相同——可以在部署前预测地址

语法

Contract x = new Contract{salt: salt}(params);

只需加一个 salt 参数。

示例:可预测地址的 Pair 工厂

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

两个容易混淆的概念:

概念 说明 获取方式
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);

CREATE2 的应用场景

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)

selfdestruct 的变化

2024 年坎昆升级(EIP-6780)限制了 selfdestruct

  • 同一笔交易内:仍可删除代码和存储
  • 之后的交易:只转移 ETH,代码和存储保留

这意味着"销毁后重建"的模式基本不再可行。

小结

本篇我们学习了:

  1. CREATECREATE2 的区别
  2. CREATE2 地址的计算公式
  3. 如何预测合约地址(以及为什么地址转换要写成 address(uint160(uint256(...)))

至此,合约间调用系列完结。你已经掌握了:

  • 如何调用其他合约(四种方式)
  • 底层 call 的用法和 calldata 构造
  • 如何在合约中创建新合约

更多内容:https://lifefindsitsway.wiki/

点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Lifefindsitsway
Lifefindsitsway
江湖只有他的大名,没有他的介绍。