【区块链安全 | 第二十八篇】合约(二)

在这里插入图片描述

合约

常量(Constant)和不可变(Immutable)状态变量

状态变量可以声明为常量(constant)或不可变(immutable)。在这两种情况下,变量在合约部署后不能再被修改。对于常量变量,其值必须在编译时固定;而不可变变量则可以在构造函数中进行赋值。

常量变量也可以在文件级别定义。

每次出现这些变量时,源代码中的变量都会被其底层值替换,且编译器不会为其分配存储槽。同时,不能使用 transient 关键字将其分配到临时存储槽。

与常规状态变量相比,常量和不可变变量的 Gas 费用要低得多。对于常量变量,赋给它的表达式会被复制到所有访问它的地方,并且每次都重新评估,从而允许进行局部优化。不可变变量只在构造时评估一次,其值会复制到所有访问它的地方。虽然不可变变量的值可以适应更少的字节,但它们仍然会保留 32 字节的存储空间,因此常量值有时比不可变值更便宜。

需要注意,并非所有类型都可以用作常量和不可变变量。目前,仅支持字符串(仅限常量)和值类型。

示例如下:

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

uint constant X = 32**22 + 8;

contract C {
    string constant TEXT = "abc";
    bytes32 constant MY_HASH = keccak256("abc");
    uint immutable decimals = 18;
    uint immutable maxBalance;
    address immutable owner = msg.sender;

    constructor(uint decimals_, address ref) {
        if (decimals_ != 0)
            // 不可变变量仅在部署时不可修改。
            // 在构造时可以被赋值多次。
            decimals = decimals_;

        // 对不可变变量的赋值甚至可以访问环境变量。
        maxBalance = ref.balance;
    }

    function isBalanceTooHigh(address other) public view returns (bool) {
        return other.balance > maxBalance;
    }
}

常量(Constant)

对于常量变量,值必须在编译时是常量,并且必须在声明时赋值。任何访问存储、区块链数据(例如 block.timestamp、address(this).balance 或 block.number)或执行数据(如 msg.value 或 gasleft())的表达式,或者调用外部合约的表达式,都是不被允许的。然而,允许那些可能对内存分配产生副作用的表达式,但不允许那些可能对其他内存对象产生副作用的表达式。内置函数如 keccak256、sha256、ripemd160、ecrecover、addmod 和 mulmod 是允许的,尽管除了 keccak256 之外,这些函数实际上会调用外部合约。

允许内存分配器产生副作用的原因是,开发者应该能够构造如查找表等复杂对象。该特性目前尚未完全实现。

不可变(Immutable)

不可变变量的限制比常量变量略微宽松:它们可以在构造函数中赋值。赋值可以在部署前的任何时刻进行,赋值后变量会变得永久不可变。

不过,不可变变量有一个限制:它们只能在构造函数中赋值,不能在其他修改器或函数中进行赋值。

对于不可变变量的读取没有任何限制。在第一次赋值之前也允许读取这些变量,因为在 Solidity 中,变量始终有一个明确的初始值。因此,甚至可以在没有显式赋值的情况下访问不可变变量。

访问不可变变量时,请注意初始化顺序。即使你提供了明确的初始化值,一些表达式可能会在初始化器之前进行评估,特别是在继承层次结构中的不同级别时。

在 Solidity 0.8.21 之前,不可变变量的初始化规则更加严格。此类变量必须在构造时进行初始化,并且在此之前无法读取。

编译器生成的合约创建代码会在返回合约的运行时代码之前进行修改,替换所有对不可变变量的引用为它们的赋值值。这一点对于将编译器生成的运行时代码与实际存储在区块链上的字节码进行比较时非常重要。编译器还会在 JSON 标准输出的 immutableReferences 字段中,输出这些不可变变量在部署字节码中的位置。

自定义存储布局

合约可以使用布局说明符来定义其存储的任意位置。合约的状态变量,包括从基类继承的变量,将从指定的基础槽(base slot)开始,而不是默认的零槽。

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

