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

在这里插入图片描述

合约

在 Solidity 中,合约类似于面向对象语言中的类。它们包含持久化的数据(存储在状态变量中),并且有可以修改这些变量的函数。调用另一个合约(实例)上的函数会执行一个 EVM 函数调用,切换上下文,使得调用合约中的状态变量不可访问。为了操作合约中的数据,必须通过调用合约的函数。

以太坊没有“cron”概念,不能在特定的时间或事件发生时自动调用函数。所有函数调用都需要外部触发,例如用户发起交易或调用。

创建合约

合约可以通过以太坊交易从外部创建,也可以通过 Solidity 合约内部创建。

例如,像 Remix 这样的 IDE 通过 UI 元素使得合约创建过程更加直观和简便。

另一种在以太坊上程序化创建合约的方法是通过 JavaScript API web3.js。web3.js 提供了一个名为 web3.eth.Contract 的函数,用来简化合约的创建。

当一个合约被创建时,其构造函数(使用 constructor 关键字声明的函数)会被执行一次。构造函数是可选的,但一个合约只能有一个构造函数,这意味着不支持构造函数的重载。

构造函数执行完成后,合约的最终代码会存储在区块链上。该代码包括所有公共和外部函数以及所有可以通过函数调用访问的内部函数。已部署的合约代码不包括构造函数的代码或仅在构造函数中调用的内部函数。

在合约内部,构造函数的参数会以 ABI 编码的方式传递,但如果通过 web3.js 创建合约时,开发者通常无需关心这一点。

值得注意的是,如果一个合约想要创建另一个合约,创建合约的源代码(和二进制代码)必须是已知的。这就意味着不可能存在循环创建依赖的情况。

示例如下:

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


contract OwnedToken {
    // `TokenCreator` 是在下面定义的合约类型。
    // 只要不用于创建新合约,引用它是没问题的。
    TokenCreator creator;
    address owner;
    bytes32 name;

    // 这是构造函数,注册了
    // creator 和分配的 name。
    constructor(bytes32 name_) {
        // 状态变量通过它们的名称来访问
        // 而不是通过比如 `this.owner`。函数可以
        // 直接访问或通过 `this.f` 访问,
        // 但后者提供了对该函数的外部视图。
        // 特别是在构造函数中,
        // 不应该外部访问函数,
        // 因为该函数尚不存在。
        // 请参阅下一节了解详情。
        owner = msg.sender;

        // 我们从 `address` 显式类型转换到 `TokenCreator`
        // 并假设调用合约的类型是 `TokenCreator`,
        // 这里没有真正的方式验证这一点。
        // 这并不会创建一个新合约。
        creator = TokenCreator(msg.sender);
        name = name_;
    }

    function changeName(bytes32 newName) public {
        // 只有 creator 可以更改名称。
        // 我们基于它的地址来比较合约,
        // 地址可以通过显式转换为地址来获取。
        if (msg.sender == address(creator))
            name = newName;
    }

    function transfer(address newOwner) public {
        // 只有当前所有者才能转移 token。
        if (msg.sender != owner) return;

        // 我们询问 creator 合约是否应该继续转移
        // 通过使用下面定义的 `TokenCreator` 合约的函数。
        // 如果调用失败(例如由于缺少 gas),
        // 这里的执行也会失败。
        if (creator.isTokenTransferOK(owner, newOwner))
            owner = newOwner;
    }
}


contract TokenCreator {
    function createToken(bytes32 name)
        public
        returns (OwnedToken tokenAddress)
    {
        // 创建一个新的 `Token` 合约并返回其地址。
        // 从 JavaScript 端来看,该函数的返回类型
        // 是 `address`,因为这是 ABI 中最接近的类型。
        return new OwnedToken(name);
    }

    function changeName(OwnedToken tokenAddress, bytes32 name) public {
        // 同样,`tokenAddress` 的外部类型
        // 仅仅是 `address`。
        tokenAddress.changeName(name);
    }

    // 执行检查以确定是否应该将 token 转移到
    // `OwnedToken` 合约
    function isTokenTransferOK(address currentOwner, address newOwner)
        public
        pure
        returns (bool ok)
    {
        // 检查一个任意条件,看看是否应该继续转移
        return keccak256(abi.encodePacked(currentOwner, newOwner))[0] == 0x7f;
    }
}

