Solidity - receive/fallback + payable + msg.value + 合约间调用 + Crowdfunding合约
### 1. 合约接收 ETH 的两种特殊函数
日期: 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 {}
}
三种方式对比
| 特性 | transfer | send | call |
|---|---|---|---|
| Gas 转发 | 2300 (固定) | 2300 (固定) | 全部剩余 (可自定义) |
| 失败行为 | 自动 revert | 返回 false | 返回 (false, bytes) |
| 重入风险 | 低 (gas 不够) | 低 (gas 不够) | 高 (需要防护) |
| 推荐程度 | 不推荐 | 不推荐 | 推荐(配合重入防护) |
| 兼容性 | 可能因 EIP-1884 gas 变化而失败 | 同上 | 最好 |
为什么 call 是推荐方式?
2019 年的 Istanbul 硬分叉(EIP-1884)提高了 SLOAD 操作码的 gas 消耗。这导致一些合约的 receive() 函数需要超过 2300 gas 才能执行(比如需要读取状态变量记日志)。transfer 和 send 固定只给 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];
}
}
合约间调用的注意事项:
- 外部调用可能失败——永远检查返回值或用
require - 外部调用可能重入——被调用合约可以回调你的合约
- Gas 消耗不确定——外部合约可能消耗大量 gas
- 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}("")。transfer 和 send 固定只转发 2300 gas,在 Istanbul 硬分叉(EIP-1884)之后,很多合约的 receive 函数需要更多 gas(因为 SLOAD 操作码涨价),导致这两种方式可能失败。call 转发所有剩余 gas,兼容性最好,但必须配合重入防护(CEI 模式或 ReentrancyGuard)。
追问:如何防止 call 的重入风险?
- Checks-Effects-Interactions 模式:先检查条件,再修改状态,最后做外部调用
- OpenZeppelin 的 ReentrancyGuard:在函数入口加 nonReentrant 修饰符
- Pull Payment 模式:让用户主动领取,而非合约推送
Q2: receive() 和 fallback() 的区别?什么时候会被调用?
核心答案:当合约收到 ETH 且 msg.data 为空时调用 receive();当调用不存在的函数或 msg.data 不为空时调用 fallback()。如果没有 receive() 但有 payable fallback(),纯 ETH 转账也会走 fallback()。两者都不存在时,纯转账会 revert。
Q3: 设计一个众筹合约需要考虑哪些安全问题?
答案要点:
- 重入攻击——退款时用 CEI 模式或 Pull Payment
- 溢出/下溢——Solidity 0.8+ 内置检查,但自定义数学运算仍需注意
- 时间操纵——
block.timestamp可被矿工小幅操纵(约 15 秒),不要依赖精确时间 - 拒绝服务——不要在循环中给所有人转账,用 Pull Payment
- 权限控制——只有 creator 能提取,只有失败后贡献者能退款
- 前端钓鱼——合约逻辑安全不代表用户安全,需配合前端安全措施
参考资源
| 资源 | 说明 |
|---|---|
| Solidity Docs - Receive/Fallback | 官方文档 |
| Solidity by Example - Sending Ether | 三种方式对比 |
| ConsenSys - Known Attacks | 安全最佳实践 |
| OpenZeppelin - ReentrancyGuard | 重入防护库 |
| EIP-1884: Gas Cost Changes | Istanbul 硬分叉 gas 变化 |
| Solidity Patterns - Pull Payment | Pull Payment 模式详解 |