加密日记【020】全面掌握Solidity开发

事件

在定义事件的时候,前三个参数可以定义为indexed,方便在区块链里面扫描查找,indexed的参数会存储在topics下面

immutable (不可变量)与constant(常量)

Solidity 0.6.5 更新引入了一个新的关键字 immutable

constant 修饰的变量需要在编译期确定值, 链上不会为这个变量分配存储空间, 它会在编译时用具体的值替代, 因此, constant常量是不支持使用运行时状态赋值的(例如:  block.number ,  now ,  msg.sender  等 )。constant 目前仅支持修饰 strings 及 值类型.

immutable 修饰的变量是在部署的时候确定变量的值, 所以写在构造函数中。它在构造函数中赋值一次之后,就不在改变, 这是一个运行时赋值, 就可以解除之前 constant 不支持使用运行时状态赋值的限制.

Solidity 0.6.x更新

  1. 继承

contract Part1 {
  constructor() public {}
}
contract Part2 {
  constructor() public {}
}
contract Child is Part1, Part2 {
  constructor() public {}

}

继承顺序是Child⇒ Part2⇒Part,先继承Part2,再继承Part1

在这次更新之后,如果继承的多个合约中有相同的函数,编译器会报错,必须override()父合约,重写函数内容

pragma solidity ^0.6.10;
contract A {
    uint public x;
    function setValue(uint _x) public virtual {
        x = _x;
    }
}

contract B {
    uint public y;
    function setValue(uint _y) public virtual {
        y = _y;
    }
}

