Solidity智能合约中的REVERT机制:全面指南

  • cyfrin
  • 发布于 2025-03-27 22:53
  • 阅读 42

本文深入探讨了Solidity智能合约中的REVERT机制,解释了其功能和处理方法,包括require、revert、assert和try/catch的用法。通过实例代码,阐释了这些机制如何确保合约执行的完整性与安全性,并讨论了EVM在处理revert时的响应和行为。文章意在帮助开发者有效调试合约和减少错误风险。

智能合约回滚时发生了什么?

了解 Solidity 智能合约的回滚、其功能及如何处理它们。探讨 require、revert、assert、gas 错误以及 try/catch 以实现安全的开发。

在与Solidity 智能合约交互时,函数调用可能不会按预期进行。交易没有成功完成,而是中止,所有在执行过程中所做的更改都会被回滚。这被称为REVERT——一种旨在保护运行时操作完整性的机制。

可以把它看作区块链的说法:“出了点问题,让我们撤销所有操作以确保状态的一致性。”

回滚确保不允许部分或意外操作。当智能合约函数中定义的条件未满足时,例如由于无效输入、资金不足或未经授权的访问尝试,回滚就会被触发。

理解为何智能合约会回滚使开发者能够有效调试失败的合约条件,在执行关键函数之前验证输入,并防止意外行为。这降低了错误风险,并确保了智能合约的安全性。

在深入细节之前,让我们先看看在 Solidity 中的函数调用以及它们对回滚的影响。

调用在智能合约回滚中的作用

调用是智能合约与以太坊虚拟机 (EVM) 交互和执行逻辑的方式。这些交互可以通过高层调用来实现,使用合同的接口以类型安全地调用函数,或者低层调用,直接执行函数。

无论哪种方法,智能合约的运行时行为都是由明确或隐式调用的函数决定的。EVM 会评估每个调用以确保操作的有效性。

如果运行时错误发生,例如在 require 语句中条件未满足,EVM 将触发状态回滚异常。这将撤销交易所做的所有更改,恢复合约到之前的状态。然而,在此过程中所消耗的 gas 是不可恢复的。

例如,在下面的合约中,如果 callMultiply(x,y) 被调用,且值大于 10,或者 lowLevelCallMultiply(12, 5) 被调用,那么 multiply 函数中的 require 语句将触发回滚,因为定义的条件未满足(12 大于 10)。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Callee {
    function multiply(uint256 x, uint256 y) public pure returns (uint256) {
        require(x < 10 && y < 10, "Inputs must be less than 10");
        return x * y;
    }
}

contract Caller {
    Callee callee;

    constructor(address calleeAddress) {
        callee = Callee(calleeAddress); // 实例化 Callee 合约
    }

    function callMultiply(uint256 x, uint256 y) public view returns (uint256) {
        // 高层调用
        return callee.multiply(x, y);
    }

    function lowLevelCallMultiply(uint256 x, uint256 y) public returns (uint256) {
        // 低层调用
        (bool success, bytes memory result) = address(callee).call(
            abi.encodeWithSignature("multiply(uint256,uint256)", x, y)
        );

        require(success, "Low-level call failed"); // 检查调用是否成功

        return abi.decode(result, (uint256)); // 解码返回的值
    }
}

区块链交易错误显示“交易已挖矿但执行失败”,输出中显示解码问题的详细信息。

状态回滚异常在低层调用中。

请注意,低层调用(例如 lowLevelCallMultiply)在失败时不会自动回滚。相反,它返回一个 布尔值truefalse),被推送到 EVM 堆栈上,与任何其他数据一样。EVM 并不基于此值采取任何行动。因此,合约必须明确检查和处理结果以确定调用是否成功。

(bool success, bytes memory result) = address(target).call(data);
require(success, "Low-level call failed");

EVM 在触发回滚时的响应如何?

虽然上述行为在大多数情况下适用于触发回滚时,EVM 的响应会根据错误类型和发生的上下文而有所不同。以下部分探讨 EVM 如何在不同场景中处理回滚。

requirerevertassert 的回滚行为

为了确保交易条件得到满足,Solidity 提供了错误处理语句 requirerevertassert 允许开发者定义操作应失败或回滚的条件。让我们逐一探讨。

Revert

revert 函数中止交易执行,撤销任何状态更改,并可以包含或不包含字符串参数以为回滚提供上下文。当没有字符串参数被调用时,revert 会触发状态回滚,但不向调用者提供信息。

function withdraw(uint256 amount) public {
    if (amount > balance) {
        revert(); // 没有返回消息
    }
    balance -= amount;
}

Revert 声明没有错误消息。

但是,如果提供了字符串参数,则交易将被回滚,并显示错误消息。

revert("Error: Insufficient balance");
// 在这里,“Error: Insufficient balance” 返回给调用者。

字符串“Insufficient balance”被编码到ABI规范中,方式与其他数据类型相同。确保消息可以被任何客户端(web3.js、ethers.js)解释。

注意: revert 的典型用法是在 if 条件中。然而,在错误或异常是明确且无依赖于运行时条件评估的情况下,可以无条件调用 revert 语句。

function deprecatedFunction() public pure {
    revert("This function is no longer supported");
}

没有条件检查的回滚。

Require

require 语句是一个条件检查,用于验证输入,确保在执行期间满足必要的状态条件,并提供可选的错误消息。如果指定条件返回 false,执行将被停止,所有状态更改会回滚。

require(msg.sender == player, "description");

revert 不同,require 直接将条件检查嵌入到语句中。例如, require(condition, "description") 相当于明确定义的 if (!condition) { revert("description");}