contract C layout at 0xAAAA + 0x11 {
    uint[3] x; // 占用槽 0xAABB..0xAABD
}

如上例所示,布局说明符使用 layout at 语法,并位于合约定义的头部。

布局说明符可以放在继承说明符之前或之后,并且最多只能出现一次。base-slot-expression 必须是一个整数文字表达式,可以在编译时求值,并且生成一个位于 uint256 范围内的值。

自定义布局不能使合约的存储“环绕”。如果选择的基础槽将静态变量推到存储的末端,编译器将发出错误。需要注意的是,动态数组和映射的数据区域不受此检查的影响,因为它们的布局不是线性的。无论使用哪个基础槽,它们的位置始终根据某种方式计算,确保它们位于 uint256 范围内,并且它们的大小在编译时是不可知的。

虽然对基础槽没有其他限制,但建议避免选择过于接近地址空间末端的位置。留下过少的空间可能会使合约升级变得复杂,或者在合约使用内联汇编存储额外值时引发问题。

存储布局只能为继承树中的最顶层合约指定,并且会影响该树中所有合约的存储变量位置。变量会按照其定义顺序以及合约在线性化继承层次结构中的位置进行布局。自定义基础槽会将所有变量的位置整体平移。

存储布局不能为抽象合约、接口和库指定。此外,值得注意的是,布局说明符不会影响瞬态状态变量。

有关存储布局以及布局说明符如何影响其的详细信息,请参见存储变量布局。

注意:
layout 和 at 标识符尚未作为语言中的保留关键字。建议避免使用它们,因为它们将在未来的破坏性版本中成为保留关键字。

函数

函数可以在合约内部或外部定义。

合约外部的函数,也称为“自由函数”,始终具有隐式的 internal 可见性。它们的代码会被包含在所有调用它们的合约中,类似于内部库函数。

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

function sum(uint[] memory arr) pure returns (uint s) {
    for (uint i = 0; i < arr.length; i++)
        s += arr[i];
}

contract ArrayExample {
    bool found;
    function f(uint[] memory arr) public {
        // 这将在内部调用自由函数。
        // 编译器会将其代码添加到合约中。
        uint s = sum(arr);
        require(s >= 10);
        found = true;
    }
}

注意:定义在合约外部的函数仍然始终在合约的上下文中执行。它们可以调用其他合约、发送以太币、销毁调用它们的合约等。与合约内部定义的函数的主要区别在于,自由函数无法直接访问 this、存储变量以及不在其作用域内的函数。

函数参数和返回变量

函数可以接受类型化的参数作为输入,并且与许多其他语言不同,函数还可以返回任意数量的值作为输出。

函数参数

函数参数的声明方式与变量相同,且未使用的参数名称可以省略。

例如,如果你希望合约接受一个外部调用,传递两个整数,可以使用以下方式:

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

contract Simple {
    uint sum;
    function taker(uint a, uint b) public {
        sum = a + b;
    }
}

函数参数可以像其他局部变量一样使用,并且可以进行赋值。

返回变量

函数的返回变量使用与 returns 关键字后相同的语法进行声明。

例如,假设你想返回两个结果:两个整数的和与积,可以使用以下方式:

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

contract Simple {
    function arithmetic(uint a, uint b)
        public
        pure
        returns (uint sum, uint product)
    {
        sum = a + b;
        product = a * b;
    }
}

返回变量的名称是可选的。返回变量可以像其他局部变量一样使用,默认会初始化为默认值,并在被(重新)赋值之前保持该值。

你可以显式地给返回变量赋值,然后像上面那样退出函数,或者也可以通过 return 语句直接提供返回值(一个或多个):

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

contract Simple {
    function arithmetic(uint a, uint b)
        public
        pure
        returns (uint sum, uint product)
    {
        return (a + b, a * b);
    }
}

如果你使用早期返回来退出一个包含返回变量的函数,必须与 return 语句一起提供返回值。

注意:你不能从非内部函数返回某些类型,包括以下列出的类型以及任何递归包含它们的复合类型:

  • 映射(mappings)

  • 内部函数类型(internal function types)

  • 设置为存储(storage)位置的引用类型(reference types)

  • 多维数组(仅适用于 ABI 编码器 v1)

  • 结构体(仅适用于 ABI 编码器 v1)

