token

Cyber-GE

第 4 章 · 约 18 分钟阅读

合约交互与继承

Interaction and Inheritance

第 4 章:合约交互与继承

智能合约不是孤岛。理解合约之间的调用方式和代码复用机制,是构建复杂 DeFi 系统的基础。


外部调用

合约之间的交互通过消息调用实现。当合约 A 调用合约 B 的函数时,EVM 会创建一个新的执行上下文,切换到合约 B 的代码和存储空间执行,完成后返回结果给合约 A。

合约调用流程

调用方式

Solidity 提供了几种不同的调用方式,它们在安全性和灵活性上各有取舍。

最常见的方式是通过接口或合约类型直接调用。这种方式类型安全,编译器会检查函数签名是否匹配。

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

contract TokenUser {
    IERC20 public token;
    
    constructor(address tokenAddress) {
        token = IERC20(tokenAddress);
    }
    
    function sendTokens(address to, uint256 amount) external {
        bool success = token.transfer(to, amount);  // 类型安全的调用
        require(success, "Transfer failed");
    }
}

如果目标合约的接口未知,或者需要更底层的控制,可以使用 call 进行低级调用。call 返回一个布尔值表示调用是否成功,以及返回数据的字节数组。

contract LowLevelCaller {
    function callTransfer(address token, address to, uint256 amount) external returns (bool) {
        // 编码函数调用
        bytes memory data = abi.encodeWithSignature(
            "transfer(address,uint256)",
            to,
            amount
        );
        
        // 低级调用
        (bool success, bytes memory returnData) = token.call(data);
        
        if (success && returnData.length > 0) {
            return abi.decode(returnData, (bool));
        }
        return false;
    }
}

低级调用的一个重要特性是:即使目标合约不存在或调用失败,它也不会自动回滚。调用者必须显式检查返回值并决定如何处理失败情况。这与直接调用不同——直接调用在失败时会自动回滚整个交易。

delegatecall

delegatecall 是一种特殊的调用方式。它执行目标合约的代码,但使用调用者的存储空间和上下文。这意味着 msg.sendermsg.value 保持不变,状态变量的修改发生在调用者的存储中。

delegatecall 执行上下文

contract Implementation {
    uint256 public value;  // 槽 0
    
    function setValue(uint256 _value) external {
        value = _value;  // 修改槽 0
    }
}

