【区块链安全 | 第二十六篇】表达式与控制结构(二)

在这里插入图片描述

表达式与控制结构

赋值

结构化赋值与返回多个值

Solidity 内部支持元组类型(tuple types),即由多个(可能类型不同)且数量在编译时已知的元素组成的列表。元组通常用于一次性返回多个值。这些返回值既可以赋值给新声明的变量,也可以赋值给已有变量(或更广义上的左值 LValues)。

需要注意的是,元组在 Solidity 中并不是一种独立的类型,它们仅作为表达式的一种语法组合存在。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;

contract C {
    uint index;

    function f() public pure returns (uint, bool, uint) {
        return (7, true, 2);
    }

    function g() public {
        // 用类型声明变量并从返回的元组中赋值,
        // 不需要指定所有元素(但数量必须一致)。
        (uint x, , uint y) = f();
        // 交换变量值的常见技巧 —— 不适用于非值类型的存储变量。
        (x, y) = (y, x);
        // 可以省略部分组件(也适用于变量声明)。
        (index, , ) = f(); // 设置 index 为 7
    }
}

不允许将变量声明与非声明赋值混用,例如以下写法是无效的:(x, uint y) = (1, 2);

注意:当元组涉及引用类型(如数组、结构体等)时,进行多变量赋值时可能会引发非预期的复制行为,应特别小心使用。在这种情况下,赋值过程可能不会像预期那样浅拷贝或引用传递,可能导致逻辑错误或意外副作用。

数组和结构体的赋值复杂性

对于非值类型(如数组和结构体,包括 bytes 和 string),赋值的语义更为复杂,特别是在涉及内存(memory)与存储(storage)之间的数据传递时。

在下面的例子中,函数 g(x) 的调用不会改变 x,因为 x 会被复制到内存中;而 h(x) 则可以成功修改 x,因为它是通过引用传递的:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.22 <0.9.0;

contract C {
    uint[20] x;

    function f() public {
        g(x);
        h(x);
    }

    function g(uint[20] memory y) internal pure {
        y[2] = 3;
    }

    function h(uint[20] storage y) internal {
        y[3] = 4;
    }
}

解析如下:

  • g(x):传入的是 memory 类型的副本,对 y[2] 的修改不会影响原始的 x。

  • h(x):传入的是 storage 引用,对 y[3] 的修改将直接影响合约状态变量 x[3]。

这体现了 Solidity 中引用类型的核心行为:memory 会创建副本,storage 会引用原始数据。

因此,在处理数组和结构体等引用类型时,务必明确所用的数据位置(data location),以避免产生意料之外的副作用或逻辑错误。

作用域和声明

在 Solidity 中,变量在声明后将具有其类型对应的默认初始值,其字节表示为全零。这个“默认值”通常是该类型的典型“零状态”。例如,bool 类型的默认值为 false,而 uint 和 int 类型的默认值则为 0。对于定长数组以及 bytes1 至 bytes32,每个元素都将被初始化为其对应类型的默认值。对于动态数组、bytes 和 string 类型,默认值为空数组或空字符串。enum 类型的默认值为其定义中的第一个成员。

Solidity 的作用域规则遵循 C99(以及许多其他编程语言)中广泛采用的标准规则:变量从声明之后立即开始可见,直至包含该声明的最内层 {} 块结束为止。唯一的例外是 for 循环的初始化部分中声明的变量,其作用域仅限于整个 for 循环结构之内。

与参数相关的变量(如函数参数、修饰符参数、catch 参数等)在其所在的代码块中是可见的:函数和修饰符的参数在整个函数体或修饰符体内均可访问;catch 语句中的参数则仅在对应的 catch 块中可见。

在代码块之外声明的元素(如状态变量、函数、合约、用户定义类型等)即便在声明之前也可以被访问。这意味着状态变量可以在其定义之前被使用,函数也可以在定义前进行递归调用。

因此,下面的示例可以正常编译而不会产生任何警告,尽管两个变量使用了相同的名称,但由于它们处于互不重叠的作用域中,因此不会冲突:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
contract C {
    function minimalScoping() pure public {
        {
            uint same;
            same = 1;
        }

        {
            uint same;
            same = 3;
        }
    }
}

以下代码展示了 C99 作用域规则的一个特殊情况:第一次对 x 的赋值实际上会影响到外层变量,而非内层变量。无论如何,编译器都会发出遮蔽(shadowing)警告,提醒开发者外部变量已被内部变量所隐藏。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
// This will report a warning
contract C {
    function f() pure public returns (uint) {
        uint x = 1;
        {
            x = 2; // this will assign to the outer variable
            uint x;
        }
        return x; // x has value 2
    }
}