在这个示例中:

  • constructor 是合约的构造函数,它将 owner 设置为合约创建时传入的地址 _owner。

  • setOwner 是一个公共函数,它允许合约的所有者(即当前 owner)修改合约的 owner 地址。

合约一旦部署,constructor 只会执行一次,之后所有状态更改都需要通过调用合约函数进行。

可见性与获取器

状态变量可见性

public
公共状态变量是合约中可以被外部访问的状态变量。与内部状态变量不同,公共状态变量会自动生成一个 getter 函数,使得其他合约和用户可以读取其值。对于同一合约内的访问,外部访问(例如 this.x)会通过自动生成的 getter 函数,而内部访问(例如 x)则直接从存储中获取。需要注意的是,编译器不会为公共变量生成 setter 函数,因此其他合约无法直接修改它们的值。

internal
内部状态变量只能在当前合约内部以及派生合约中访问,无法从外部直接访问。对于状态变量而言,internal 是默认的可见性级别。

private
私有状态变量与内部状态变量类似,唯一的区别在于私有状态变量无法被派生合约访问。它们只能在定义它们的合约内部访问,派生合约无法继承并访问这些变量。

注意:
无论是私有(private)还是内部(internal)状态变量,它们仍然在区块链上可见。其他合约无法直接访问这些变量,但区块链的任何参与者(包括使用区块链浏览器的用户)都可以查看它们的值。

函数可见性

Solidity 中的函数调用有两种类型:外部调用和内部调用。函数的可见性决定了它能否被外部调用,是否可以被继承等。以下是四种主要的函数可见性类型。

external
外部函数是合约接口的一部分,可以通过交易或其他合约调用。外部函数不能在合约内部被直接调用。例如,如果有一个外部函数 f,则 f() 在合约内部无法调用,但可以通过 this.f() 来调用。

public
公共函数也是合约接口的一部分,可以通过内部或外部调用访问。它们不仅可以被合约内部访问,也可以通过交易调用或其他合约调用。

internal
内部函数只能从当前合约内部或继承它的合约中访问。它们不会暴露给合约的 ABI,因此无法通过外部交易访问。内部函数适用于在合约内部操作复杂的逻辑,但不需要公开接口的情况。

private
私有函数与内部函数类似,但它们不能在派生合约中访问。它们只能在定义它们的合约内部访问。

注意:
设置为 private 或 internal 的函数或变量不能完全保证隐私,因为这些设置仅阻止合约外部的合约访问这些函数或变量。区块链上的数据仍然是公开的,任何参与者都能查看(例如通过区块链浏览器)。

在以下示例中,合约 D 无法编译,因为它尝试调用 f 和 compute 函数,这两个函数的可见性不允许合约 D 访问它们。f 是 private,因此只能在合约 C 内部访问,而 compute 是 internal,只能在继承了 C 的合约中访问。而合约 E 继承了 C,因此合约 E 可以访问 compute:

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

contract C {
    uint private data;

    function f(uint a) private pure returns(uint b) { return a + 1; }
    function setData(uint a) public { data = a; }
    function getData() public view returns(uint) { return data; }
    function compute(uint a, uint b) internal pure returns (uint) { return a + b; }
}

// 这将无法编译
contract D {
    function readData() public {
        C c = new C();
        uint local = c.f(7); // 错误:成员 `f` 不可见
        c.setData(3);
        local = c.getData();
        local = c.compute(3, 5); // 错误:成员 `compute` 不可见
    }
}

contract E is C {
    function g() public {
        C c = new C();
        uint val = compute(3, 5); // 访问内部成员(从派生到父合约)
    }
}

