WTF-Solidity 第 19 讲:Solidity 合约接收 ETH 的 receive() 与 fallback() 函数深度解析
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
receive()与fallback()是 Solidity 中两个特殊的回调函数,分别在「合约收到 ETH 转账」与「调用不存在于合约中的函数」两种场景下被触发,是任何会持有原生资产或承担代理转发职责的合约都无法回避的基础设施。本文以 WTF-Solidity 第 19 讲的英文版教程(Languages/en/19_Fallback_en/readme.md)为骨架,结合仓库内的 Fallback.sol、SendETH.sol、ProxyContract.sol 等源码,系统梳理两个函数的声明规则、触发判定流程、2300 gas 限制、安全风险,以及它们在接收 ETH 与代理合约中的实际应用,读完即可在真实合约中正确写出、部署并验证这两个函数。
一、为什么需要 receive() 与 fallback():两个特殊函数的前世今生
Solidity 支持两种特殊的回调函数,receive()和fallback(),它们主要在两种情况下被使用:
- 接收 ETH:当合约地址收到原生代币转账时触发;
- 处理合约中不存在的函数调用:当调用数据(
msg.data)中的函数选择器无法匹配合约里任何已声明的函数时兜底执行,这是代理合约(proxy contract)得以实现的基础。
这里有一个重要的历史背景(原文中有明确提示):在 Solidity 0.6.x 版本之前,语法上只有fallback()一个函数,它同时承担「接收用户发送的 ETH」和「在被调用函数签名未匹配时响应调用」两个职责;从 0.6 版本开始,Solidity 才将fallback()拆分成receive()和fallback()两个函数,让「收钱」与「兜底转发」各司其职。因此在阅读老项目源码时,遇到 0.6 之前的fallback(),要意识到它在当时同时承担了如今receive()的角色。
本讲聚焦于「接收 ETH」这一核心场景,代理合约场景则作为fallback()的重要工程应用一并展开。
二、接收 ETH 的专用入口:receive()
2.1 声明规则
receive()是合约收到 ETH 转账时被调用的函数。它的声明方式与普通函数完全不同:
- 一个合约最多只能有一个
receive()函数; - 声明时不需要
function关键字,直接写receive() external payable { ... }; - 不能有任何参数,不能返回任何值;
- 必须包含
external可见性修饰符和payable状态可变性修饰符(否则无法接收 ETH,编译也会报错)。
2.2 gas 限制:为什么 receive() 里不宜做复杂逻辑
当合约接收 ETH 时,receive()会被触发。但要注意:如果别人用send()和transfer()方法发送 ETH,gas会被限制在 2300(只够记录日志等极简操作),此时如果receive()里逻辑太复杂(例如写存储、循环、再调用其他合约),会触发Out of Gas报错,导致转账失败;而如果用call发送 ETH,则可以自定义gas,从而在receive()里执行更复杂的逻辑。
这三种发送 ETH 的方法在仓库第 20 讲 SendETH.sol 的注释中给出了权威对照:
// 3种方法发送ETH // transfer: 2300 gas, revert // send: 2300 gas, return bool // call: all gas, return (bool, data)即transfer()固定 2300 gas 且失败直接 revert;send()同样 2300 gas 但失败只返回bool;call()可携带全部 gas 并返回(bool, data)。这正是receive()内「轻量」原则的底层原因。
2.3 在 receive() 中发送事件
虽然不建议做复杂操作,但在receive()中记录一条事件是安全且常见的最佳实践。原文给出的概念示例如下:
// 定义事件 event Received(address Sender, uint Value); // 接收ETH时释放 Received 事件 receive() external payable { emit Received(msg.sender, msg.value); }仓库中可部署的完整实现位于 Languages/en/19_Fallback_en/Fallback.sol,其中事件命名为receivedCalled(中文版 19_Fallback/Fallback.sol 与之完全一致):
// Emit Received event when receiving ETH receive() external payable { emit receivedCalled(msg.sender, msg.value); }通过事件中的msg.sender(发送方)与msg.value(转账金额,单位 wei),链下可以完整审计每一笔进入合约的 ETH。
2.4 安全提醒:恶意 receive() 的拒绝服务风险
原文特别警告:有些恶意合约会在receive()函数(0.6 之前则是fallback())中嵌入大量消耗 gas 的内容,或者故意让执行失败(revert)的代码,导致包含退款、转账逻辑的合约无法正常工作。例如,一个合约要向某个地址退款,而该地址是一个部署了恶意receive()的合约,若退款使用transfer()/send()这类固定 2300 gas 的调用,就可能因接收方receive()故意 revert 或耗尽 gas 而永久失败。因此,在编写包含退款等逻辑的合约时,必须假定接收方可能是恶意合约,并采取call+ 返回值检查、或者「拉取式支付(pull payment)」等防御手段。
三、兜底回退函数:fallback()
3.1 声明规则与触发时机
fallback()函数会在调用合约中不存在的函数时被触发,同时也可用于接收 ETH,或用于代理合约(proxy contract)。它的声明规则:
- 声明时不需要
function关键字; - 必须由
external修饰; - 一般也会用
payable修饰以接收 ETH:fallback() external payable { ... }(若不需要收 ETH,也可以不加payable)。
3.2 带数据的兜底示例
定义一个fallback(),被触发时释放fallbackCalled事件,并输出msg.sender、msg.value和msg.data(调用数据):
event fallbackCalled(address Sender, uint Value, bytes Data); // fallback fallback() external payable { emit fallbackCalled(msg.sender, msg.value, msg.data); }仓库中对应的实际实现见 Languages/en/19_Fallback_en/Fallback.sol。注意msg.data是bytes类型,它保存了本次调用的完整 calldata——这正是代理合约能够「原样转发」调用数据的关键。
3.3 工程应用:代理合约的流量入口
fallback()在代理合约中扮演「统一转发入口」:所有无法匹配的函数调用(即所有业务调用)都会落入fallback(),再由它把msg.data通过delegatecall委托给逻辑合约执行。
仓库第 46 讲 ProxyContract.sol 的实现非常典型:
/** * @dev 回调函数,调用`_delegate()`函数将本合约的调用委托给 `implementation` 合约 */ fallback() external payable { _delegate(); }其_delegate()内部通过内联汇编执行delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0),把调用者发来的 calldata 完整转发给逻辑合约,并将返回值原样返回(见 ProxyContract.sol)。第 47 讲的可升级合约 Upgrade.sol 采用了同样的模式:
// fallback函数,将调用委托给逻辑合约 fallback() external payable { (bool success, bytes memory data) = implementation.delegatecall(msg.data); }由此可见,fallback()不只是「接收 ETH」的工具,更是整个可升级代理体系(46 讲 ProxyContract、47 讲 Upgrade、48 讲 TransparentProxy、49 讲 UUPS)的基石入口。
四、receive 与 fallback 的触发判定:一张流程图说清楚
receive和fallback都能够用于接收 ETH,但触发规则并不相同。原文给出了权威判定流程:
触发fallback() 还是 receive()? 接收ETH | msg.data是空? / \ 是 否 / \ receive()存在? fallback() / \ 是 否 / \ receive() fallback()用文字归纳就是:
- 合约接收 ETH 时,若
msg.data为空且合约中存在receive()→ 触发receive(); - 若
msg.data不为空(例如带着 calldata 调用合约)→ 无论是否有receive(),都触发fallback(); - 若
msg.data为空但合约中没有声明receive()→ 触发fallback(),此时fallback()必须为payable,否则无法接收 ETH; - 若合约中既没有
receive()也没有payable fallback(),那么向合约直接发送 ETH 将会报错(但仍可以通过带有payable的函数向合约转入 ETH)。
这段判定逻辑同样以注释形式固化在仓库源码中,见 Languages/en/19_Fallback_en/Fallback.sol 与中文版 19_Fallback/Fallback.sol,可以作为编写合约时的「速查卡」随时查阅。
为了便于记忆,将两者的差异汇总如下:
| 对比维度 | receive() | fallback() |
|---|---|---|
| 声明关键字 | 不需要function | 不需要function |
| 参数/返回值 | 无参数、无返回值 | 无参数、无返回值 |
| 必备修饰符 | external payable | external(通常加payable) |
| 触发条件① | msg.data为空且已声明 | msg.data非空,或msg.data为空但未声明 receive() |
| 触发条件② | 纯 ETH 转账 | 调用不存在的函数(如代理合约转发) |
| 用途 | 专门接收 ETH | 接收 ETH + 代理转发/兜底 |
五、Remix 实操演示:从部署到事件验证
教程的完整实验环境是 Remix IDE,仓库中提供了四张与之对应的操作截图。以仓库根目录为基准,图片位于 Languages/en/19_Fallback_en/img/ 目录。完整步骤如下:
第一步:部署合约。在 Remix 中新建文件,粘贴 Fallback.sol(或中文版 19_Fallback/Fallback.sol)的全部代码并编译,随后部署合约Fallback。
第二步:触发 receive()。在「VALUE」栏填入要发送给合约的金额(单位是 Wei,例如 100),保持 CALLDATA 为空,然后点击「Transact」。
第三步:验证 receivedCalled 事件。交易成功后,在交易日志中可以看到receivedCalled事件被释放,事件参数记录了发送方地址与金额 100 Wei——证明空 calldata 的纯转账确实落入了receive()。
第四步:触发 fallback()。再次在「VALUE」栏填入金额(例如 99 Wei),并在「CALLDATA」栏填入任意非空数据(例如0xabcd),然后点击「Transact」。
第五步:验证 fallbackCalled 事件。交易成功后,日志中会释放fallbackCalled事件,其参数包含发送方地址、金额 99 Wei 以及原始调用数据0xabcd——证明非空 calldata 的调用被fallback()完整接管。
两轮实验恰好构成一组对照:同样的合约、同样地填入 VALUE,唯一变量是 CALLDATA 是否为空,最终分别触发了receive()与fallback(),与第四节中的判定流程完全吻合。读者在本地复现时,只需观察事件名称即可快速确认当前触发的是哪个函数。
六、总结
本讲介绍了 Solidity 中的两种特殊函数receive()和fallback()。回顾核心要点:
receive()是接收 ETH 的专用入口,无参数、无返回值、必须external payable,且每个合约最多一个;由于send/transfer只提供 2300 gas,receive()内应保持轻量(推荐只发事件),并警惕恶意合约在receive()中消耗 gas 或故意 revert 造成的退款拒绝服务。fallback()在函数签名不匹配或无可匹配调用时兜底执行,必须external、通常payable;它既是接收 ETH 的通道,也是代理合约中delegatecall转发的统一入口(可对照 ProxyContract.sol 与 Upgrade.sol 加深理解)。- 触发顺序:接收 ETH 时,
msg.data为空且有receive()→ 触发receive();msg.data非空或没有receive()→ 触发(payable 的)fallback();两者皆无则直接转账失败。 - 0.6 版本之前只有单一
fallback(),0.6 之后才拆分为receive()与fallback(),阅读旧合约时需注意这一语义差异。
掌握这两个函数,是理解 ETH 入账链路、防御恶意接收方攻击以及编写可升级代理合约的第一步,也为后续第 20 讲「三种发送 ETH 的方式」、第 46~49 讲「代理与可升级合约」系列打下了直接基础。
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考