在 Solidity 0.5.0 版本之前,其作用域规则与 JavaScript 相似:在函数内部声明的变量在整个函数范围内都是可见的,无论该变量在函数体中的哪个位置被声明。换句话说,变量的作用域是函数级的(function-scoped),而非块级的(block-scoped)。

下面是一个示例,在 0.5.0 之前的版本中可以成功编译,但从 0.5.0 开始将会导致编译错误,因为 Solidity 从该版本起开始采用更严格的块级作用域规则:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
// This will not compile
contract C {
    function f() pure public returns (uint) {
        x = 2;
        uint x;
        return x;
    }
}

检查或不检查的算术运算

溢出或下溢是指在对一个不受限制的整数进行算术运算时,结果超出了该类型的表示范围。

在 Solidity 0.8.0 之前,算术运算会在发生溢出或下溢时自动环绕(wrap),这也导致了广泛使用额外的库来进行检查。自 Solidity 0.8.0 起,所有算术运算发生溢出或下溢时默认会回滚(revert),因此不再需要这些库。

如果希望恢复之前的行为,可以使用 unchecked 块:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract C {
    function f(uint a, uint b) pure public returns (uint) {
        // 这个减法会在下溢时环绕。
        unchecked { return a - b; }
    }
    function g(uint a, uint b) pure public returns (uint) {
        // 这个减法会在下溢时回滚。
        return a - b;
    }
}

调用 f(2, 3) 将返回 2**256-1,而调用 g(2, 3) 会导致断言失败。

unchecked 块可以在其内部的任何位置使用,但不能作为块的替代,并且不能嵌套使用。

该设置仅会影响语法上位于块内部的语句。从 unchecked 块内部调用的函数不会继承该属性。

  • 为避免歧义,不能在 unchecked 块内部使用 _;

  • 以下运算符在发生溢出或下溢时会导致断言失败,但如果在 unchecked 块内使用,则会在没有错误的情况下进行环绕(wrap): ++, --, +, 二进制 -, 一元 -, *, /, %, ** +=, -=, *=, /=, %=。

  • unchecked 块不能禁用零除或模零的检查。

  • 按位运算符不会执行溢出或下溢检查。例如,type(uint256).max << 3 不会回滚,即使 type(uint256).max * 8 会回滚。这在使用按位移位(<<, >>, <<=, >>=)替代整数除法和乘法时尤其明显。

  • 语句 int x = type(int).min; -x; 会导致溢出,因为负数范围可以容纳比正数范围多一个值。

  • 显式类型转换将始终进行截断,并且永远不会导致断言失败,唯一的例外是从整数转换为枚举类型时。

错误处理:Assert、Require、Revert 和异常

Solidity 通过状态回退异常来处理错误。发生异常时,当前调用(及其所有子调用)对状态所做的所有更改都会被撤销,并向调用者标记错误。

如果异常发生在子调用中,除非被 try/catch 语句捕获,否则异常会自动“冒泡”,即异常会被重新抛出。需要注意的是,低级函数 send、call、delegatecall 和 staticcall 有一个例外:如果发生异常,它们会返回 false 作为第一个返回值,而不是像其他函数那样“冒泡”。

对于低级函数 call、delegatecall 和 staticcall,如果被调用的账户不存在,它们会返回 true 作为第一个返回值,这是 EVM 设计的特殊行为。因此,在调用之前,必须显式检查账户是否存在。

异常可以包含错误数据,并且这些数据会作为错误实例传递回调用者。Solidity 中有两种内建错误类型:Error(string) 和 Panic(uint256)。其中,Error 用于“常规”错误条件,而 Panic 用于表示代码中不应出现的错误,通常是由于代码逻辑错误或其他未预见的情况。

通过 assert 进行 Panic 和通过 require 进行 Error

Solidity 提供了便利函数 assert 和 require 来检查条件,并在条件不满足时抛出异常。

assert 函数会创建一个类型为 Panic(uint256) 的错误。编译器在某些情况下也会创建相同类型的错误,如下所述。

assert 仅应当用于测试内部错误,并检查不变式。正常情况下,代码不应当触发 Panic 错误,即使是无效的外部输入。如果触发了 Panic,意味着合约中存在错误,必须修复它。语言分析工具可以帮助评估合约,找出可能导致 Panic 的条件和函数调用。