通过这样的示例,可以看出 Solidity 中不同可见性说明符的作用:private 限制了函数的访问范围为当前合约内部,internal 使得函数在当前合约及其子合约中可访问,而 public 则使得函数可以在任何地方调用。

获取器函数

编译器会为所有公共状态变量自动创建 getter 函数。对于下面给出的合约,编译器会生成一个名为 data 的函数,该函数不接受任何参数并返回 uint 类型,返回的值是状态变量 data 的值。状态变量可以在声明时初始化。

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

contract C {
    uint public data = 42;
}

contract Caller {
    C c = new C();
    function f() public view returns (uint) {
        return c.data(); // 调用 getter 函数
    }
}

这些 getter 函数具有外部可见性。如果在合约内部访问符号(即不使用 this.),它会被当作一个状态变量。如果在外部访问符号(即使用 this.),它会被当作一个函数。

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

contract C {
    uint public data;
    function x() public returns (uint) {
        data = 3; // 内部访问
        return this.data(); // 外部访问
    }
}

如果你有一个公共的数组类型的状态变量,那么你只能通过生成的 getter 函数来检索数组中的单个元素。这个机制存在是为了避免返回整个数组时产生高昂的 gas 费用。你可以使用参数来指定返回哪一个单独的元素,例如 myArray(0)。如果你想一次性返回整个数组,那么你需要编写一个函数,例如:

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

contract arrayExample {
    // 公共状态变量
    uint[] public myArray;

    // 编译器生成的 getter 函数
    /*
    function myArray(uint i) public view returns (uint) {
        return myArray[i];
    }
    */

    // 返回整个数组的函数
    function getArray() public view returns (uint[] memory) {
        return myArray;
    }
}

现在,你可以使用 getArray() 来检索整个数组,而不是使用 myArray(i),后者每次调用时只能返回一个元素。

更复杂的示例:

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

contract Complex {
    struct Data {
        uint a;
        bytes3 b;
        mapping(uint => uint) map;
        uint[3] c;
        uint[] d;
        bytes e;
    }
    mapping(uint => mapping(bool => Data[])) public data;
}

在上述代码中,编译器会生成类似下面的函数。由于映射和数组(字节数组除外)在结构体中无法单独访问,所以它们被省略了:

function data(uint arg1, bool arg2, uint arg3)
    public
    returns (uint a, bytes3 b, bytes memory e)
{
    a = data[arg1][arg2][arg3].a;
    b = data[arg1][arg2][arg3].b;
    e = data[arg1][arg2][arg3].e;
}

函数修饰符

修饰符可以通过声明方式改变函数的行为。例如,可以使用修饰符在执行函数之前自动检查某个条件。

修饰符是合约的继承属性,可以被派生合约重写,但只有在它们被标记为 virtual 时才可以重写。

示例如下:

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

contract owned {
    constructor() { owner = payable(msg.sender); }
    address payable owner;

    // 本合约仅定义了一个修饰符,但没有使用它:它将在派生合约中使用。
    // 函数体将在修饰符定义中的特殊符号 `_;` 处插入。
    // 这意味着,如果是所有者调用此函数,函数会执行,否则会抛出异常。
    modifier onlyOwner {
        require(
            msg.sender == owner,
            "Only owner can call this function."
        );
        _;  // 这里是函数体的插入点
    }
}

contract priced {
    // 修饰符可以接受参数:
    modifier costs(uint price) {
        if (msg.value >= price) {
            _;  // 这里是函数体的插入点
        }
    }
}

contract Register is priced, owned {
    mapping(address => bool) registeredAddresses;
    uint price;

    constructor(uint initialPrice) { price = initialPrice; }

    // 这里必须提供 `payable` 关键字,否则该函数会自动拒绝所有发送给它的 Ether。
    function register() public payable costs(price) {
        registeredAddresses[msg.sender] = true;
    }

    // 这个合约继承了 `owned` 合约的 `onlyOwner` 修饰符。因此,只有当调用者是存储的所有者时,调用 `changePrice` 才会生效。
    function changePrice(uint price_) public onlyOwner {
        price = price_;
    }
}

