在 Solidity 的世界里,大多数函数都有明确的名字、参数和用途。但还有两个比较特别的“隐形入口”函数:receive()
和 fallback()
。
它们不需要(也不能)显式调用,却能在特定场景下自动触发,决定了一个合约如何接收 ETH,以及如何应对未知调用。
这节课,我们就来深入理解它们的触发机制、区别、常见风险,并通过 Foundry 实现完整的测试用例,验证各种交互场景。
receive()
external payable
msg.data
为空时触发fallback()
payable
或非 payable
可以理解成:
receive
是“收款专用”fallback
是“万能接单员”,负责兜底处理各种不在菜单上的请求
用一个对照表最直观:
场景 | 是否有 | 是否有 | 是否带数据 | 会触发 |
---|---|---|---|---|
ETH,无数据 | 有 | 任意 | 否 |
|
ETH,无数据 | 无 |
| 否 |
|
ETH,有数据 | 任意 |
| 是 |
|
ETH,有数据 | 任意 | 非 | 是 | revert |
无 ETH,有数据 | 任意 | 任意 | 是 |
|
这样,你在测试时就可以根据表格预判合约的行为。
不仅是函数本身的定义,调用方的转账方式也会影响触发情况和 gas 行为:
方法 | Gas 转发 | 失败时 | 返回值 | 常见用途 |
---|---|---|---|---|
| 2300 gas | revert | 无 | 早期推荐,安全但已不再建议 |
| 2300 gas | 返回 | bool | 不希望失败直接回滚的场景 |
| 所有剩余 gas | 返回 | (bool, bytes) | 推荐方式,灵活且可配 CEI 模式 |
在 EIP-1884 调整 gas 成本后,transfer
和 send
的 2300 gas 限制已经不能保证可靠执行,因此现在主流建议是用 call
。
为了直观感受它们的触发规则,我们实现三个合约和一组测试。
Sender.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Sender {
function transferTo(address payable target) public payable {
target.transfer(msg.value);
}
function sendTo(address payable target) public payable returns (bool) {
return target.send(msg.value);
}
function callTo(address payable target) public payable returns (bool, bytes memory) {
(bool success, bytes memory data) = target.call{value: msg.value}("");
return (success, data);
}
function callWithData(address target, bytes calldata data) public payable returns (bool, bytes memory) {
(bool success, bytes memory ret) = target.call{value: msg.value}(data);
return (success, ret);
}
}
Receivers.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleReceiver {
event GotReceive(address indexed sender, uint256 amount);
event GotFallback(address indexed sender, uint256 amount, bytes data);
receive() external payable {
emit GotReceive(msg.sender, msg.value);
}
fallback() external payable {
emit GotFallback(msg.sender, msg.value, msg.data);
}
}
contract WriterReceiver {
uint256 public counter;
event GotAny(address indexed sender, uint256 amount);
receive() external payable {
counter += 1;
emit GotAny(msg.sender, msg.value);
}
fallback() external payable {
counter += 1;
emit GotAny(msg.sender, msg.value);
}
}
contract NonPayableFallback {
fallback() external {
// 非 payable,不能收 ETH
}
}
FallbackReceive.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/Sender.sol";
import "../src/Receivers.sol";
contract FallbackReceiveTest is Test {
Sender sender;
SimpleReceiver simple;
WriterReceiver writer;
NonPayableFallback nonPayable;
function setUp() public {
sender = new Sender();
simple = new SimpleReceiver();
writer = new WriterReceiver();
nonPayable = new NonPayableFallback();
vm.deal(address(this), 10 ether);
}
function testTransferToSimpleReceiver() public {
sender.transferTo{value: 1 ether}(payable(address(simple)));
assertEq(address(simple).balance, 1 ether);
}
function testTransferToWriterReceiverFails() public {
vm.expectRevert();
sender.transferTo{value: 1 ether}(payable(address(writer)));
}
function testCallToWriterReceiverSucceeds() public {
(bool ok, ) = sender.callTo{value: 1 ether}(payable(address(writer)));
assertTrue(ok);
assertEq(address(writer).balance, 1 ether);
assertEq(writer.counter(), 1);
}
function testSendToWriterReceiverReturnsFalse() public {
bool sent = sender.sendTo{value: 1 ether}(payable(address(writer)));
assertFalse(sent);
assertEq(address(writer).balance, 0);
}
function testCallTriggersReceiveWhenNoData() public {
(bool ok, ) = sender.callTo{value: 1 ether}(payable(address(simple)));
assertTrue(ok);
assertEq(address(simple).balance, 1 ether);
}
function testCallWithDataTriggersFallback() public {
bytes memory someData = abi.encodeWithSignature("nonexistent()");
(bool ok, ) = sender.callWithData{value: 0}(address(simple), someData);
assertTrue(ok);
}
function testCallToNonPayableFallbackWithValueFails() public {
(bool ok, ) = sender.callTo{value: 1 ether}(payable(address(nonPayable)));
assertFalse(ok);
}
}
执行测试:
➜ counter git:(main) ✗ forge test --match-path test/FallbackReceive.t.sol -vvv
[⠊] Compiling...
[⠒] Compiling 3 files with Solc 0.8.30
[⠑] Solc 0.8.30 finished in 540.39ms
Compiler run successful!
Ran 7 tests for test/FallbackReceive.t.sol:FallbackReceiveTest
[PASS] testCallToNonPayableFallbackWithValueFails() (gas: 28887)
[PASS] testCallToWriterReceiverSucceeds() (gas: 55238)
[PASS] testCallTriggersReceiveWhenNoData() (gas: 31262)
[PASS] testCallWithDataTriggersFallback() (gas: 18734)
[PASS] testSendToWriterReceiverReturnsFalse() (gas: 30670)
[PASS] testTransferToSimpleReceiver() (gas: 29019)
[PASS] testTransferToWriterReceiverFails() (gas: 29210)
Suite result: ok. 7 passed; 0 failed; 0 skipped; finished in 4.74ms (5.49ms CPU time)
Ran 1 test suite in 212.40ms (4.74ms CPU time): 7 tests passed, 0 failed, 0 skipped (7 total tests)
receive()
和 fallback()
中避免复杂逻辑,减少重入风险。call
取代 transfer
/ send
,并配合 Checks-Effects-Interactions 模式。fallback()
用于转发调用时,应配合 delegatecall
并严格控制可调用目标。receive()
专收无数据 ETHfallback()
兜底处理未知调用和带数据的 ETH