由于库函数具有不同的内部 ABI,这一限制不适用于库函数。

返回多个值

当一个函数有多个返回类型时,可以使用 return (v0, v1, …, vn) 语句返回多个值。返回值的数量必须与返回变量的数量相匹配,且它们的类型也必须兼容,可能需要进行隐式转换。

状态可变性

查看函数 (View Functions)

函数可以声明为 view,表示它们承诺不会修改合约的状态。

注意:
如果编译器的 EVM 目标是 Byzantium 或更高版本(这是默认设置),在调用 view 函数时,会使用 STATICCALL 操作码。这确保了在 EVM 执行过程中,状态不会被修改。对于库中的 view 函数,则使用 DELEGATECALL,因为 DELEGATECALL 和 STATICCALL 没有合并。这意味着库中的 view 函数没有运行时检查机制来防止状态修改。然而,由于库代码通常是已知的,并且静态分析工具会在编译时进行检查,因此这不会影响安全性。

以下操作被视为修改状态:

  • 写入状态变量(包括存储和瞬态存储)。

  • 触发事件。

  • 创建新合约。

  • 使用 selfdestruct 销毁合约。

  • 通过调用发送以太币。

  • 调用任何未标记为 view 或 pure 的函数。

  • 使用低级调用。

  • 使用包含特定操作码的内联汇编。

以下是一个 view 函数的示例:

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

contract C {
    function f(uint a, uint b) public view returns (uint) {
        return a * (b + 42) + block.timestamp;
    }
}

在 Solidity 版本 0.5.0 之前,constant 被用作函数的别名,相当于 view。但在 0.5.0 版本中,constant 被移除。Getter 方法会自动标记为 view。

此外,在 0.5.0 版本之前,编译器没有为 view 函数使用 STATICCALL 操作码,导致通过无效的显式类型转换可能修改合约状态。通过为 view 函数使用 STATICCALL,EVM 层面上会防止状态修改。

纯函数 (Pure Functions)

函数可以声明为 pure,表示它们承诺既不读取也不修改合约的状态。具体来说,纯函数应当能够仅依赖其输入和 msg.data,在编译时就能计算出结果,而无需访问当前区块链的状态。因此,读取不可变变量(immutable)被视为非纯操作。

注意:
如果编译器的 EVM 目标是 Byzantium 或更高版本(默认设置),会使用 STATICCALL 操作码。虽然 STATICCALL 可以确保状态不被修改,但它并不能完全保证状态不会被读取。

除了上述被认为是修改状态的操作,以下操作也被视为读取状态:

  • 读取状态变量(包括存储和瞬态存储)。

  • 访问 address(this).balance 或

    .balance。

  • 访问 block、tx、msg 中的任何成员(除了 msg.sig 和 msg.data)。

  • 调用任何未标记为 pure 的函数。

  • 使用包含特定操作码的内联汇编。

以下是一个 pure 函数的示例:

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

contract C {
    function f(uint a, uint b) public pure returns (uint) {
        return a * (b + 42);
    }
}

纯函数也可以使用 revert() 和 require() 函数来撤销潜在的状态变化。撤销状态变化不被视为“状态修改”,因为只有先前未标记为 view 或 pure 的代码中的状态变化才会被撤销,而这些代码有选择性地捕获了 revert,不会传播。

这种行为符合 STATICCALL 操作码的行为。

在 EVM 层面,无法防止函数读取状态,EVM 只能防止它们修改状态(即,强制执行 view,但不能强制执行 pure)。

特殊函数

接收以太币函数 (Receive Ether Function)

一个合约最多只能有一个 receive 函数,该函数使用 receive() external payable { … } 来声明(不需要使用 function 关键字)。receive 函数必须具备以下特点:

  • 不接受参数。

  • 不返回任何值。

  • 必须具有 external 可见性和 payable 状态可变性。