contract Mutex {
    bool locked;
    
    // 锁定的修饰符
    modifier noReentrancy() {
        require(
            !locked,
            "Reentrant call."
        );
        locked = true;
        _;  // 这里是函数体的插入点
        locked = false;
    }

    // 这个函数通过互斥锁保护,这意味着来自 `msg.sender.call` 内部的重入调用无法再次调用 `f`。
    // `return 7` 语句将 7 分配给返回值,但仍然会执行修饰符中的 `locked = false` 语句。
    function f() public noReentrancy returns (uint) {
        (bool success,) = msg.sender.call("");
        require(success);
        return 7;
    }
}

如果你想访问合约 C 中定义的修饰符 m,可以使用 C.m 来引用它,而无需进行虚拟查找。修饰符只能在当前合约或其基合约中定义和使用。它们也可以在库中定义,但只能在同一库中的函数中使用。

修饰符可以改变函数的行为,并且可以接受参数。它们可以用于在函数执行前检查条件、保护某些操作或提供额外的功能:
1.修饰符可以在函数体之前、之后或期间插入代码,控制函数执行的流程。
2.多个修饰符可以同时应用于一个函数,并且它们会按照在函数签名中出现的顺序执行。
3.在修饰符中,_ 是一个占位符,表示被修饰的函数体将被插入到这个位置。不同于变量名中的下划线,修饰符中的 _ 表示一个特殊的插入点。
4、修饰符本身可以显式返回控制权,例如 return;,但这不会影响被修饰函数的返回值。修饰符的返回只会影响它本身的执行,控制流将继续执行前一个修饰符中的 _ 之后的语句。
5、修饰符可以接受参数,并且这些参数可以是任意表达式。修饰符中的引入符号在函数体内不可见,因为它们可能会被重写。

临时存储

临时存储是通过 EIP-1153 引入的一种新的数据存储位置,除了内存、存储、calldata(以及返回数据和代码)之外。它通过操作码 TSTORE 和 TLOAD 实现,表现为键值存储。与存储不同,临时存储中的数据是短期有效的,仅在当前交易范围内存在,交易结束后,临时存储中的数据会被重置为零。

临时存储变量
1.临时存储变量不能在声明时初始化,即不能在声明时赋值。因为这些值会在交易结束时被清除,导致初始化失效。临时存储变量会根据其底层类型自动初始化为默认值。
2.常量(constant)和不可变变量(immutable)与临时存储变量冲突,因为这些值要么被内联,要么直接存储在代码中。
3.临时存储变量具有独立于存储的地址空间,因此它们的顺序不会影响存储状态变量的布局,反之亦然。然而,临时存储变量需要与其他状态变量使用不同的名称,因为所有状态变量共享同一个命名空间。
4.临时存储中的值与存储中的值一样,按相同的方式打包存储。有关存储布局的详细信息,可以参考存储布局章节。
5.临时存储变量可以具有可见性。对于公共变量,编译器将自动为其生成 getter 函数。
6.目前,临时存储仅支持值类型的状态变量声明,不能用于引用类型(如数组、映射和结构体),也不支持局部变量或参数变量。

临时存储的一个常见应用场景是实现更便宜的重入锁。通过操作码 TSTORE 和 TLOAD,可以更低成本地实现重入锁。下面的示例展示了如何使用临时存储实现重入锁:

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

contract Generosity {
    mapping(address => bool) sentGifts;
    bool transient locked;

    modifier nonReentrant {
        require(!locked, "Reentrancy attempt");
        locked = true;
        _;
        // 解锁保护,使模式可以组合。
        // 函数退出后,它可以再次被调用,甚至在同一交易中。
        locked = false;
    }

    function claimGift() nonReentrant public {
        require(address(this).balance >= 1 ether);
        require(!sentGifts[msg.sender]);
        (bool success, ) = msg.sender.call{value: 1 ether}("");
        require(success);

        // 在重入函数中,最后执行这一步会打开漏洞
        sentGifts[msg.sender] = true;
    }
}