contract Proxy {
    uint256 public value;  // 槽 0,与 Implementation 布局相同
    address public implementation;
    
    constructor(address _impl) {
        implementation = _impl;
    }
    
    fallback() external payable {
        address impl = implementation;
        assembly {
            // 复制 calldata
            calldatacopy(0, 0, calldatasize())
            // delegatecall 到实现合约
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            // 复制返回数据
            returndatacopy(0, 0, returndatasize())
            // 根据结果返回或回滚
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

delegatecall 是代理模式的基础,但也是最危险的调用方式之一。如果实现合约的存储布局与代理合约不匹配,会导致存储冲突,可能造成严重的安全问题。

staticcall

staticcall 用于只读调用。它保证被调用的函数不会修改任何状态。如果被调用的代码尝试写入存储、发送 ETH 或创建合约,调用会失败。

contract ReadOnlyCaller {
    function safeGetBalance(address token, address account) external view returns (uint256) {
        bytes memory data = abi.encodeWithSignature("balanceOf(address)", account);
        
        // staticcall 保证不会修改状态
        (bool success, bytes memory returnData) = token.staticcall(data);
        
        require(success, "Static call failed");
        return abi.decode(returnData, (uint256));
    }
}

当函数声明为 viewpure 时,Solidity 编译器会自动使用 staticcall 进行外部调用。


接收 ETH

合约接收 ETH 需要特殊处理。有两个特殊函数用于此目的:receivefallback

receive 函数在合约收到纯 ETH 转账(没有 calldata)时被调用。它必须是 external payable,不能有参数和返回值。

contract ETHReceiver {
    event Received(address sender, uint256 amount);
    
    receive() external payable {
        emit Received(msg.sender, msg.value);
    }
}

fallback 函数在两种情况下被调用:收到 ETH 但没有 receive 函数,或者调用了不存在的函数。如果 fallback 声明为 payable,它也可以接收 ETH。

contract FallbackExample {
    event FallbackCalled(address sender, uint256 value, bytes data);
    
    fallback() external payable {
        emit FallbackCalled(msg.sender, msg.value, msg.data);
    }
}

调用顺序是:如果有 calldata,调用 fallback;如果没有 calldata 且存在 receive,调用 receive;否则调用 fallback(如果存在且 payable)。

发送 ETH

发送 ETH 有三种方式,它们的 Gas 限制和错误处理方式不同。

transfer 发送固定 2300 Gas,失败时自动回滚。这个 Gas 限制只够执行一个事件日志,不足以执行复杂逻辑。

function sendViaTransfer(address payable recipient) external payable {
    recipient.transfer(msg.value);  // 失败自动回滚
}

send 也发送 2300 Gas,但失败时返回 false 而不是回滚。

function sendViaSend(address payable recipient) external payable {
    bool success = recipient.send(msg.value);
    require(success, "Send failed");
}

call 是推荐的方式。它转发所有可用 Gas(或指定数量),返回成功状态和返回数据。

function sendViaCall(address payable recipient) external payable {
    (bool success, ) = recipient.call{value: msg.value}("");
    require(success, "Call failed");
}

由于 EIP-1884 提高了某些操作码的 Gas 成本,2300 Gas 限制可能不足以让接收合约执行 receive 函数。推荐使用 call 发送 ETH。


继承

Solidity 支持多重继承,允许合约从多个父合约继承状态变量和函数。继承使用 is 关键字。

contract Ownable {
    address public owner;
    
    constructor() {
        owner = msg.sender;
    }
    
    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }
}

contract Pausable is Ownable {
    bool public paused;
    
    modifier whenNotPaused() {
        require(!paused, "Paused");
        _;
    }
    
    function pause() external onlyOwner {
        paused = true;
    }
    
    function unpause() external onlyOwner {
        paused = false;
    }
}

contract Token is Pausable {
    mapping(address => uint256) public balances;
    
    function transfer(address to, uint256 amount) external whenNotPaused {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

Token 合约继承了 Pausable 的所有功能,而 Pausable 又继承了 Ownable。这形成了一个继承链:Token -> Pausable -> Ownable

函数重写

子合约可以重写父合约的函数。父合约的函数必须标记为 virtual,子合约的重写函数必须标记为 override

contract Base {
    function greet() public virtual returns (string memory) {
        return "Hello from Base";
    }
}

contract Child is Base {
    function greet() public virtual override returns (string memory) {
        return "Hello from Child";
    }
}

contract GrandChild is Child {
    function greet() public override returns (string memory) {
        return string.concat(super.greet(), " and GrandChild");
    }
}

super 关键字用于调用父合约的函数。在多重继承中,super 会按照 C3 线性化顺序调用所有父合约的同名函数。

多重继承

当合约从多个父合约继承时,需要注意继承顺序。Solidity 使用 C3 线性化算法确定函数调用顺序。基本规则是:从最基础的合约到最派生的合约,从右到左。

contract A {
    function foo() public virtual returns (string memory) {
        return "A";
    }
}

contract B is A {
    function foo() public virtual override returns (string memory) {
        return string.concat(super.foo(), "B");
    }
}

contract C is A {
    function foo() public virtual override returns (string memory) {
        return string.concat(super.foo(), "C");
    }
}

// 继承顺序:A -> B -> C -> D
contract D is B, C {
    function foo() public override(B, C) returns (string memory) {
        return string.concat(super.foo(), "D");  // 返回 "ABCD"
    }
}

D 中调用 super.foo() 会触发整个继承链:D.foo() -> C.foo() -> B.foo() -> A.foo()

构造函数继承

如果父合约的构造函数有参数,子合约必须提供这些参数。有两种方式:

contract Parent {
    uint256 public value;
    
    constructor(uint256 _value) {
        value = _value;
    }
}

// 方式 1:在继承列表中指定
contract Child1 is Parent(100) {
    // value 被初始化为 100
}

// 方式 2:在构造函数中指定
contract Child2 is Parent {
    constructor(uint256 _value) Parent(_value) {
        // 可以使用动态值
    }
}

抽象合约与接口

抽象合约包含至少一个未实现的函数。它不能被直接部署,只能被继承。

abstract contract Animal {
    string public name;
    
    constructor(string memory _name) {
        name = _name;
    }
    
    // 抽象函数,子合约必须实现
    function speak() public virtual returns (string memory);
    
    // 具体函数,子合约可以直接使用
    function getName() public view returns (string memory) {
        return name;
    }
}

contract Dog is Animal {
    constructor() Animal("Dog") {}
    
    function speak() public pure override returns (string memory) {
        return "Woof!";
    }
}

接口是更严格的抽象。接口不能有状态变量、构造函数或函数实现。所有函数必须是 external

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
}

接口定义了合约的外部 API,是合约之间交互的契约。ERC-20、ERC-721 等标准都是以接口形式定义的。


库是一种特殊的合约,用于代码复用。库不能有状态变量(除了常量),不能继承或被继承,不能接收 ETH。

library SafeMath {
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a, "SafeMath: addition overflow");
        return c;
    }
    
    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b <= a, "SafeMath: subtraction overflow");
        return a - b;
    }
}