以下情况会触发 Panic 异常,错误数据中提供的错误代码表示具体的 Panic 类型:

  • 0x00:由编译器自动插入的通用 Panic 错误。

  • 0x01:当调用 assert 且传入的参数计算结果为 false 时。

  • 0x11:算术运算导致下溢或溢出,且不在 unchecked { … } 块内时。

  • 0x12:除以零或取模零时(例如 5 / 0 或 23 % 0)。

  • 0x21:将一个过大或负数的值转换为枚举类型时。

  • 0x22:访问存储字节数组并且该数组编码错误时。

  • 0x31:对空数组调用 .pop() 时。

  • 0x32:访问数组、bytesN 或数组切片的越界或负索引时(即 x[i],其中 i >= x.length 或 i < 0)。

  • 0x41:分配过多的内存或创建一个过大的数组时。

  • 0x51:调用零初始化的内部函数类型变量时。

require 函数提供了三种重载方式:

  • require(bool):如果条件为 false,则回滚并不返回任何数据(甚至没有错误选择器)。

  • require(bool, string):如果条件为 false,则回滚并带有一个 Error(string) 错误消息。

  • require(bool, error):如果条件为 false,则回滚并带有自定义的错误类型,错误数据由第二个参数提供。

注意:require 的条件表达式会不加限制地评估,因此需要特别小心,确保它们不会引起意外的副作用。例如,在 require(condition, CustomError(f())); 和 require(condition, f()); 中,函数 f() 无论条件是否为 true 或 false 都会被调用。

以下情况会导致编译器生成 Error(string) 异常(或没有数据的异常):

  • 调用 require(x) 且 x 的值为 false。

  • 使用 revert() 或 revert(“description”)。

  • 执行外部函数调用时,目标合约没有代码。

  • 合约通过公共函数接收 Ether,但没有使用 payable 修饰符(包括构造函数和回退函数)。

  • 合约通过公共 getter 函数接收 Ether。

在以下情况下,外部调用的错误数据(如果提供)会被转发,并可能导致 Error 或 Panic 异常(或其他任何类型的异常):

  • 如果 .transfer() 失败。

  • 如果我们通过消息调用一个函数,但它没有正确执行(例如,耗尽了 gas,找不到匹配的函数,或者自身抛出异常)。不过,低级操作调用(如 send、delegatecall、callcode 或 staticcall)除外,低级操作不会抛出异常,而是通过返回 false 来指示失败。

  • 如果我们使用 new 关键字创建合约,但合约创建未能正确完成。

我们可以为 require 提供一个消息字符串或自定义错误,但 不能 为 assert 提供错误消息。

注意:如果我们没有为 require 提供字符串或自定义错误参数,它将回滚并返回空的错误数据,甚至不包括错误选择器。

该示例展示了如何使用 require 来检查输入条件,并使用 assert 来进行内部错误检查:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;

contract Sharer {
    function sendHalf(address payable addr) public payable returns (uint balance) {
        require(msg.value % 2 == 0, "Even value required.");
        uint balanceBeforeTransfer = address(this).balance;
        addr.transfer(msg.value / 2);
        // 由于 transfer 在失败时会抛出异常且不能回调这里,
        // 所以我们不可能仍然拥有一半的 Ether。
        assert(address(this).balance == balanceBeforeTransfer - msg.value / 2);
        return address(this).balance;
    }
}

在内部,Solidity 执行 revert 操作(指令 0xfd)。这会导致 EVM 撤销对状态所做的所有更改。撤销的原因是没有安全的方式继续执行,因为预期的效果没有发生。为了保持事务的原子性,最安全的做法是撤销所有更改,并使整个交易(或至少是调用)无效。

在这两种情况下,调用者可以使用 try/catch 来响应此类失败,但被调用者中的更改将始终被撤销。

在 Solidity 0.8.0 之前,Panic 异常会使用无效的操作码,导致消耗所有可用的 gas。而在 Metropolis 发布之前,使用 require 进行的异常检查也会消耗所有 gas。

revert

通过 revert 语句和 revert 函数可以直接触发回滚(revert)。

revert 语句接受一个自定义错误作为直接参数,无需括号:

revert CustomError(arg1, arg2);

为了向后兼容,还可以使用 revert() 函数,它使用括号并接受一个字符串:

revert(); 
revert("description");

错误数据将传递回调用者,并可以在那里进行捕获。使用 revert() 会导致没有任何错误数据的回滚,而 revert(“description”) 会创建一个 Error(string) 错误。

与使用字符串描述相比,使用自定义错误实例通常更加节省成本,因为自定义错误的名称只需编码四个字节,而较长的描述可以通过 NatSpec 提供,并不会产生额外费用。

以下示例展示了如何将错误字符串和自定义错误实例与 revert 一起使用,并展示了与 require 的等效用法:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

contract VendingMachine {
    address owner;
    error Unauthorized();
    function buy(uint amount) public payable {
        if (amount > msg.value / 2 ether)
            revert("Not enough Ether provided.");
        // 另一种方式:
        require(
            amount <= msg.value / 2 ether,
            "Not enough Ether provided."
        );
        // 执行购买操作。
    }
    function withdraw() public {
        if (msg.sender != owner)
            revert Unauthorized();

        payable(msg.sender).transfer(address(this).balance);
    }
}