该函数可以是虚拟的(virtual),可以被继承的子合约重写,并且可以附加修饰符。

receive 函数会在合约被调用且 calldata 为空时执行。特别地,当通过 .send() 或 .transfer() 进行简单的以太币转账时,EVM 会调用该函数。如果没有定义 receive 函数,但存在一个 payable 的回退函数(fallback 函数),则会调用回退函数来接收以太币。如果合约既没有定义 receive 函数,也没有定义 payable 的回退函数,则合约无法通过非 payable 的函数接收以太币,并且会抛出异常。

需要注意的是,receive 函数的执行有一个 2300 gas 的限制(例如,使用 send 或 transfer 进行转账时),这使得该函数只能执行一些基本操作,如记录日志等。任何消耗超过 2300 gas 的操作(如以下所列)都无法在 receive 函数内执行:

  • 写入存储
  • 创建合约
  • 调用外部函数(尤其是消耗大量 gas 的外部函数)
  • 发送以太币

如果以太币被直接发送到合约(即没有函数调用,而是发送方使用 send 或 transfer),且接收合约未定义 receive 函数或 payable 的回退函数时,交易将抛出异常,且以太币会被退回(在 Solidity v0.4.0 之前,行为有所不同)。为了使合约能够接收以太币,必须定义 receive 函数(不推荐使用 payable 回退函数来接收以太币,因为回退函数会被调用,并且可能造成发送方接口混淆的问题)。

没有 receive 函数的合约仍然可以接收矿工区块奖励(即矿工奖励),或者可以作为 selfdestruct 的目标地址。

这些转账无法被合约处理,因此合约不能对其做出反应或拒绝这些转账。这是 EVM 的设计选择,Solidity 无法绕过这一点。

这种设计也意味着 address(this).balance 可能会高于合约中的某些手动计算值(例如在 receive 函数中更新的计数器)。

以下是一个使用 receive 函数的 Sink 合约示例:

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

// 这个合约接收所有发送到它的以太币,并没有方法可以取回。
contract Sink {
    event Received(address, uint);
    receive() external payable {
        emit Received(msg.sender, msg.value);
    }
}
回退函数 (Fallback Function)

一个合约最多只能定义一个回退函数,使用 fallback () external [payable] 或 fallback (bytes calldata input) external [payable] returns (bytes memory output) 来声明(不需要使用 function 关键字)。该函数必须具备 external 可见性,并且可以是虚拟的(virtual),可以被继承的子合约重写,并且可以附加修饰符。

回退函数会在合约被调用时执行,具体情境包括:

  • 如果没有其他函数与调用的函数签名匹配;

  • 如果没有提供数据并且合约没有定义 receive 以太币函数时。

回退函数总是会接收到数据,但如果希望它同时接收以太币,则必须标记为 payable。

如果使用带参数的版本,input 参数包含发送到合约的所有数据(相当于 msg.data),而 output 会返回数据。返回的数据不会进行 ABI 编码,而是原样返回(即不做填充)。

在最坏的情况下,如果没有定义 receive 函数,并且回退函数是 payable,那么它同样受到 2300 gas 的限制(这与 receive 以太币函数的限制相同)。

回退函数与其他函数一样,只要有足够的 gas,就可以执行复杂的操作。

警告:
如果没有定义 receive 以太币函数,且回退函数是 payable,则该回退函数也会在简单的以太币转账时被调用。因此,建议在定义 payable 的回退函数时,始终同时定义一个 receive 以太币函数,以区分以太币转账与接口混淆问题。

注意:
如果需要解码输入数据,可以检查前四个字节以提取函数选择符,然后使用 abi.decode 和数组切片语法来解码 ABI 编码的数据。例如:(c, d) = abi.decode(input[4:], (uint256, uint256)); 但是,建议仅在无法通过其他方式处理时才使用此方法,优先使用适当的函数进行处理。

示例代码如下:

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

contract Test {
    uint x;
    // 这个函数会在所有消息发送到该合约时调用
    // 因为没有其他函数。
    // 发送以太币到此合约会导致异常,
    // 因为回退函数没有 `payable` 修饰符。
    fallback() external { x = 1; }
}

