返回 SC 笔记
SC Day 19

Solidity - receive/fallback + payable + msg.value + 合约间调用 + Crowdfunding合约

### 1. 合约接收 ETH 的两种特殊函数

2026-04-19
第一阶段:基础构建
soliditypayablereceivefallbackmsg-valuecrowdfundingcontract-interaction

日期: 2026-04-19 方向: Solidity 阶段: 第一阶段:基础构建 标签: #solidity #payable #receive #fallback #msg-value #crowdfunding #contract-interaction


今日目标

类型内容
学习掌握 receive/fallback 的触发条件、payable 修饰符、msg.value 的工作原理、三种发送 ETH 方式的区别、合约间调用
实操实现完整的 Crowdfunding 合约(含贡献/退款/提取/截止日期/目标金额)
产出ETH 处理完整知识图谱 + 生产级众筹合约 + 安全分析

核心概念

1. 合约接收 ETH 的两种特殊函数

Solidity 合约默认不能接收 ETH。如果有人直接向合约地址转 ETH(不调用任何函数),交易会 revert。要让合约能接收 ETH,需要实现 receive()fallback() 函数。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract EthReceiver {
    event Received(address indexed sender, uint256 amount);
    event FallbackCalled(address indexed sender, uint256 amount, bytes data);

    /// @notice 当合约收到纯 ETH 转账(msg.data 为空)时触发
    /// @dev 不能有参数,不能返回值,必须是 external payable
    receive() external payable {
        emit Received(msg.sender, msg.value);
    }

    /// @notice 当调用不存在的函数,或发送 ETH 时 msg.data 不为空且没有 receive 时触发
    /// @dev 可以有 payable,也可以没有
    fallback() external payable {
        emit FallbackCalled(msg.sender, msg.value, msg.data);
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

receive() vs fallback() 触发规则

决策流程如下(这是面试高频考点):

                        有人向合约发送 ETH 或调用函数
                                    |
                            msg.data 是否为空?
                           /                \
                         是                  否
                         |                    |
                   receive() 存在?      函数签名匹配?
                  /            \          /         \
                是             否        是          否
                |               |        |           |
          调用 receive()    fallback()  调用对应     fallback()
                            存在?      函数        存在?
                           /      \               /      \
                         是       否            是        否
                         |        |             |         |
                    调用 fallback  revert    调用 fallback  revert

关键区别总结

特性receive()fallback()
触发条件msg.data 为空(纯转账)msg.data 不为空 或 没有 receive
参数不能有不能有
返回值不能有不能有
payable必须可选(想接收 ETH 就加)
Gas 限制2300 gas(通过 transfer/send 调用时)2300 gas(通过 transfer/send 调用时)
典型用途接收纯 ETH 转账代理合约、处理未知调用

2. payable 修饰符与 msg.value

payable 告诉编译器"这个函数可以接收 ETH"。没有 payable 的函数如果收到 ETH 会 revert。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract PayableDemo {
    mapping(address => uint256) public deposits;

    /// @notice payable 函数可以接收 ETH
    /// msg.value 是本次调用发送的 ETH 数量(单位:wei)
    function deposit() external payable {
        require(msg.value > 0, "Must send ETH");
        deposits[msg.sender] += msg.value;
    }

    /// @notice 没有 payable —— 如果发送 ETH 会 revert
    function getDeposit(address user) external view returns (uint256) {
        return deposits[user];
    }

    /// @notice 常用的 msg 全局变量
    function showMsgFields() external payable returns (
        address sender,      // 调用者地址
        uint256 value,       // 发送的 ETH(wei)
        bytes calldata data, // 调用数据
        uint256 gasLeft      // 剩余 gas
    ) {
        return (msg.sender, msg.value, msg.data, gasleft());
    }
}

msg.value 的关键特性

  • 单位是 wei(1 ETH = 10^18 wei)
  • 在整个调用链中保持不变(A 调用 B,B 中的 msg.value 还是 A 发送的金额)
  • 一旦函数执行开始,ETH 已经转入合约,即使后续 revert 也会退回
  • 陷阱:在循环中多次检查 msg.value 不会每次扣减,它是一个固定值
// 经典陷阱:msg.value 重入
function batchDeposit(address[] calldata recipients) external payable {
    uint256 perPerson = msg.value / recipients.length;
    for (uint256 i = 0; i < recipients.length; i++) {
        // 不要这样写!每次迭代 msg.value 都是同一个值
        // deposits[recipients[i]] += msg.value;  // 错误!会多计
        deposits[recipients[i]] += perPerson;      // 正确
    }
}

3. 发送 ETH 的三种方式

这是 Solidity 安全编程中最核心的知识点之一。三种方式各有利弊。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SendEthDemo {
    // ========== 方式 1: transfer ==========
    // 发送 2300 gas(只够触发 receive/fallback 中的事件)
    // 失败时自动 revert
    function sendViaTransfer(address payable to, uint256 amount) external {
        to.transfer(amount);
        // 如果失败,整个交易 revert,上面的状态修改也撤销
    }

    // ========== 方式 2: send ==========
    // 发送 2300 gas
    // 失败时返回 false,不会 revert
    function sendViaSend(address payable to, uint256 amount) external {
        bool success = to.send(amount);
        require(success, "Send failed");
        // 必须检查返回值!不检查就是静默失败
    }

    // ========== 方式 3: call(推荐) ==========
    // 转发所有可用 gas(可自定义)
    // 失败时返回 false,不会 revert
    // 可以附带 calldata 调用目标合约函数
    function sendViaCall(address payable to, uint256 amount) external {
        (bool success, bytes memory data) = to.call{value: amount}("");
        require(success, "Call failed");
    }

    receive() external payable {}
}

三种方式对比

特性transfersendcall
Gas 转发2300 (固定)2300 (固定)全部剩余 (可自定义)
失败行为自动 revert返回 false返回 (false, bytes)
重入风险低 (gas 不够)低 (gas 不够) (需要防护)
推荐程度不推荐不推荐推荐(配合重入防护)
兼容性可能因 EIP-1884 gas 变化而失败同上最好

为什么 call 是推荐方式?

2019 年的 Istanbul 硬分叉(EIP-1884)提高了 SLOAD 操作码的 gas 消耗。这导致一些合约的 receive() 函数需要超过 2300 gas 才能执行(比如需要读取状态变量记日志)。transfersend 固定只给 2300 gas,会导致这些合约无法接收 ETH。call 转发所有 gas,不存在这个问题。

但是 call 给了接收方执行任意代码的机会,所以必须配合重入防护使用。

4. 合约间调用

合约可以调用其他合约的函数,这是 DeFi 可组合性的基础。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title 价格预言机接口
interface IPriceOracle {
    function getPrice(string calldata symbol) external view returns (uint256);
    function updatePrice(string calldata symbol, uint256 price) external;
}

/// @title 简单价格预言机
contract SimplePriceOracle is IPriceOracle {
    mapping(string => uint256) private prices;
    address public admin;

    constructor() {
        admin = msg.sender;
    }

    function getPrice(string calldata symbol) external view override returns (uint256) {
        require(prices[symbol] > 0, "Price not set");
        return prices[symbol];
    }

    function updatePrice(string calldata symbol, uint256 price) external override {
        require(msg.sender == admin, "Only admin");
        prices[symbol] = price;
    }
}

/// @title 使用预言机的借贷合约
contract SimpleLending {
    IPriceOracle public oracle;
    mapping(address => uint256) public collateral;  // ETH 抵押品
    mapping(address => uint256) public debt;         // USD 债务

    uint256 public constant COLLATERAL_RATIO = 150;  // 150% 超额抵押
    uint256 public constant LIQUIDATION_RATIO = 120; // 120% 清算线

    event Deposited(address indexed user, uint256 amount);
    event Borrowed(address indexed user, uint256 amount);
    event Liquidated(address indexed user, address indexed liquidator);

    constructor(address _oracle) {
        oracle = IPriceOracle(_oracle);
    }

    /// @notice 存入 ETH 作为抵押品
    function deposit() external payable {
        require(msg.value > 0, "Must send ETH");
        collateral[msg.sender] += msg.value;
        emit Deposited(msg.sender, msg.value);
    }

    /// @notice 借出 USD(简化:只记录债务)
    function borrow(uint256 usdAmount) external {
        // 调用外部合约获取价格
        uint256 ethPriceUsd = oracle.getPrice("ETH");

        uint256 collateralValueUsd = (collateral[msg.sender] * ethPriceUsd) / 1e18;
        uint256 newDebt = debt[msg.sender] + usdAmount;
        uint256 requiredCollateral = (newDebt * COLLATERAL_RATIO) / 100;

        require(collateralValueUsd >= requiredCollateral, "Insufficient collateral");

        debt[msg.sender] = newDebt;
        emit Borrowed(msg.sender, usdAmount);
    }

    /// @notice 获取用户的健康因子(>100 安全,<100 可被清算)
    function healthFactor(address user) public view returns (uint256) {
        if (debt[user] == 0) return type(uint256).max;

        uint256 ethPriceUsd = oracle.getPrice("ETH");
        uint256 collateralValueUsd = (collateral[user] * ethPriceUsd) / 1e18;

        return (collateralValueUsd * 100) / debt[user];
    }
}

合约间调用的注意事项

  1. 外部调用可能失败——永远检查返回值或用 require
  2. 外部调用可能重入——被调用合约可以回调你的合约
  3. Gas 消耗不确定——外部合约可能消耗大量 gas
  4. msg.sender 会变化——B 合约中 msg.sender 是 A 合约地址,不是原始用户

代码实战:完整的 Crowdfunding 合约

一个生产级的众筹合约,包含所有 ETH 处理的最佳实践。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title Crowdfunding - 去中心化众筹合约
/// @notice 支持创建众筹项目,贡献资金,达标提取,未达标退款
/// @dev 使用 pull payment 模式防止重入攻击
contract Crowdfunding {
    // ========== 状态定义 ==========

    enum CampaignStatus {
        Active,     // 进行中
        Successful, // 达标
        Failed,     // 未达标(过期)
        Finalized   // 资金已提取
    }

    struct Campaign {
        address payable creator;     // 发起人
        string title;                // 项目标题
        string description;          // 项目描述
        uint256 goal;                // 目标金额(wei)
        uint256 deadline;            // 截止时间(时间戳)
        uint256 totalRaised;         // 已筹集金额
        uint256 contributorCount;    // 贡献者人数
        CampaignStatus status;       // 当前状态
        bool creatorWithdrawn;       // 发起人是否已提取
    }

    // 项目 ID => 项目信息
    mapping(uint256 => Campaign) public campaigns;
    // 项目 ID => 贡献者地址 => 贡献金额
    mapping(uint256 => mapping(address => uint256)) public contributions;
    // 项目 ID => 贡献者是否已退款
    mapping(uint256 => mapping(address => bool)) public hasRefunded;

    uint256 public campaignCount;
    uint256 public constant MIN_CONTRIBUTION = 0.01 ether;
    uint256 public constant MAX_DURATION = 90 days;
    uint256 public constant MIN_DURATION = 1 days;

    // ========== 事件 ==========

    event CampaignCreated(
        uint256 indexed campaignId,
        address indexed creator,
        string title,
        uint256 goal,
        uint256 deadline
    );

    event ContributionMade(
        uint256 indexed campaignId,
        address indexed contributor,
        uint256 amount,
        uint256 totalRaised
    );

    event RefundClaimed(
        uint256 indexed campaignId,
        address indexed contributor,
        uint256 amount
    );

    event FundsWithdrawn(
        uint256 indexed campaignId,
        address indexed creator,
        uint256 amount
    );

    event CampaignStatusChanged(
        uint256 indexed campaignId,
        CampaignStatus newStatus
    );

    // ========== 修饰符 ==========

    modifier campaignExists(uint256 _id) {
        require(_id < campaignCount, "Campaign does not exist");
        _;
    }

    modifier onlyCreator(uint256 _id) {
        require(msg.sender == campaigns[_id].creator, "Not campaign creator");
        _;
    }

    // ========== 核心函数 ==========

    /// @notice 创建新的众筹项目
    /// @param _title 项目标题
    /// @param _description 项目描述
    /// @param _goal 目标金额(wei)
    /// @param _durationDays 持续天数
    /// @return campaignId 新项目的 ID
    function createCampaign(
        string calldata _title,
        string calldata _description,
        uint256 _goal,
        uint256 _durationDays
    ) external returns (uint256 campaignId) {
        require(bytes(_title).length > 0, "Title required");
        require(_goal > 0, "Goal must be > 0");
        require(
            _durationDays * 1 days >= MIN_DURATION &&
            _durationDays * 1 days <= MAX_DURATION,
            "Duration out of range"
        );

        campaignId = campaignCount++;

        Campaign storage c = campaigns[campaignId];
        c.creator = payable(msg.sender);
        c.title = _title;
        c.description = _description;
        c.goal = _goal;
        c.deadline = block.timestamp + (_durationDays * 1 days);
        c.status = CampaignStatus.Active;

        emit CampaignCreated(campaignId, msg.sender, _title, _goal, c.deadline);
    }

    /// @notice 向项目贡献 ETH
    /// @param _id 项目 ID
    function contribute(uint256 _id) external payable campaignExists(_id) {
        Campaign storage c = campaigns[_id];

        require(c.status == CampaignStatus.Active, "Campaign not active");
        require(block.timestamp < c.deadline, "Campaign expired");
        require(msg.value >= MIN_CONTRIBUTION, "Below minimum contribution");
        require(msg.sender != c.creator, "Creator cannot contribute");

        // 如果是新贡献者,增加计数
        if (contributions[_id][msg.sender] == 0) {
            c.contributorCount++;
        }

        contributions[_id][msg.sender] += msg.value;
        c.totalRaised += msg.value;

        // 检查是否达标
        if (c.totalRaised >= c.goal) {
            c.status = CampaignStatus.Successful;
            emit CampaignStatusChanged(_id, CampaignStatus.Successful);
        }

        emit ContributionMade(_id, msg.sender, msg.value, c.totalRaised);
    }

    /// @notice 项目失败时,贡献者领取退款(Pull Payment 模式)
    /// @dev Pull 模式:由用户主动领取,而非合约批量推送
    ///      这样即使某个用户的地址是恶意合约也不会阻塞其他人
    /// @param _id 项目 ID
    function claimRefund(uint256 _id) external campaignExists(_id) {
        Campaign storage c = campaigns[_id];

        // 必须过期且未达标
        require(block.timestamp >= c.deadline, "Campaign still active");
        require(c.totalRaised < c.goal, "Campaign was successful");
        require(!hasRefunded[_id][msg.sender], "Already refunded");

        uint256 contributed = contributions[_id][msg.sender];
        require(contributed > 0, "No contribution found");

        // 更新状态(先改状态,再转账——防重入)
        if (c.status == CampaignStatus.Active) {
            c.status = CampaignStatus.Failed;
            emit CampaignStatusChanged(_id, CampaignStatus.Failed);
        }

        hasRefunded[_id][msg.sender] = true;
        contributions[_id][msg.sender] = 0;  // 清零防止重复领取

        // 使用 call 发送 ETH(推荐方式)
        (bool success, ) = payable(msg.sender).call{value: contributed}("");
        require(success, "Refund transfer failed");

        emit RefundClaimed(_id, msg.sender, contributed);
    }

    /// @notice 项目成功后,发起人提取资金
    /// @param _id 项目 ID
    function withdrawFunds(uint256 _id) external campaignExists(_id) onlyCreator(_id) {
        Campaign storage c = campaigns[_id];

        require(c.status == CampaignStatus.Successful, "Campaign not successful");
        require(!c.creatorWithdrawn, "Already withdrawn");

        c.creatorWithdrawn = true;
        c.status = CampaignStatus.Finalized;

        uint256 amount = c.totalRaised;

        // 使用 call 发送 ETH
        (bool success, ) = c.creator.call{value: amount}("");
        require(success, "Withdrawal failed");

        emit FundsWithdrawn(_id, c.creator, amount);
        emit CampaignStatusChanged(_id, CampaignStatus.Finalized);
    }

    // ========== 查询函数 ==========

    /// @notice 获取项目当前状态
    function getCampaignStatus(uint256 _id) external view campaignExists(_id) returns (CampaignStatus) {
        Campaign storage c = campaigns[_id];
        if (c.status == CampaignStatus.Active && block.timestamp >= c.deadline) {
            if (c.totalRaised >= c.goal) {
                return CampaignStatus.Successful;
            }
            return CampaignStatus.Failed;
        }
        return c.status;
    }

    /// @notice 获取项目详情
    function getCampaignInfo(uint256 _id) external view campaignExists(_id) returns (
        address creator,
        string memory title,
        uint256 goal,
        uint256 totalRaised,
        uint256 deadline,
        uint256 contributorCount,
        CampaignStatus status,
        uint256 timeRemaining
    ) {
        Campaign storage c = campaigns[_id];
        uint256 remaining = 0;
        if (block.timestamp < c.deadline) {
            remaining = c.deadline - block.timestamp;
        }
        return (
            c.creator, c.title, c.goal, c.totalRaised,
            c.deadline, c.contributorCount, c.status, remaining
        );
    }

    /// @notice 获取用户在某项目中的贡献
    function getContribution(uint256 _id, address _user) external view returns (uint256) {
        return contributions[_id][_user];
    }

    /// @notice 获取合约总余额
    function getTotalBalance() external view returns (uint256) {
        return address(this).balance;
    }

    // ========== 接收 ETH ==========

    /// @notice 直接转账视为对项目 0 的贡献(如果存在且活跃)
    receive() external payable {
        if (campaignCount > 0) {
            // 找到最新的活跃项目
            // 注意:这只是演示,生产环境不建议这样做
            revert("Please use contribute() function");
        }
    }

    /// @notice 拒绝未知函数调用
    fallback() external {
        revert("Function not found");
    }
}

合约安全分析

上面的 Crowdfunding 合约中应用了多个安全模式。

/*
 * ============================================
 * 安全模式分析
 * ============================================
 *
 * 1. Checks-Effects-Interactions (CEI) 模式
 *    在 claimRefund 中:
 *    - Checks:  require(contributed > 0) — 先检查
 *    - Effects: hasRefunded = true; contributions = 0; — 再改状态
 *    - Interactions: call{value: contributed}("") — 最后外部调用
 *
 *    为什么重要?如果先转账再改状态,恶意合约可以在 receive() 中
 *    重新调用 claimRefund,在状态更新前再次领取退款(重入攻击)。
 *
 * 2. Pull Payment 模式
 *    让用户主动领取退款,而非合约循环推送。
 *
 *    反面例子(不要这样做):
 *    function refundAll() external {
 *        for (uint i = 0; i < contributors.length; i++) {
 *            // 如果某个地址是恶意合约且 receive() 会 revert
 *            // 整个循环就卡住了,其他人都拿不到退款
 *            payable(contributors[i]).transfer(amounts[i]);
 *        }
 *    }
 *
 * 3. 布尔标记防重复
 *    hasRefunded[_id][msg.sender] = true 防止同一用户多次领取
 *
 * 4. 状态先置零
 *    contributions[_id][msg.sender] = 0 在转账前清零
 *    即使发生重入,金额已经是 0,无法再次领取
 */

5. transfer / send / call 的实际影响

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title 演示三种方式在不同接收者场景下的行为
contract TransferMethodsDemo {

    /// @notice 展示 transfer 在遇到消耗 gas 的接收者时会失败
    function demonstrateTransferLimit(address payable target) external payable {
        // 如果 target 合约的 receive() 消耗 >2300 gas,这里会 revert
        target.transfer(msg.value);
    }

    /// @notice 使用 call 的推荐模式
    function safeTransfer(address payable target, uint256 amount) external {
        require(address(this).balance >= amount, "Insufficient balance");

        // 推荐:使用 call + 检查返回值
        (bool success, ) = target.call{value: amount}("");
        require(success, "Transfer failed");
    }

    /// @notice 带 gas 限制的 call(介于 transfer 和无限 gas 之间)
    function limitedGasCall(address payable target, uint256 amount) external {
        require(address(this).balance >= amount, "Insufficient balance");

        // 给接收方 50000 gas,足够做一些事但不会太多
        (bool success, ) = target.call{value: amount, gas: 50000}("");
        require(success, "Transfer failed");
    }

    receive() external payable {}
}

/// @title 消耗大量 gas 的接收者
contract ExpensiveReceiver {
    uint256 public callCount;
    event Received(uint256 count, uint256 amount);

    // 这个 receive 消耗超过 2300 gas(SSTORE 消耗 ~20000 gas)
    // transfer() 和 send() 调用时会失败
    // call() 会成功
    receive() external payable {
        callCount++;  // SSTORE 操作,约 20000 gas
        emit Received(callCount, msg.value);  // LOG 操作,约 1500+ gas
    }
}

/// @title 恶意接收者(尝试重入)
contract MaliciousReceiver {
    address public target;
    uint256 public attackCount;

    constructor(address _target) {
        target = _target;
    }

    // 尝试在 receive() 中重入
    receive() external payable {
        attackCount++;
        if (attackCount < 3) {
            // 尝试重入——如果目标合约没有用 CEI 模式或重入锁,可能成功
            // 使用 transfer/send 时,这里没有足够的 gas 执行
            // 使用 call 时,这里有足够的 gas,所以目标合约必须有防护
            (bool success, ) = target.call{value: 0}(
                abi.encodeWithSignature("claimRefund(uint256)", 0)
            );
            // 注意:我们不检查 success,因为我们只是尝试攻击
        }
    }
}

6. 合约间调用的高级模式

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title 展示合约间调用的不同方式
contract ContractCaller {

    /// @notice 方式 1:通过接口调用(推荐,类型安全)
    function callViaInterface(address oracle, string calldata symbol) external view returns (uint256) {
        // 编译时检查函数签名
        return IPriceOracle(oracle).getPrice(symbol);
    }

    /// @notice 方式 2:通过 abi.encodeWithSignature 调用(灵活但不安全)
    function callViaEncode(address oracle, string calldata symbol) external returns (uint256) {
        (bool success, bytes memory data) = oracle.call(
            abi.encodeWithSignature("getPrice(string)", symbol)
        );
        require(success, "Call failed");
        return abi.decode(data, (uint256));
    }

    /// @notice 方式 3:通过 abi.encodeWithSelector 调用
    function callViaSelector(address oracle, string calldata symbol) external returns (uint256) {
        (bool success, bytes memory data) = oracle.call(
            abi.encodeWithSelector(IPriceOracle.getPrice.selector, symbol)
        );
        require(success, "Call failed");
        return abi.decode(data, (uint256));
    }

    /// @notice 带 ETH 的合约间调用
    function depositToWETH(address weth) external payable {
        // 调用 WETH 合约的 deposit 函数,并发送 ETH
        (bool success, ) = weth.call{value: msg.value}(
            abi.encodeWithSignature("deposit()")
        );
        require(success, "WETH deposit failed");
    }
}

interface IPriceOracle {
    function getPrice(string calldata symbol) external view returns (uint256);
}

msg.sender 在合约间调用中的变化

用户 (0xAlice)
    → 调用合约 A 的函数
        A 中: msg.sender = 0xAlice
    → A 内部调用合约 B 的函数
        B 中: msg.sender = 合约A的地址(不是 0xAlice!)
    → B 内部调用合约 C 的函数
        C 中: msg.sender = 合约B的地址

这就是为什么 DeFi 协议经常需要 tx.origin(永远是最初发起交易的 EOA 地址)来做某些检查。但注意,依赖 tx.origin 做权限控制是不安全的(钓鱼攻击)。


关键要点总结

ETH 处理核心规则

规则说明
call 发送 ETH避免 2300 gas 限制问题
先改状态再外部调用CEI 模式防重入
用 Pull Payment让用户主动领取,而非合约推送
检查 call 返回值(bool success, ) = ...call{value: amount}("")
小心 msg.value 在循环中它是固定值,不会每次迭代减少
payable 修饰符不可省没有 payable 的函数收到 ETH 会 revert

receive/fallback 速记

条件调用的函数
纯转账(msg.data 空) + receive 存在receive()
纯转账 + 无 receive + fallback 存在fallback()
调用不存在的函数 + fallback 存在fallback()
以上条件都不满足revert

常见误区

误区 1:认为 transfer 最安全

// 过时的建议:"用 transfer 防重入"
// 事实:transfer 固定 2300 gas 可能导致合法合约无法接收 ETH
// EIP-1884 后 SLOAD 从 200 gas 涨到 800 gas,很多合约的 receive() 超过 2300 gas

// 正确做法:用 call + 重入防护
function withdraw() external {
    uint256 amount = balances[msg.sender];
    balances[msg.sender] = 0;  // 先清零(CEI 模式)
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

误区 2:不检查 call 的返回值

// 危险!如果 call 失败,ETH 没有发出,但代码继续执行
function badWithdraw(address payable to, uint256 amount) external {
    to.call{value: amount}("");  // 没有检查返回值!
    balances[to] = 0;           // 即使转账失败也清零了——资金丢失
}

// 正确做法
function goodWithdraw(address payable to, uint256 amount) external {
    (bool success, ) = to.call{value: amount}("");
    require(success, "Transfer failed");
    balances[to] = 0;
}

误区 3:在 receive/fallback 中做太多事

// 危险:receive 中执行复杂逻辑
receive() external payable {
    // 如果通过 transfer/send 调用,只有 2300 gas
    // 以下操作可能全部失败
    balances[msg.sender] += msg.value;  // SSTORE ~20000 gas
    totalDeposits += msg.value;          // SSTORE ~5000 gas
    emit Deposited(msg.sender, msg.value); // LOG ~1500 gas
    // 总计远超 2300 gas
}

// 更好的做法:receive 只发事件,复杂逻辑放在专门的 payable 函数
receive() external payable {
    emit Received(msg.sender, msg.value);
}

function deposit() external payable {
    balances[msg.sender] += msg.value;
    totalDeposits += msg.value;
    emit Deposited(msg.sender, msg.value);
}

误区 4:误以为 msg.value 在内部调用时会归零

contract A {
    function callB(address b) external payable {
        // msg.value = 用户发送的 ETH,假设是 1 ETH

        // 调用 B 但不附带 ETH
        B(b).doSomething();
        // B 中的 msg.value 是 0(因为没有用 {value: ...})

        // 调用 B 并转发 ETH
        B(b).doSomethingPayable{value: msg.value}();
        // B 中的 msg.value 是 1 ETH

        // 注意:A 合约现在余额减少了 1 ETH(转给了 B)
        // 但 msg.value 仍然是 1 ETH(它是只读的,不会变)
    }
}

contract B {
    function doSomething() external {
        // msg.value == 0(A 没有附带 ETH)
    }

    function doSomethingPayable() external payable {
        // msg.value == A 附带的金额
    }
}

面试关联

Q1: Solidity 中发送 ETH 的三种方式,推荐哪种?为什么?

核心答案:推荐使用 call{value: amount}("")transfersend 固定只转发 2300 gas,在 Istanbul 硬分叉(EIP-1884)之后,很多合约的 receive 函数需要更多 gas(因为 SLOAD 操作码涨价),导致这两种方式可能失败。call 转发所有剩余 gas,兼容性最好,但必须配合重入防护(CEI 模式或 ReentrancyGuard)。

追问:如何防止 call 的重入风险?

  1. Checks-Effects-Interactions 模式:先检查条件,再修改状态,最后做外部调用
  2. OpenZeppelin 的 ReentrancyGuard:在函数入口加 nonReentrant 修饰符
  3. Pull Payment 模式:让用户主动领取,而非合约推送

Q2: receive() 和 fallback() 的区别?什么时候会被调用?

核心答案:当合约收到 ETH 且 msg.data 为空时调用 receive();当调用不存在的函数或 msg.data 不为空时调用 fallback()。如果没有 receive() 但有 payable fallback(),纯 ETH 转账也会走 fallback()。两者都不存在时,纯转账会 revert。

Q3: 设计一个众筹合约需要考虑哪些安全问题?

答案要点

  1. 重入攻击——退款时用 CEI 模式或 Pull Payment
  2. 溢出/下溢——Solidity 0.8+ 内置检查,但自定义数学运算仍需注意
  3. 时间操纵——block.timestamp 可被矿工小幅操纵(约 15 秒),不要依赖精确时间
  4. 拒绝服务——不要在循环中给所有人转账,用 Pull Payment
  5. 权限控制——只有 creator 能提取,只有失败后贡献者能退款
  6. 前端钓鱼——合约逻辑安全不代表用户安全,需配合前端安全措施

参考资源

资源说明
Solidity Docs - Receive/Fallback官方文档
Solidity by Example - Sending Ether三种方式对比
ConsenSys - Known Attacks安全最佳实践
OpenZeppelin - ReentrancyGuard重入防护库
EIP-1884: Gas Cost ChangesIstanbul 硬分叉 gas 变化
Solidity Patterns - Pull PaymentPull Payment 模式详解