这两种方式 if (!condition) revert(…); 和 require(condition, …); 是等价的,只要传递给 revert 和 require 的参数没有副作用(例如,它们只是字符串)。

注意:
require 函数与其他函数一样会被评估。这意味着所有参数在函数执行前都会被评估。特别是,在 require(condition, f()) 中,即使条件为 true,f() 函数也会被执行。

提供的字符串会像调用 Error(string) 函数一样进行 ABI 编码。在上面的示例中,revert(“Not enough Ether provided.”); 会返回以下十六进制数据作为错误返回信息:

0x08c379a0                                                         // Error(string) 的函数选择器
0x0000000000000000000000000000000000000000000000000000000000000020 // 数据偏移量
0x000000000000000000000000000000000000000000000000000000000000001a // 字符串长度
0x4e6f7420656e6f7567682045746865722070726f76696465642e000000000000 // 字符串数据

调用者可以使用 try/catch 获取提供的消息,如下所示。

注意:
曾经有一个叫做 throw 的关键字,其语义与 revert() 相同。它在 Solidity 版本 0.4.13 中被弃用,并在版本 0.5.0 中被移除。

try/catch

外部调用的失败可以通过 try/catch 语句来捕获,示例如下:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.1;

interface DataFeed { 
    function getData(address token) external returns (uint value); 
}

contract FeedConsumer {
    DataFeed feed;
    uint errorCount;
    
    function rate(address token) public returns (uint value, bool success) {
        // 如果错误次数超过10次,永久禁用机制
        require(errorCount < 10);
        
        try feed.getData(token) returns (uint v) {
            return (v, true);
        } catch Error(string memory /*reason*/) {
            // 如果调用 getData 中的 revert,并且提供了错误原因字符串时,执行此处
            errorCount++;
            return (0, false);
        } catch Panic(uint /*errorCode*/) {
            // 如果发生 panic(例如除零错误或溢出),执行此处
            errorCount++;
            return (0, false);
        } catch (bytes memory /*lowLevelData*/) {
            // 如果调用 revert(),执行此处
            errorCount++;
            return (0, false);
        }
    }
}

try 关键字后必须跟一个外部函数调用或合约创建表达式(如 new ContractName())。表达式中的错误不会被捕获,只有外部调用内部发生的 revert 才会被捕获。returns 部分(可选)声明与外部调用返回类型匹配的返回变量。如果没有错误,这些变量将被赋值,合约执行会继续到第一个成功的代码块。如果成功块执行完毕,执行将继续到 catch 块之后。

Solidity 支持根据错误类型使用不同的 catch 块:

  • catch Error(string memory reason) { … }:如果错误是由 revert(“reasonString”) 或 require(false, “reasonString”)(或其他导致类似异常的内部错误)引起的,将执行此 catch 块。

  • catch Panic(uint errorCode) { … }:如果错误是由 panic(例如 assert 失败、除零、无效数组访问、算术溢出等)引起的,将执行此 catch 块。

  • catch (bytes memory lowLevelData) { … }:如果错误签名与其他 catch 块不匹配,或解码错误消息时发生错误,或者异常未提供错误数据,则会执行此块。声明的变量将提供对低级错误数据的访问。

  • catch { … }:如果你不关心错误数据,可以使用 catch { … }(即使它是唯一的 catch 块)。

为了捕获所有错误情况,至少需要包含 catch { … } 或 catch (bytes memory lowLevelData) { … } 之一。

在 returns 和 catch 块中声明的变量仅在其后跟随的块中有效。

注意:
1.如果在 try/catch 语句内解码返回数据时发生错误,将导致当前执行的合约抛出异常,因此该错误不会被 catch 块捕获。如果在解码 catch Error(string memory reason) 时发生错误,而有低级 catch 块存在,那么该错误会被低级 catch 块捕获。
2.如果执行进入 catch 块,外部调用的状态更改效果已经被回滚。如果执行进入成功块,则效果不会回滚。如果效果已被回滚,执行要么继续在 catch 块中,要么整个 try/catch 语句会回滚(例如,由于解码失败或没有提供低级 catch 块)。
3.失败调用的原因可能有很多种。不要假设错误信息直接来自被调用合约:错误可能在调用链的更深处发生,且被调用合约只是转发了错误。此外,错误也可能是由于超出 gas 限制的情况,而不是故意的错误条件:调用方始终保留至少 1/64 的 gas,因此即使被调用合约耗尽 gas,调用方仍然有剩余的 gas。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

秋说

感谢打赏

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值