Solidity 错误处理
引言
本文详细描述了调用智能合约时可能发生的各种错误,以及 Solidity 的 Try/Catch 块如何响应(或未能响应)这些错误。要理解 Solidity 中的 Try/Catch 如何工作,我们必须了解当低级调用失败时返回的数据。编译器决定了这种行为,而不是以太坊虚拟机。因此,用其他语言或汇编编写的合约不一定会遵循这里解释的所有错误格式。
低级调用失败的情况
当对外部合约的低级调用失败时,它返回一个布尔值false
。这个false
表示调用未成功执行。调用在以下情况下可能返回false
:
- 被调用的合约回滚(revert)
- 被调用的合约执行非法操作(如除以零或访问越界数组索引)
- 被调用的合约耗尽所有 gas
在以下部分中,我们将检查可能导致低级调用返回 false
的情况,以及它们可能提供的任何返回数据。然后我们将探讨 Try/Catch 如何处理(或未能处理)每种情况。
第 1 部分:在回滚时返回什么内容
1. 如果使用了没有错误字符串的 revert,会返回什么?
使用 revert 的最简单方法是不提供回滚原因。
contract ContractA {
function mint() external pure {
revert();
}
}
如果我们部署上述合约(ContractA)并从另一个合约(ContractB)进行低级调用mint()
函数,如下所示:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
import "hardhat/console.sol";
contract ContractB {
function call_failure(address contractAAddress) external {
(, bytes memory err) = contractAAddress.call(
abi.encodeWithSignature("mint()")
);
console.logBytes(err);
}
}
revert()
错误将被触发,不会返回任何数据。错误返回的数据是0x
,这只是没有附带数据的十六进制表示。
2. 从带有错误字符串的 revert 中返回什么?
另一种使用 revert 的方法是提供一个字符串消息。这有助于识别合约中交易失败的原因。
让我们触发一个带有字符串的回滚,看看返回了什么:
contract ContractB {
function mint() external pure {
revert("Unauthorized");
}
}
调用合约将是:
import "hardhat/console.sol";
contract ContractA {
function call_failure(address contractBAddress) external {
(, bytes memory err) = contractBAddress.call(
abi.encodeWithSignature("mint()")
);
console.logBytes(err); // just so we can see the error data
}
}
当 revert 带有字符串参数触发时,它会将 Error 函数Error(string)
的 ABI 编码返回给调用者。我们回滚的返回数据将是函数调用的 ABI 编码Error("Unauthorized")
。
在这种情况下,它将具有Error(string)
函数的函数选择器,字符串的偏移量,长度和字符串内容的十六进制编码。
让我们进一步解释输出:
- 选择器
08c379a0
是keccak256("Error(string)")
的前四个字节,其中 string 指的是原因字符串。 - 接下来的 96 个字节(3 个 32 字节)是字符串
Unauthorized
的 ABI 编码 - 前 32 个字节是字符串长度位置的偏移量
- 第二个 32 个字节是字符串的长度(12 个字节以十六进制表示为 c)
- 字符串
Unauthorized
的实际内容以 UTF- 8 编码为字节556e617574686f72697a6564
3. 从自定义 revert 中返回什么?
Solidity0.8.4 引入了错误类型,可以与 revert 语句一起使用,以创建既可读又节省 gas 的自定义错误。
要创建自定义错误类型,你将使用关键字error
来定义错误,类似于定义事件:
error Unauthorized();
如果需要强调错误信息的一些细节,你也可以定义带有参数的自定义错误:error CustomError(arg1, arg2, etc)
。
error Unauthorized(address caller);
没有参数的自定义 revert
让我们比较一个带参数的自定义回滚与一个不带参数的例子:
pragma solidity >=0.8.4;
error Unauthorized();
contract ContractA {
function mint() external pure {
revert Unauthorized();
}
}
在上述例子中,我们希望回滚交易并返回错误Unauthorized
。我们的调用合约将保持不变:
import "hardhat/console.sol";
contract ContractB {
function call_failure(address contractAAddress) external {
(, bytes memory err) = contractAAddress.call(
abi.encodeWithSignature("mint()")
);
console.logBytes(err); // just so we can see the error data
}
}
不带参数的自定义回滚将仅返回函数选择器(keccak256("Unauthorized()")
的前四个字节)给调用者,即0x82b42900
。
带参数的自定义 revert
现在让我们看看带参数的自定义错误:
pragma solidity >=0.8.4;
error Unauthorized(address caller);
contract ContractA {
function mint() external pure {
revert Unauthorized(msg.sender);
}
}
0x8e4a23d60000000000000000000000009c84abe0d64a1a27fc82821f88adae290eab5e07
带参数的自定义回滚将返回函数选择器(keccak256("Unauthorized(address)")
的前四个字节)以及地址参数的 ABI 编码。在这种情况下,返回的数据将包含函数选择器和调用者地址的编码。
这里需要注意的是,你不能定义自定义错误error Error(string)
或error Panic(uint256)
,因为这些与 require 和 assert 分别返回的错误冲突
第 2 部分:Try/Catch 如何处理错误
Solidity 中的 Try/Catch 基础
Solidity 0.6 版本引入了 try/catch 语句,这是错误处理能力的一大飞跃。try/catch 允许我们处理外部函数调用失败的情况,而不需要回滚整个交易(被调用函数中的状态更改仍会回滚,但调用函数中的状态更改不会)。
在 Solidity 中,try/catch 只能用于:
- 外部合约调用
- 合约创建调用(constructor 被视为 external 函数)
基本语法如下:
try externalContract.f() {
// 调用成功的情况下执行的代码
} catch {
// 调用失败的情况下执行的代码
}
如果调用的函数有返回值,必须在 try 后面声明变量来接收:
try externalContract.f() returns (uint256 value) {
// 使用返回的value值
} catch {
// 处理错误
}
捕获特定类型的错误
Solidity 的 try/catch 提供了四种捕获错误的方式:
- 捕获带有原因的错误:使用
catch Error(string memory reason) {}
可以捕获通过revert("reason")
或require(false, "reason")
抛出的错误。
try externalContract.f() {
// 成功代码
} catch Error(string memory reason) {
// 处理带有原因的错误
// 例如:reason = "Unauthorized"
}
- 捕获所有非法操作 :使用
catch Panic(uint256 errorCode) {}
,例如除以零错误,以及 assert 错误等
try externalContract.f() {
// 成功代码
} catch Panic(uint256 errorCode) {
// 处理非法操作错误
}
- 捕获自定义错误 :使用
catch (bytes memory reason) {}
或具体的错误定义catch Unknown(uint256 code) {}
可以捕获自定义错误。
try externalContract.f() {
// 成功代码
} catch (bytes memory reason) {
// 处理自定义错误
// 例如:reason = 自定义错误的ABI编码
}
- 捕获所有错误 :使用
catch {}
可以捕获所有其他类型的错误。
try externalContract.f() {
// 成功代码
} catch Error(string memory reason) {
// 处理带有原因的错误
} catch {
// 处理所有其他错误
}
注:catch Error(string memory reason) {}
和catch {}
两种捕获错误的方式不能共存
实际应用示例
下面这个合约展示了如何使用 try/catch 处理合约创建可能失败的情况:
pragma solidity >=0.7.0 <0.9.0;
interface Other {
function hello() external returns (bytes memory);
}
contract Contract {
function callOtherContract(Other other) public returns (bytes memory){
try other.hello() returns (bytes memory data) {
return data;
} catch Panic(uint256 errorCode) {
// 处理非法错误
} catch Error(string memory reason) {
// 处理带有原因的错误
} catch (bytes memory reason) {
// 处理所有其他错误
}
}
}
第 3 部分:Try/Catch 的限制和注意事项
使用 try/catch 时需要注意以下几点:
- 只能用于外部调用:try/catch 只能用于外部合约调用和合约创建,不能用于内部函数调用。如果想在同一合约内使用 try/catch,可以通过
this.function()
的方式调用,这会被视为外部调用。 - 只捕获外部调用中的异常 :try/catch 只能捕获外部调用本身中发生的异常,不能捕获调用参数计算过程中的异常。例如:
function createCharitySplitter(address _charityOwner) public {
try new CharitySplitter(getCharityOwner(_charityOwner, false))
returns (CharitySplitter newCharitySplitter)
{
// 成功代码
} catch {
// 错误处理
}
}
function getCharityOwner(address _charityOwner, bool _toPass)
internal returns (address) {
require(_toPass, "revert-required-for-testing"); // 这个错误不会被try/catch捕获
return _charityOwner;
}
- 不能在构造函数中使用 this:不能在构造函数中使用
this.f()
,因为此时合约还未创建完成。 - 状态回滚的范围 :当外部调用失败时,该调用中的所有状态更改都会回滚,但调用前后的状态更改不会回滚。
总结
Solidity 中的错误处理机制提供了多种方式来抛出和捕获错误:
抛出错误的方式 :
- 使用
revert()
不带原因 - 使用
revert("reason")
带原因 - 使用自定义错误
error ErrorName()
不带参数 - 使用自定义错误
error ErrorName(param1, param2)
带参数 - 使用
require(condition, "reason")
- 使用
assert(condition)
- 使用
捕获错误的方式 :
- 使用
try/catch
捕获外部调用中的错误 - 使用
catch Error(string memory reason)
捕获带原因的错误 - 使用
catch (bytes memory reason)
捕获自定义错误 - 使用
catch
捕获所有其他错误
- 使用
通过合理使用这些错误处理机制,我们可以编写更加健壮和用户友好的智能合约,提高合约的可靠性和安全性。