function withdraw() public {
    require(msg.sender == player, "Only the player can withdraw funds");
    payable(owner).transfer(address(this).balance);
}

带有错误消息的 require 声明。

例如,如果 withdraw 函数中的 require 语句的条件为 false,错误消息“Only the player can withdraw funds”将被返回,所有更改将被回滚。

Assert

assert 函数强制内部不变性并检测智能合约中的错误,以确保在执行期间保持不变条件。

assert 语句的失败表明存在重大程序错误。与 require 不同,assert 用于内部一致性和正确性,不用于用户输入或外部验证。

assert 失败时,EVM 将触发 Panic(uint256) 错误,状态更改将被回滚。此错误与由唯一 错误代码 表示的特定 内部错误类别 相关联。

Panic(uint256) 的返回值由以下组成:

函数选择器 标识错误,确保调用正确的错误处理例程。选择器是字符串的keccak256 哈希 的前 4 个字节。

错误代码: 一个 uint256 值,指定问题类型(例如,溢出、除以零)。

在下面的示例合约中,testOverFlow 因算术溢出而回滚,返回 Panic(uint256) 错误。返回值包括选择器 0x4e487b71 和代码 0x011

function testOverflow() public pure {
    uint256 maxUint = type(uint256).max;
    uint256 result = maxUint + 1; // 这会导致溢出
    assert(result > maxUint);  // 失败并触发 Panic(0x11)
}

区块链合约调用显示成功执行,十六进制输出表示结果。

算术溢出引发的 Panic(uint256) 错误

同样,除以零从空数组弹出数组越界 也会触发 恐慌错误。这种行为与回滚错误不同,后者可以包含自定义错误消息。相反,恐慌错误以标准格式编码失败原因,用于内部异常。

在超出 gas 操作中的错误返回

Solidity 中的超出 gas 操作发生在智能合约执行消耗的 gas 超过交易的 gas 限制时。这可能是由于不效率操作、无限循环或计算开销高的任务导致的。

当这种情况发生时,操作将停止,EVM 将回滚所有状态更改。然而,与 require()revert() 不同,超出 gas 的情况停止执行,不会向调用者传递任何原因。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract ExpensiveEtherTransfer {

    function sendEtherToMultiple(address payable[] memory recipients) public payable {
        uint256 amountPerRecipient = msg.value / recipients.length;

        for (uint256 i = 0; i < recipients.length; i++) {
            require(recipients[i] != address(0), "Invalid address");

            // 向每个接收方转账以太币
            recipients[i].transfer(amountPerRecipient);
        }
    }
}

对于高层函数调用(例如 contract.method()),一旦 gas 用完,EVM 立即回滚执行,并且不返回错误消息,因为没有足够的 gas 来编码它。在 低层函数调用(例如 calldelegatecall)中,超出 gas 错误使得 success 标志返回 false,而 bytes 返回数据保持为空( 0x),没有提供额外的信息。此行为同样适用于汇编调用。

注意: 超出 gas 的错误不属于 Solidity 的错误或 Panic(uint256) 机制。相反,它们是由于 gas 不足而导致的 EVM 级别的内在失败。

在 try/catch 块中失败的外部函数调用

Try/catch 是一种控制结构,用于处理外部函数调用或合约交互期间的异常。该块在不影响错误发生之前所做的状态更改的情况下本地处理运行时错误。如果在被调用的合约内发生错误,只有特定合约内的状态更改会被回滚。

在这些情况下,调用合约内部的任何错误(例如,try 或 catch 块内部的错误)不会自动被 try/catch 捕获,除非单独处理,事务会回滚。这意味着必须使用明确的错误处理(如 revert require 或自定义错误处理)来处理发生在 try 或 catch 块内部的错误。

contract TryCatchExample {
    event Success(string message);
    event ErrorHandled(string reason);
    event LowLevelError(string description);

    ExternalContract public externalContract;

    // 构造函数初始化外部合约
    constructor(address externalContractAddress) {
        externalContract = ExternalContract(externalContractAddress);
    }

    // 调用外部合约的 riskyOperation 函数
    function execute(uint256 value) public {
        try externalContract.riskyOperation(value) returns (string memory result) {
            // 捕获成功执行
            emit Success(result);
        } catch Error(string memory reason) {
            // 捕获带理由字符串的回滚错误
            emit ErrorHandled(reason);
        } catch (bytes memory lowLevelData) {
            // 捕获低层错误(例如,超出 gas 或无效操作码)
            emit LowLevelError("Low-level error encountered");
        }
    }
}

从上面的代码片段可以看出,catch Error(string memory reason) 块捕获 Error(string) 基础的错误。它提取出原因字符串,并允许开发者进行程序化处理。如果 revertrequire 语句包含原因字符串,它将匹配 Error(string) 格式并可以在该块中被捕获。

这意味着 revert("Some reason")require(false, "Some reason") 将被 catch Error(string memory reason) 块拦截。

但是,自定义错误(例如,error CustomError(uint256 value)) 在 try/catch 语法中没有直接支持来定义特定的 catch 块,如 catch CustomError(){}

try/catch 块中,catch (bytes memory lowLevelData)catch() {} 用于处理低层错误和未在早期 catch 块中明确捕获的错误。

结论

理解如何触发回滚有助于 Solidity 开发者调试失败的条件、验证输入并防止意外行为。此理解对于减少 gas 成本和确保合约安全至关重要。

此外,使用 try/catch 块处理外部调用中的错误可以让开发者本地管理异常,而不影响整个合约的状态。这使得有效调试和测试智能合约变得更加容易。

  • 原文链接: cyfrin.io/blog/what-happ...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.