临时存储对拥有它的合约是私有的,类似于持久存储。只有拥有合约的框架(或合约)才能访问其临时存储,并且在访问时,所有框架都访问相同的临时存储。

临时存储是 EVM 状态的一部分,因此,它受到与持久存储相同的可变性约束。这意味着,任何对临时存储的读取操作都不是纯函数,写入操作也不是视图函数。

临时存储在特殊调用上下文中的行为:
1.如果在 STATICCALL 上下文中执行 TSTORE 操作码,将会触发异常,而不是执行写入操作。TLOAD 操作码在 STATICCALL 上下文中是被允许的。
2.在 DELEGATECALL 和 CALLCODE 这两种上下文中使用临时存储时,临时存储的拥有合约是发出 DELEGATECALL 或 CALLCODE 指令的合约(即调用方)。这种行为类似于持久存储的工作方式。
3.当临时存储在 CALL 或 STATICCALL 上下文中使用时,临时存储的拥有合约是目标合约(即被调用方),而不是调用方。

在 DELEGATECALL 中,当前不支持引用临时存储变量。因此,无法将这些变量传递给库调用。若要在库中访问临时存储,只能通过内联汇编实现。

如果框架回退(例如,在执行过程中出现错误),那么所有在框架进入和返回之间进行的临时存储写入都会被回退,包括在内层调用中的写入。外部调用的调用方可以通过 try…catch 块来防止内层调用的回退向上传播。

智能合约的可组合性和临时存储的注意事项

根据 EIP-1153 的规范,为了确保智能合约的可组合性,在使用临时存储的高级用例时,建议格外小心。

对于智能合约而言,可组合性是一个关键设计原则,它旨在实现自包含的行为,使得对单个智能合约的多个调用能够组合成更复杂的应用程序。到目前为止,EVM 在很大程度上保证了这种可组合行为,因为在复杂交易中对智能合约的多个调用,与在多个独立交易中进行的多个调用几乎没有区别。然而,临时存储的使用可能会违反这一原则。错误的使用方式可能导致复杂的错误,而这些错误只有在跨多个调用进行时才会显现。

我们通过一个简单的示例来说明这个问题:

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

contract MulService {
    uint transient multiplier;

    function setMultiplier(uint mul) external {
        multiplier = mul;
    }

    function multiply(uint value) external view returns (uint) {
        return value * multiplier;
    }
}

以及一系列外部调用:

setMultiplier(42);
multiply(1);
multiply(2);

如果这个示例使用内存或存储来保存乘数,那么它将完全符合可组合性原则。无论是将调用拆分为多个交易,还是将它们组合成一个调用,都不会影响结果:一旦乘数设置为 42,后续的调用将分别返回 42 和 84。这使得像批量处理多个交易中的调用以减少 Gas 费用等用例成为可能。然而,临时存储可能破坏这种用例,因为可组合性不再是理所当然的。在这个示例中,如果这些调用没有在同一交易中执行,乘数将会被重置,后续的 multiply 函数调用将始终返回 0。

另一个问题是,由于临时存储被设计为相对便宜的键值存储,智能合约开发者可能倾向于将临时存储用作内存映射的替代方案,而不跟踪映射中修改的键,这样就不会在调用结束时清除映射。这在复杂交易中可能会导致意外行为,因为在之前的合约调用中设置的值仍然存在。

使用临时存储进行重入锁定是安全的,因为它会在合约调用帧结束时被清除。然而,开发者应避免为了节省 100 Gas(用于重置重入锁)而做出妥协,因为不这样做会将合约限制为仅能在一个交易内执行一次调用,无法在复杂的组合交易中使用,而这些交易正是链上复杂应用的基础。

因此,建议在智能合约调用结束时始终完全清除临时存储,以避免上述问题,并简化在复杂交易中分析合约行为的过程。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

秋说

感谢打赏

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

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

打赏作者

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

抵扣说明:

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

余额充值