contract TestPayable {
    uint x;
    uint y;
    // 这个函数会在所有消息发送到该合约时调用,除了简单的以太币转账
    // (除了 `receive` 函数,没有其他函数)。
    // 任何带有非空 calldata 的调用都会执行回退函数(即使以太币与调用一起发送)。
    fallback() external payable { x = 1; y = msg.value; }

    // 这个函数会在简单的以太币转账时调用,即
    // 每个没有 calldata 的调用。
    receive() external payable { x = 2; y = msg.value; }
}

contract Caller {
    function callTest(Test test) public returns (bool) {
        (bool success,) = address(test).call(abi.encodeWithSignature("nonExistingFunction()"));
        require(success);
        // 结果是 test.x 变为 == 1。

        // address(test) 不允许直接调用 ``send``,因为 ``test`` 没有 `payable`
        // 回退函数。
        // 必须将其转换为 ``address payable`` 类型才能允许调用 ``send``。
        address payable testPayable = payable(address(test));

        // 如果有人向该合约发送以太币,
        // 转账将失败,即此处返回 false。
        return testPayable.send(2 ether);
    }

    function callTestPayable(TestPayable test) public returns (bool) {
        (bool success,) = address(test).call(abi.encodeWithSignature("nonExistingFunction()"));
        require(success);
        // 结果是 test.x 变为 == 1,test.y 变为 0。
        (success,) = address(test).call{value: 1}(abi.encodeWithSignature("nonExistingFunction()"));
        require(success);
        // 结果是 test.x 变为 == 1,test.y 变为 1。

        // 如果有人向该合约发送以太币,`TestPayable` 中的 `receive` 函数会被调用。
        // 由于该函数会写入存储,它消耗的 gas 比使用简单的 ``send`` 或 ``transfer`` 要多。
        // 因此,我们必须使用低级调用。
        (success,) = address(test).call{value: 2 ether}("");
        require(success);
        // 结果是 test.x 变为 == 2,test.y 变为 2 ether。

        return true;
    }
}

函数重载

合约可以定义多个具有相同名称但参数类型不同的函数,这种行为被称为“函数重载”,并且适用于继承自父合约的函数。以下示例展示了在合约 A 中如何实现函数 f 的重载:

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

contract A {
    function f(uint value) public pure returns (uint out) {
        out = value;
    }

    function f(uint value, bool really) public pure returns (uint out) {
        if (really)
            out = value;
    }
}

重载函数也会出现在外部接口中。如果两个外部可见的函数在 Solidity 类型上不同,但它们的外部类型相同,则会导致编译错误。例如,下面的代码将无法编译:

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

// 这将无法编译
contract A {
    function f(B value) public pure returns (B out) {
        out = value;
    }

    function f(address value) public pure returns (address out) {
        out = value;
    }
}

contract B {
}

在上述示例中,两个 f 函数最终都接收 address 类型(在 ABI 中),尽管在 Solidity 中它们被视为不同的类型。这个问题会导致编译时错误,因为外部可见的函数必须有唯一的外部签名。

重载解析与参数匹配

当选择重载函数时,Solidity 会根据当前作用域中的函数声明以及函数调用时提供的参数进行匹配。如果所有参数都能隐式转换为预期的类型,则该函数会被选为重载候选函数。如果没有唯一的候选函数,则解析会失败。

注意:
返回参数不参与重载解析。

以下代码展示了如何根据传入的参数选择重载的函数:

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

contract A {
    function f(uint8 val) public pure returns (uint8 out) {
        out = val;
    }

    function f(uint256 val) public pure returns (uint256 out) {
        out = val;
    }
}

在调用 f(50) 时,将产生类型错误,因为 50 可以隐式转换为 uint8 和 uint256 两种类型。因此,无法唯一地选择重载函数。而在调用 f(256) 时,解析会选择 f(uint256),因为 256 不能隐式转换为 uint8 类型。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

秋说

感谢打赏

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

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

打赏作者

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

抵扣说明:

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

余额充值