在Solidity开发中,错误处理就像是合约的守护者,保护着你的代码免受意外和攻击,所以用好require、revert和assert这三个关键字特别重要。
这篇文章,我将和你分享它们的区别和用法,帮你在开发中少踩坑,写出更安全、更可靠的合约。
先来个直观的对比(手机屏下可左右滚动查看):
| 特性 | require | revert | assert |
|---|---|---|---|
| 主要用途 | 输入验证、条件检查 | 复杂条件逻辑、自定义error | 不变量检查、内部错误捕获 |
| gas退还 | 退还剩余gas | 退还剩余gas | 0.8.0后改用REVERT |
| 操作码 | REVERT | REVERT | 0.8.0前INVALID,后REVERT |
| 错误信息 | 支持字符串消息 | 支持自定义error类型 | 0.8.0前仅返回默认Panic错误码,后支持自定义消息 |
| 常见场景 | 参数验证、权限检查 | 复杂条件、详细错误 | 内部一致性验证 |
这三个关键字虽然都能处理错误,但功能定位完全不同。require像是门卫,负责输入验证;revert是个灵活的管家,能处理各种复杂状况;assert则是内部的守护者,确保核心逻辑正确。
接下来,我带你逐个认识它们。
require是Solidity中最常见的错误处理工具,简单直接。如果条件为假,它就回滚交易并告诉你哪里不对。
function withdraw(uint256 amount) public {
// 验证输入参数
require(amount > 0, "Amount must be greater than zero");
// 检查业务条件
require(balances[msg.sender] >= amount, "Insufficient balance");
// 检查权限
require(msg.sender == owner || isAuthorized[msg.sender], "Unauthorized");
// 执行提币逻辑...
}
当你使用require时,Solidity编译器会将其转换为:
if (!condition) {
revert(message);
}
简单地来说,require = if + revert,它帮你把条件检查和回滚打包在一起,使用更加便捷。
revert允许你在任何条件下手动触发回滚,特别适合处理复杂情况。它还能搭配自定义error类型,让你省gas又能返回更多信息(如参数值)。
// 定义自定义error类型
error InsufficientBalance(address user, uint256 available, uint256 required);
error ZeroAmount();
error Unauthorized(address caller);
function withdraw(uint256 amount) public {
// 使用if-revert模式进行条件检查
if (amount == 0) {
revert ZeroAmount();
}
if (balances[msg.sender] < amount) {
// 通过自定义error提供更详细的上下文
revert InsufficientBalance(msg.sender, balances[msg.sender], amount);
}
if (msg.sender != owner && !isAuthorized[msg.sender]) {
revert Unauthorized(msg.sender);
}
// 执行提币逻辑...
}
从Solidity 0.8.4开始,自定义error成为开发者的重要选择,有如下优势:
assert是用来检查"理论上不该出错"的条件。如果它触发了,通常说明代码逻辑有严重问题。
uint256 public totalDeposited;
function deposit() external payable {
if (msg.value == 0) revert ZeroAmount();
uint256 oldTotal = totalDeposited;
totalDeposited += msg.value;
// 验证状态一致性,检查存取账目是否正确
assert(totalDeposited == oldTotal + msg.value);
}
在Solidity 0.8.0之前,assert失败会使用INVALID操作码,消耗所有剩余gas且无法提供错误信息,给调试带来困难。从0.8.0开始,它改用REVERT,也会退还剩余gas,更加便于开发。
需要注意的是,即使在0.8.0后,assert失败仍会触发Panic错误(如Panic(0x01)表示断言失败),而不是普通的revert,这有助于区分断言失败和业务逻辑错误。
理论结合实践,我展示一个存款提款合约,看这三个关键字如何协同工作:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SecureVault {
mapping(address => uint256) public balances;
uint256 public totalDeposited;
address public immutable owner;
bool private locked; // 防重入锁
// 自定义error
error DepositZeroAmount();
error InsufficientBalance(address user, uint256 balance, uint256 requestedAmount);
error WithdrawZeroAmount();
error WithdrawFailed();
error NotOwner(address caller);
error ReentrantCall();
constructor() {
owner = msg.sender;
}
// 防重入修饰器 - 防止重入攻击,确保同一函数不可重入调用
modifier nonReentrant() {
if (locked) revert ReentrantCall();
locked = true;
_;
locked = false;
}
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner(msg.sender);
_;
}
/// @notice 用户存款
function deposit() external payable {
// 使用revert+自定义error检查输入
if (msg.value == 0) revert DepositZeroAmount();
balances[msg.sender] += msg.value;
totalDeposited += msg.value;
// 使用assert确保状态一致(在此简单合约中两者应该严格相等)
assert(totalDeposited == address(this).balance);
}
/// @notice 用户取款
function withdraw(uint256 amount) external nonReentrant {
// 使用revert+自定义error检查输入
if (amount == 0) revert WithdrawZeroAmount();
// 检查余额
if (balances[msg.sender] < amount) {
revert InsufficientBalance(msg.sender, balances[msg.sender], amount);
}
// 先修改状态
balances[msg.sender] -= amount;
totalDeposited -= amount;
// 再进行外部交互
(bool success, ) = payable(msg.sender).call{value: amount}("");
if (!success) {
revert WithdrawFailed();
}
// 使用assert确保状态一致
assert(totalDeposited == address(this).balance);
}
}
这里,revert负责输入和业务逻辑验证,assert确保状态不出错,配合使用效果良好。
基于实际开发经验,以下是几个实用的最佳实践:
在函数开头进行所有输入和条件验证,错了就早点返回失败("fail fast"原则),避免浪费gas。
function transfer(address to, uint256 amount) external {
// 先进行所有输入验证
if (to == address(0)) revert InvalidAddress();
if (amount == 0) revert ZeroAmount();
// 只有验证通过后才执行核心逻辑...
}
自定义error比字符串消息更省gas,还能带参数,有助于更快发现问题:
// 不推荐
require(balance >= amount, "Insufficient balance");
// 推荐
error InsufficientBalance(uint256 available, uint256 required);
if (balance < amount) revert InsufficientBalance(balance, amount);
assert只用来检查内部逻辑问题,不要用于业务验证。
先修改状态,再调用外部合约,防止重入攻击。
balances[msg.sender] -= amount; // 先改状态
(bool success, ) = msg.sender.call{value: amount}(""); // 再交互
不要只说"错了",要告诉用户具体什么问题,方便调试和追踪错误。
// 不明确的错误
if (!success) revert("Failed");
// 明确且有用的错误
if (!success) revert TransferFailed(recipient, amount);
错误处理还能优化gas使用,以下是几个技巧:
if-revert比带字符串的require可节省约20-50 gas(视字符串长度而定):
// 消耗更多gas,尤其是错误提示字符串比较长时
require(amount > 0, "Zero amount");
// 更省gas
if (amount == 0) revert ZeroAmount();
error名称不要太长,这样在部署时可节省gas。
// 太长,不推荐
error ThisFunctionRequiresTheCallerToHaveEnoughBalanceToCompleteTheTransfer();
// 简洁清晰,推荐
error InsufficientBalance();
与外部合约交互时,错误处理需要更加小心:
error ExternalCallFailed(address target);
function safeCall(address target, bytes memory data) external {
(bool success, bytes memory result) = target.call(data);
if (!success) {
// 检查result是否包含外部合约的错误信息,若有则直接抛出
if (result.length > 0) {
// 这里用Yul汇编提取外部错误信息并重新抛出
assembly {
revert(add(32, result), mload(result))
}
} else {
// 外部调用失败但无具体错误信息
revert ExternalCallFailed(target);
}
}
}
如果你在面试中被问到这个问题,可以按照这个思路回答:
require负责输入参数验证,退还gasrevert处理复杂逻辑,支持自定义错误assert检查内部逻辑,发现严重问题assert消耗所有gas,现在也会退还了require,复杂逻辑用revert,内部检查用assert面试官可能问:为何assert不用于输入验证?
答:assert是用来检查程序内部不变量的,如果触发说明代码逻辑有bug。输入验证是外部条件检查,应该用require或revert处理预期的业务错误。
require、revert和assert就像Solidity的错误处理三剑客:
require守住输入关,简单实用revert处理复杂逻辑,灵活高效assert保护内部逻辑,安全第一掌握好这三个关键字,你的合约会更加安全,面试也能轻松通过!
基于全文检索与主题相似度
关注我,带你转型Web3开发,分享我从2018年入行区块链以来的开发经验(DeFi/GameFi/NFT/DAO/OTC/钱包/Trading Bot/MEME Launch),免费领取我私下整理的“智能合约学习”资料一份,帮你少走弯路! 说在前面 在Solidity开发中,搞懂数据存储位置就像掌握
关注我,带你转型Web3开发,分享我从2018年入行区块链以来的开发经验(DeFi/GameFi/NFT/DAO/OTC/钱包/Trading Bot/MEME Launch),免费领取我私下整理的“智能合约学习”资料一份,帮你少走弯路! 说在前面 在solidity开发中,合约间的交互是常见需求,
关注我,带你转型Web3开发,分享我从2018年入行区块链以来的开发经验(DeFi/GameFi/NFT/DAO/OTC/钱包/Trading Bot/MEME Launch),免费领取我私下整理的“智能合约学习”资料一份,帮你少走弯路! 说在前面 在Solidit开发中,ETH 转账是一项基础却至