contract C is A, B {
    function setValue(uint _x) public override(A,B) {
        A.setValue(_x);
    }
  • 只有标记为virtual的函数才可以重写它们。 此外,任何重写的函数都必须标记为override 。 如果重写后依旧是可重写的,则仍然需要标记为virtual

  • 关键字super的工作原理与以前相同:在扁平化继承层次结构中,super将函数调用到更上一级的函数。 外部函数(external函数)仍然不允许使用super

  • 接口(interface)的函数都是隐式虚函数的,因此在实现接口时,必须在实现中显式重写其函数

  1. 接口可以继承

    允许接口继承接口。 派生的接口是的所有接口函数的组合。 实现合约必须实现的所有继承接口的函数,如果合约未实现所有函数,则必须将合约标记为abstract

    pragma solidity ^0.6.10;
    interface X {
        function setValue(uint _x) external;
    }
    
    interface Y is X {
        function getValue() external returns (uint);
    }
    
    contract Z is Y {
        uint x;
        function setValue(uint _x) external override { x = _x; }
        function getValue() external override returns (uint) { return x; }
    }
    abstract contract N is Y {
        uint x;
        function setValue(uint _x) external override { x = _x; }
    }
    
  2. 抽象合约

    0.5版本编译器隐式地将未实现其所有函数的合约当作是抽象合约。0.6必须显式的指定abstract

  3. public的返回类型必须一致

  4. 不再有状态变量遮蔽

    在继承的合约和本合约中有相同函数名称和变量名称,会报错

  5. 引入try/catch

    • 这是仅仅提供给外部调用的特性,部署新合约也被视为外部调用。不可以和低级调用一起使用。

    • 该功能能够捕获仅在调用内部产生的异常。调用后的 try 代码块是在成功之后执行。不会捕获try 代码块中的任何异常。

    • 如果函数调用返回一些变量,则可以在以下执行块中使用它们(如以上示例中所述)。

      • 如果执行了 try 成功代码块,则必须声明与函数调用实际返回值相同类型的变量。

      • 如果执行了低级的catch块,则返回值是类型为bytes的变量。任何特定条件的catch子句都有其自己的返回值类型。

    • 请记住,低级catch (bytes memory returnData) 子句能够捕获所有异常,而特定条件的catch子句只捕获对应的错误。处理各种异常时,请考虑同时使用两者。

    • 在为 try 外部调用设置特定的gas使用量时,低级的catch子句会捕获最终的out of gas错误。 但如果交易本身没有足够的 gas执行代码,则out of gas是没法捕获的。

Solidity 0.8.x更新

  1. 会自动检查溢出,如果默认可以溢出,用unchecked

  2. 无效的操作码被还原取代,这样就不会把剩余的gas消耗掉

  • ABIEncoderV2现在是默认自动激活。 从0.6开始,Encoder就不再是实验性的了,只是因为遗留的原因,保留了 pragma experimental 这个名字。 现在你不需要再加这行了。

  • 移除任何Openzeppelin SafeMath,你不再需要它了。

  • 可能需要进行一些类型转换。

    • msg.sender 和 tx.origin 默认不属于payable类型。 将 msg.sender.transfer 改为 payable(msg.sender).transfer

    • 只有在符合给定类型的情况下,才允许类型转换,所以uint256(-1)将不再工作。 使用type(uint256).max代替。

    • 当多次改变符号时,类型转换在某些情况下会受到限制,因为类型转换的顺序可能会对结果产生影响。 你现在会看到一个类似TypeError的错误。 不允许从 int256 到 bytes32 进行显示的类型转换,得先手动转换为uint256。

  • 修饰组合

    • myContract.functionCall{gas: 10000}{value: 1 ether }()改为:

    • myContract.functionCall{gas: 10000, value: 1 ether }()

  • 将 x**y**z 改为(x**y)**z,因为默认的执行顺序改变了。

  • 将 byte 类型改为 byte1 。

https://docs.soliditylang.org/en/latest/080-breaking-changes.html

以太坊合约委托调用(DelegateCall)

当我们说“函数被调用”时,这意味着我们将特定的上下文(如参数)注入到该组命令(函数)中,并且在此上下文中一个接一个地执行命令。

函数、命令组、地址空间可以通过其名称找到。

在以太坊函数中,调用可以用字节码表示,使用 4 + 32 * N个字节表达。这个字节码由两部分组成。

  • 函数选择器:这是函数调用字节码的前4个字节。函数选择器是通过对目标函数的名称加上其参数类型(不包括空格)进行哈希(keccak-256哈希函数)取前 4 个字节得到,例如bytes4(keccak-256(“saveValue(uint)”))。基于此函数选择器,EVM可以决定应在合约中调用哪个函数。

  • 函数参数:将参数的每个值转换为固定长度为32bytes的十六进制字符串。如果有多个参数,则串联在一起。

如果用户将此4 + 32 * N字节字节代码传递给交易的数据字段。 EVM可以找到应执行的函数,然后将参数注入该函数。

停止使用Solidity的transfer()

以往是因为transfer()和send()是固定gas,用来避免call()的重入攻击,但是现在gas是可变的,这种固定gas的操作就不安全了。

使用检查-生效-交互(checks-effects-interactions)来避免call的重入攻击

使用工厂提高智能合约安全性

工厂模式看成是一种深入防守的托管方式

托管背后的理念是,不同的资金必须分开,以确保合约能够始终覆盖所有的欠款。 要想做好托管,最简单的方法之一就是将资金完全分离到不同的智能合约中。

使用工厂模式的一个主要缺点是,它的Gas 较高。

Solidity 优化

Solidity 怎样写出最节省Gas的智能合约

  1. 使用短路模式排序Solidity操作

    它将低gas成本的操作放在前面,高gas成本的操作放在后面,这样如果前面的低成本操作可行,就可以跳过(短路)后面的高成本以太坊虚拟机操作了

  2. 删减不必要的Solidity库

  3. 精确声明Solidity合约函数的可见性

    https://yeetsai.com/blog/2018/01/07/solidity-external-public-best-practices/

    通过显式地标记函数为外部函数(External),可以强制将函数参数的存储位置设置为calldata,这会节约每次函数执行时所需的以太坊gas成本

  4. 使用适合的数据类型

    • 在任何可以使用uint类型的情况下,不要使用string类型

    • 存储一个uint256要比存储一个uint8的gas成本低,因为每次存储都是32字节的,只存一个uint8还要从256进行缩小,但是如果是多个uint8能组成一个uint256,那比存多个256gas便宜

    • 当可以使用bytes类型时,不要在solidity合约种使用byte[]类型

    • 如果bytes的长度有可以预计的上限,那么尽可能改用bytes1~bytes32这些具有固定长度的solidity类型

    • bytes32所需的gas成本要低于string类型

  5. 避免Solidity智能合约中的死代码

  6. 避免使用不必要的条件判断

  7. 避免在循环中执行gas成本高的操作

  8. 避免使用常量结果的循环

  9. 合并循环

  10. 避免循环中的重复计算

  11. 去除循环中的比较运算

控制 gas 成本

  • 不要存储不必要的数据

  • 将多个小变量打包到单个字中

  • 仅将默克尔根存储为状态

  • 潜在的无限迭代

  • 链外计算(提示)

  • 使用提款模式

编写 O(1) 复杂度的可迭代映射

O(1) 复杂度: 表示即便数量增加,gas 成本也会保持一样。

比如从数组改成链表存储

减少智能合约的 gas 消耗的8种方法

  1. 首选数据类型:尽量使用256位的变量,例如 uint256和bytes32

  2. 在合约的字节码中存储值:constant, immutable

  3. 通过SOLC编译器将变量打包到单个插槽中:多个变量凑成一个插槽

  4. 通过汇编将变量打包到单个插槽中

    function encode(uint64 _a, uint64 _b, uint64 _c, uint64 _d) internal pure returns (bytes32 x) {
        assembly {
            let y := 0
            mstore(0x20, _d)
            mstore(0x18, _c)
            mstore(0x10, _b)
            mstore(0x8, _a)
            x := mload(0x20)
        }
    }
    function decode(bytes32 x) internal pure returns (uint64 a, uint64 b, uint64 c, uint64 d) {
        assembly {
            d := x
            mstore(0x18, x)
            a := mload(0)
            mstore(0x10, x)
            b := mload(0)
            mstore(0x8, x)
            c := mload(0)
        }
    }
    
  5. 连接函数参数

    pragma solidity ^0.4.21;
    contract bitCompaction {
      function oldExam(uint64 a, uint64 b, uint64 c, uint64 d) public {
      }
      function newExam(uint256 packed) public {
      }
    }
    Old: func once(uint256 header, uint256 val...) x N
    New: func batch(uint256 header, uint256[] val... x N)
    
  6. Merkle 证明可减少存储负载

    bytes32 public merkleRoot;
    
    //Let a,...,h be the orange base blocks
    function check
    (
        bytes32 hash4,
        bytes32 hash12,
        uint256 a,
        uint32 b,
        bytes32 c,
        string d,
        string e,
        bool f,
        uint256 g,
        uint256 h
    )
        public view returns (bool success)
    {
        bytes32 hash3 = keccak256(abi.encodePacked(a, b, c, d, e, f, g, h));
        bytes32 hash34 = keccak256(abi.encodePacked(hash3, hash4));
        require(keccak256(abi.encodePacked(hash12, hash34)) == merkleRoot, "Wrong Element");
    
        return true;
    }
    
  7. 无状态合约

    正常的合约

    contract DataStore {
        mapping(address => mapping(bytes32 => string)) public store;
        
        event Save(address indexed from, bytes32 indexed key, string value);
        
        function save(bytes32 key, string value) {
            store[msg.sender][key] = value;
            Save(msg.sender, key, value);
        }
    }
    

    无状态合约

    contract DataStore {
        function save(bytes32 key, string value) {}
    }
    

    在 Etherscan 上查看此交易,向下滚动到输入数据,然后单击转换为 Ascii 按钮。我们的数据存在于交易的输入中。

    https://etherscan.io/tx/0xc9fdf51d30e4f9940c57a917a65da5e4ff1e4018283b8e62aa13d2dcea0d9b6b

  8. 在IPFS上存储数据

如何减少字节码大小及节省 gas

  1. 修饰器是内联函数,多次内联可能会超过合约的最大限制24KB,将内联函数的内部代码提取出来改成内部函数

    modifier onlyAuthorized() {
            bool isOwner = msg.sender == IOwnable(address(securityToken)).owner();
            require(isOwner ||
                securityToken.isModule(msg.sender, DATA_KEY) ||
                securityToken.checkPermission(msg.sender, address(this), MANAGEDATA),
                "Unauthorized"
            );
            _;
        }
    //改写成
    modifier onlyAuthorized() {
            _isAuthorized();
            _;
        }
    
    function _isAuthorized() internal view {require(msg.sender == address(securityToken) ||
            msg.sender == IOwnable(address(securityToken)).owner() ||
            securityToken.checkPermission(msg.sender, address(this), MANAGEDATA) ||
            securityToken.isModule(msg.sender, DATA_KEY),
            "Unauthorized"
        );
    }
    
  2. 布尔类型使用8位,而你只需要1位 ,用uint256存储256个布尔值,采用位移的方式

  3. 当你调用库的公共(public)函数时,该函数的字节码不会包含在合约内,因此可以把一些复杂的逻辑放在库中,这样减小合约的大小。不过你得清楚,调用库会花费一些gas和使用一些字节码。对库的调用是通过委托调用(delegate call)的方式进行的,这意味着库可以访问合约拥有的数据,并且具有相同的权限。因此对于简单任务不值得这样做。

  4. 无需使用默认值初始化变量

  5. 使用简短的错误原因字符串,使用error关键字更省gas

  6. 避免重复检查

  7. 单行交换变量

    (hello, world) = (world, hello)

  8. 使用事件存储链上不需要的数据

  9. 适当的使用优化(optimizer)

  10. 更少调用函数可能更好

  11. 调用内部函数更便宜

    在智能合约内部,调用内部(internal)函数比调用公共(public)函数要便宜,因为当你调用公共函数时,所有参数都会再次复制到内存中并传递给该函数。相比之下,当你调用内部函数时,将传递这些参数的引用,并且它们不会再次复制到内存中。这样可以节省一些 gas ,尤其是在参数较大时如此。

  12. 使用代理模式进行大规模部署

一些简单的 Gas 优化基础

  1. 升级 Solidity 版本

  2. 放弃 Counters.sol

  3. 避免将参数复制到内存

    对于某些类型的参数,如字符串或者数组,Solidity 会强制指定存储位置(memorycalldata )。这里用calldata会便宜的多,所以你会希望尽可能多的使用它,而memory只在你需要修改参数时才用(因为calldata会让它们只读)

当使用任何一种计数器(如_tokenId),从1开始而不是从0开始,会让第一次 mint 便宜一些。通常,写入没有值的槽比写入有值的槽更贵。

此外,整数递增++i(返回上一次的值,然后再加1)比i++(加1,然后返回新的值)更便宜。如果你仅仅需要一个计数器而不需要它的返回值,你可能会更想要第一种。

除法,Solidity 插入了一个检查,确保没有被0除。如果你可以确定除数不为0,你可以使用汇编来执行操作,这样可以节省一些额外的gas

标记为payable的函数会比其他函数调用时便宜

"Stack Too Deep(堆栈太深)" 解决方案

// SPDX-License-Identifier: MIT
pragma solidity 0.7.1;

contract StackTooDeepTest1 {
    function addUints(
        uint256 a,uint256 b,uint256 c,uint256 d,uint256 e,uint256 f,uint256 g,uint256 h,uint256 i
    ) external pure returns(uint256) {

        return a+b+c+d+e+f+g+h+i;
    }
}
  1. 使用更少的变量

  2. 利用函数

    使用内部函数解决

    // SPDX-License-Identifier: MIT
    pragma solidity 0.7.1;
    
    contract StackTooDeepTest1 {
       function addUints(
            uint256 a,uint256 b,uint256 c,uint256 d,uint256 e,uint256 f,uint256 g,uint256 h,uint256 i
        ) external pure returns(uint256) {
    
            return _addThreeUints(a,b,c) + _addThreeUints(d,e,f) + _addThreeUints(g,h,i);
        }
    
        function _addThreeUints(uint256 a, uint256 b, uint256 c) private pure returns(uint256) {
            return a+b+c;
        }
    }
    
  3. 代码块作用域范围

    // SPDX-License-Identifier: MIT
    pragma solidity 0.7.1;
    
    contract StackTooDeepTest2 {
        function addUints(
            uint256 a,uint256 b,uint256 c,uint256 d,uint256 e,uint256 f,uint256 g,uint256 h,uint256 i
        ) external pure returns(uint256) {
    
            uint256 result = 0;
    
            {
                result = a+b+c+d+e;
            }
    
            {
                result = result+f+g+h+i;
            }
    
            return result;
        }
    }
    
  4. 利用结构体

    // SPDX-License-Identifier: MIT
    pragma solidity 0.7.1;
    pragma experimental ABIEncoderV2;
    
    contract StackTooDeepTest3 {
        struct UintPair {
            uint256 value1;
            uint256 value2;
        }
    
        function addUints(
            UintPair memory a, UintPair memory b, UintPair memory c, UintPair memory d, uint256 e
        ) external pure returns(uint256) {
    
            return a.value1+a.value2+b.value1+b.value2+c.value1+c.value2+d.value1+d.value2+e;
        }
    }
    
  5. 一些黑技巧

    // SPDX-License-Identifier: MIT
    pragma solidity 0.7.1;
    
    contract StackTooDeepTest4 {
        function addUints(
            uint256 /*a*/,uint256 /*b*/,uint256 c,uint256 d,uint256 e,uint256 f,uint256 g,uint256 h,uint256 i
        ) external pure returns(uint256) {
          return _fromUint(msg.data)+c+d+e+f+g+h+i;
        }
    
        function _fromUint(bytes memory data) internal pure returns(uint256 value) {
            uint256 value1;
            uint256 value2;
    
            assembly {
                value1 := mload(add(data, 36))
                value2 := mload(add(data, 68))
                value  := add(value1, value2)
            }
        }
    }
    

    智能合约Gas 优化的几个技术

    • 交易Gas:用户每次与智能合约交互时支付的 Gas 量。实现 Gas 高效的函数,必须尽可能地减少Gas消耗。

    • 部署Gas :每次部署智能合约时,需要支付的Gas量。部署智能合约通常只发生一次,尽管如此,仍然可以节省 Gas 也是很有趣的。

  • 尽量减少链上数据(使用事件、IPFS、无状态合约、merkle证明)。

  • 最小化链上操作(字符串、返回存储值、循环、本地存储、批处理)

  • 内存位置(calldata、栈、内存、存储)。

    以太坊有4个内存位置,从最便宜的到最贵的:calldata,栈,内存和存储。如果使用得当,你将节省大量的交易 Gas

    • Calldata :只适用于输入参数且参数是外部函数的引用数据类型(数组,字符串 ...)。Calldata参数是只读的,如果你有一些需要传递给函数的引用类型,总是考虑使用calldata,因为它是最便宜的。

    • :只对方法中定义的值类型数据有效。

    • 内存:内存是易丢失的RAM,在EVM终止运行的时候会被移除。你可以用它来存储引用数据类型,它比存储更便宜。当向其他函数传递参数,或在你的函数中声明临时变量时,除非你严格需要使用存储,否则应该总是使用内存。

    • 存储:是最昂贵的存储位置。存储数据在区块链上持久存在,正如这个列表的第一个元素所说,应该总是尽量减少链上数据

  • 变量顺序

  • 首选的数据类型

  • (嵌入式库,独立部署库合约)。

  • 最小代理(Minimal Proxy)

  • 构造函数

  • 合约大小(消息、修改器、函数)。

  • Solidity编译器优化器

https://learnblockchain.cn/article/4515

避免区块Gas限制导致问题

(2023年3月7日)现在每个区块的目标气体限制为1500 万气体,但大小可以根据网络需求而变化,最高可达3000 万气体。 通过调整区块大小和基本费用,网络通常在1500 万气体时达到平衡。 如果区块gas 高于15M 阈值,则提高下一个区块的基本费用。 同样,如果区块gas 低于15M 阈值,则下一个区块的基本费用会降低。

当合约中存在依赖时间、依赖数据大小(如数组长度)的循环时,都可能会有潜在的漏洞。

如何缩减合约以规避合约大小限制

引入这一限制是为了防止拒绝服务(DOS)攻击。

  • 把你的合约分开

  • 使用库

  • 使用代理

  • 删除一些函数

  • 避免额外的变量

  • 缩短错误提示信息

  • 在优化器中考虑一个低的运行值

  • 避免向函数传递结构体

  • 声明函数和变量的正确可见性

  • 删除函数修改器

ERC20-Permit

之前方法

  1. 用户提交token.approve(myContract.address, amount)交易。

  2. 等待交易确认。

  3. 用户提交第二个 myContract.doSomething()交易,该交易内部使用 token.transferFrom

现在

  1. 用户进行授权签名:签名信息signature=(myContract.address,amount)

  2. 用户向 myContract.doSomething(signature)提交签名。

  3. myContract使用 token.permit增加配额,并调用 token.transferFrom 获取代币。