contract Calculator {
    using SafeMath for uint256;  // 附加库函数到类型
    
    function calculate(uint256 a, uint256 b) external pure returns (uint256) {
        return a.add(b);  // 等价于 SafeMath.add(a, b)
    }
}

using A for B 语法将库 A 的函数附加到类型 B。调用时,类型 B 的值会作为第一个参数传入。

库函数可以是 internalexternalinternal 函数会被内联到调用合约中,不产生外部调用开销。external 函数需要部署库合约,通过 delegatecall 调用。

library ArrayUtils {
    // internal 函数,会被内联
    function sum(uint256[] memory arr) internal pure returns (uint256) {
        uint256 total = 0;
        for (uint256 i = 0; i < arr.length; i++) {
            total += arr[i];
        }
        return total;
    }
    
    // external 函数,需要部署库
    function sort(uint256[] storage arr) external {
        // 原地排序
        for (uint256 i = 0; i < arr.length; i++) {
            for (uint256 j = i + 1; j < arr.length; j++) {
                if (arr[i] > arr[j]) {
                    (arr[i], arr[j]) = (arr[j], arr[i]);
                }
            }
        }
    }
}

工厂模式

工厂合约用于动态创建其他合约。这在需要部署多个相似合约时非常有用,比如 Uniswap 的交易对工厂。

contract Token {
    string public name;
    string public symbol;
    address public owner;
    
    constructor(string memory _name, string memory _symbol, address _owner) {
        name = _name;
        symbol = _symbol;
        owner = _owner;
    }
}

contract TokenFactory {
    address[] public tokens;
    
    event TokenCreated(address indexed token, string name, string symbol);
    
    function createToken(string memory name, string memory symbol) external returns (address) {
        Token token = new Token(name, symbol, msg.sender);
        tokens.push(address(token));
        emit TokenCreated(address(token), name, symbol);
        return address(token);
    }
    
    function getTokenCount() external view returns (uint256) {
        return tokens.length;
    }
}

CREATE2

普通的 new 使用 CREATE 操作码,合约地址由部署者地址和 nonce 决定。CREATE2 允许预先计算合约地址,地址由部署者地址、salt 和合约字节码的哈希决定。

contract Create2Factory {
    event Deployed(address addr, bytes32 salt);
    
    function deploy(bytes32 salt, bytes memory bytecode) external returns (address) {
        address addr;
        assembly {
            addr := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
            if iszero(extcodesize(addr)) {
                revert(0, 0)
            }
        }
        emit Deployed(addr, salt);
        return addr;
    }
    
    function computeAddress(bytes32 salt, bytes memory bytecode) external view returns (address) {
        return address(uint160(uint256(keccak256(abi.encodePacked(
            bytes1(0xff),
            address(this),
            salt,
            keccak256(bytecode)
        )))));
    }
}

CREATE2 的主要用途包括:

  • 反事实部署:在合约部署前就知道地址,可以先向该地址转账
  • 确定性地址:相同的 salt 和字节码总是产生相同的地址
  • 合约升级:销毁旧合约后,可以在同一地址部署新合约

总结

合约交互是 DeFi 生态系统的基础。理解不同调用方式的特性——call 的灵活性、delegatecall 的上下文保持、staticcall 的只读保证——对于编写安全的合约至关重要。继承和库提供了代码复用的机制,而工厂模式则支持动态合约部署。

但是,合约交互也带来了安全风险。外部调用可能触发重入攻击,delegatecall 可能导致存储冲突,不当的继承顺序可能产生意外行为。下一章将深入讨论这些安全问题以及如何防范。


参考文献

Qián - The Creative

卦辞 · Judgment

"元亨利贞。"

象曰 · Image

天行健,君子以自强不息。

今日启示 · Insight

创造力与领导力的时刻。保持正直,大事可成。