您正在查看: 2023年1月

从EVM 角度看合约创建与部署

技术背景

本文我们探讨智能合约是如何创建和部署的。

当谈论以太坊上的合约构建时,我们必须区分在EVM层面上发生的事情和作为Solidity开发者,在编写我们想要部署的合约时看到的事情之间的区别。

在这份文件中,我们将探讨智能合约是如何在链上部署的,以及与EVM执行合约创建代码有关的微妙之处。

前备知识点

  1. 构造函数用于初始化状态变量,与普通函数不同,它在合约部署后是不可访问的。
  2. 以太坊的合约部署是独特的,因为这个动作本身就是运行EVM字节码的副产品。
  3. 部署者附加构造函数参数,这使得初始化的合约状态可以改变而不必重新编译。
  4. 尽管常量(constants)的使用成本较低,但不可变的变量(immutable)有更大的灵活性,因为它们可以在构造函数中初始化,也可以运行时复制值。
  5. 当运行一个合约的构造函数时,新创建的合约存在,但只是部分存在,这可能会引起不可预测的操作码执行结果。

Solidity

当Solidity开发者编写合约时,可以定义一个特殊的函数,称为constructor(),其作用很像其他面向对象编程中的构造函数。就好像Solidity团队想让开发者有宾至如归的感觉,让他们觉得他们只是在定义一个类(合约)和它的构造函数。

constructor()函数通常用于初始化状态变量:

pragma solidity ^0.8.13;

contract MyCoin {
  uint public constant totalSupply = 1000000000000000000000000000;
  mapping(address => uint256) balances;

  constructor() {
    balances[msg.sender] = totalSupply;
  }
}

上面的例子是一个简单的构造函数,它将部署器的余额设置为totalSupply值,在我们的例子中这是一个常数。

尽管构造函数在Solidity中是明确定义的,但它与普通函数的不同之处在于,它在合约部署后是不可访问的。我们将使用 solc的- abi输出标志来观察合约的可访问函数列表:

[{
  "inputs": [],
  "stateMutability": "nonpayable",
  "type": "constructor"
}, {
  "inputs": [],
  "name": "totalSupply",
  "outputs": [{
    "internalType": "uint256",
    "name": "",
    "type": "uint256"
  }],
  "stateMutability": "view",
  "type": "function"
}]

编译器为totalSupply状态变量创建了一个公共函数,因为我们把这个变量声明为public。

我们的 构造函数 在合约的ABI中具有一个特殊的 类型,以表明它不是一个普通的函数。它没有name 字段,与 真正的 函数不同,暗示它是不可访问的。

部署

让我们看看当我们部署合约时将会运行的实际代码:

solc --bin ~/Desktop/MyCoin.sol

======= Desktop/MyCoin.sol:MyCoin =======

Binary:
608060405234801561001057600080fd5b506b033b2e3c9fd0803ce80000006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220edc6183cb296d3c2809859d4531deef0fb83c0ad90b772697ffaa375befe9c7664736f6c634300080d0033

这是 init code,而不仅仅是将被部署到链上的代码。对于后者,我们使用solc的-- bin-runtime 选项获得:

solc --bin-runtime ~/Desktop/MyCoin.sol

======= Desktop/MyCoin.sol:MyCoin =======

Binary of the runtime part:
6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220edc6183cb296d3c2809859d4531deef0fb83c0ad90b772697ffaa375befe9c7664736f6c634300080d0033

-- bin输出包含-- bin-runtime输出,这不是巧合。-- bin输出包含部署动作和要部署的代码,而-- bin-runtime输出只显示要部署的代码:

让我们仔细看看init code 是做什么的,看一下我们例子合约的反汇编:

label_0000:
    0000 60 PUSH1 0x80
    0002 60 PUSH1 0x40
    0004 52 MSTORE
    0005 34 CALLVALUE
    0006 80 DUP1
    0007 15 ISZERO
    0008 61 PUSH2 0x0010
    000B 57 JUMPI
label_000C:
    000C 60 PUSH1 0x00
    000E 80 DUP1
    000F FD REVERT
label_0010:
    0010 5B JUMPDEST
    0011 50 POP
    0012 6B PUSH12 0x033b2e3c9fd0803ce8000000
    001F 60 PUSH1 0x00
    0021 80 DUP1
    0022 33 CALLER
    0023 73 PUSH20 0xffffffffffffffffffffffffffffffffffffffff
    0038 16 AND
    0039 73 PUSH20 0xffffffffffffffffffffffffffffffffffffffff
    004E 16 AND
    004F 81 DUP2
    0050 52 MSTORE
    0051 60 PUSH1 0x20
    0053 01 ADD
    0054 90 SWAP1
    0055 81 DUP2
    0056 52 MSTORE
    0057 60 PUSH1 0x20
    0059 01 ADD
    005A 60 PUSH1 0x00
    005C 20 SHA3
    005D 81 DUP2
    005E 90 SWAP1
    005F 55 SSTORE
    0060 50 POP
    0061 60 PUSH1 0xbd
    0063 80 DUP1
    0064 61 PUSH2 0x006e
    0067 60 PUSH1 0x00
    0069 39 CODECOPY
    006A 60 PUSH1 0x00
    006C F3 RETURN
    006D FE ASSERT

为了完全理解这个例子的要点,建议读者尝试用调试器自己执行这个例子,比如使用evm.codes

这里发生了很多事情,但我们可以把它分解成2个基本部分:

  1. 运行构造函数(设置代码)
    这一部分来自0000-005F
  2. 返回将被部署在链上的运行时字节码
    这一部分来自于0060-006C

对于构造函数部分,事情是非常简单的。请记住,我们将Solidity构造函数设置为 nonpayable. 事实上,我们不需要做任何事情就可以使构造函数成为 nonpayable,这是一个 "solc "默认值,除非指定 payable。

因此,如果任何 value 与运行我们代码的交易一起被发送,0005-000F 行将简单地回退。
在第0012-005F行,可以看到Solidity构造函数的反汇编,它包含了一行代码:

pragma solidity ^0.8.13;
...
balances[msg.sender] = totalSupply;

由于我们用一个常数来表示 totalSupply 值,编译器将该值植入EVM字节码。这可以在第0012行看到,十六进制值0x033b2e3c9fd0803ce8000000被推送到堆栈。这是1000000000000000000000的十进制,也就是我们给totalSupply赋值。

CALLER操作码,在第0022行检索Solidity的msg.sender。

随后,在005F行,我们可以看到SSTORE,它实际上是在映射中存储常量。

在第0060-006C行,CODECOPY将当前代码从偏移量0x006E复制到偏移量0x00的内存中,该偏移量刚好在init code的最后一行。RETURN被执行。

这实际上是将 runtime code 完整地返回给EVM,以便它将其作为代码存储在账户的状态中。这种行为,类似于一个安装者(init code),想要部署它所持有的一段代码(runtime code)。

由于在以太坊中存储数据的成本很高,最好只存储我们真正打算多次使用的代码,即运行时代码(runtime code)。这就是为什么初始化合约状态的一次性设置代码,即 constructor,不是部署代码的一部分。进行一个疯狂类比,设置代码可以被看作是火箭飞船的助推器,在使用一次后就被处理掉。然而,当一次性助推器永远消失在海洋中时,设置代码作为合约部署交易的一部分永远记录在链上。保留部分对于分析新部署的合约往往是有用的。

我们可以用一幅漂亮的ASCII图来总结本节,它显示了 "初始代码(init code)" 是如何组成的。

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                           setup_code                          |
+                           +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           |                                   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+                                   +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                          runtime_code                         |
+                     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                     |
+-+-+-+-+-+-+-+-+-+-+-+

我们有110字节的 "设置代码(setup code)",用于初始化合约的状态,附加189字节的 "运行时代码(runtime code)",将被部署到链上。

构造函数参数

如果我们想在构造函数的参数中指定总的发行量呢?这在同一合约将以不同的初始化值被多次部署的情况下最为有用:

pragma solidity ^0.8.13;

contract MyCoin2 {
  mapping(address => uint256) balances;

  constructor(uint256 _totalSupply) {
    balances[msg.sender] = _totalSupply;
  }
}

为构造函数提供参数的能力允许 Solidity 开发人员改变合约的初始化状态,而不必重新编译合约。这与我们观察到的在字节码中把初始化参数设置成常量的以往方法不同。

对源码模式的小改变对 init code 有实质性的影响:

solc --bin ~/Desktop/MyCoin2.sol

======= Desktop/MyCoin2.sol:MyCoin2 =======

Binary: 6080604052348015600f57600080fd5b506040516101223803806101228339818101604052810190602f919060ad565b806000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055505060d5565b600080fd5b6000819050919050565b608d81607c565b8114609757600080fd5b50565b60008151905060a7816086565b92915050565b60006020828403121560c05760bf6077565b5b600060cc84828501609a565b91505092915050565b603f806100e36000396000f3fe6080604052600080fdfea2646970667358221220c96818e63eea5c37b6a86bd71c0e718fc8f036db10e8d071ade062040534d7a564736f6c634300080d0033

让我们检查一下代码的大小:

In [3]: hex(len(binascii.unhexlify('6080604052348015600f57600080fd5b506040516101223803806101228339818101604052810190602f919060ad565b806000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffff
ffffffffffffff168152602001908152602001600020819055505060d5565b600080fd5b6000819050919050565b608d81607c565b8114609757600080fd5b50565b60008151905060a7816086565b92915050565b60006020828403121560c05760bf6077565b
5b600060cc84828501609a565b91505092915050565b603f806100e36000396000f3fe6080604052600080fdfea2646970667358221220c96818e63eea5c37b6a86bd71c0e718fc8f036db10e8d071ade062040534d7a564736f6c634300080d0033')))

Out[3]: '0x122'

这个链接 是反汇编的init code 及 除去runtime 后的代码 。
Solidity代码中的一个小变化引起了EVM字节码的巨大变化。当我们处理EVM字节码时,提供构造函数参数是相当复杂的。
我们建议读者用evm.codes 来执行每一步代码。
虽然也可能通过反汇编整个代码来了解发生了什么,但让我们查看反编译版本中的main()函数:

 pragma solidity ^ 0.8 .13;
 ...

 var temp0 = memory[0x40: 0x60];
 var temp1 = code.length - 0x0122;
 memory[temp0: temp0 + temp1] = code[0x0122: 0x0122 + temp1];
 memory[0x40: 0x60] = temp1 + temp0;
 var0 = 0x2f;
 var var2 = temp0;
 var var1 = var2 + temp1;
 var0 = func_00AD(var1, var2);

main()函数抓取CODESIZE操作码的结果,用于计算当前运行代码的大小,并从中减去0x0122。记住,init code是0x0122字节。init code 在意它的大小,并期望在执行前有东西附加到它上面!

反编译中的其他函数用来验证附加在init code上的东西不大于0x20(32)字节,并获取它。
然后我们看到这个值在同一设置代码中使用,因为它被SSTORE存储在合约的状态中。
总之,当EVM执行init code时,构造函数参数会被附加到init code上。负责正确追加参数的是部署者,而不是编译器。再次使用一个图表示:

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                           setup_code                          |
+     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     |                                                         |
+-+-+-+                                                         +
|                          runtime_code                         |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|   |                   constructor_arguments                   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|   |
+-+-+

我们可以看到这次的setup_code非常笨重,因为它包含了辅助函数来检索附加在runtime_code末尾的constructor_arguments,而这次的runtime_code出乎意料的小!

为什么这次的 runtime code 只有63字节?(提示:我们是否在合约中省略了任何可公开访问的函数?)

常量(Constant) 与不可变量(Immutable)

我们已经看 到Solidity编译器是如何将定义为常量的值植入EVM字节码的。这并不是在Solidity中可以声明不可修改的变量的唯一形式, 我们也可以将状态变量声明为 immutable。
与 constant 变量不同,immutable变量可以在构造函数中被初始化:

pragma solidity ^ 0.8 .13;

contract MyCoin {
  uint256 public immutable totalSupply;
  mapping(address => uint256) balances;

  constructor(uint256 _totalSupply) {
    totalSupply = _totalSupply;
    balances[msg.sender] = _totalSupply;
  }
}

通过这样的设置,会在运行时字节码中看到一大块零:

solc --bin ~/Desktop/MyCoin3.sol

======= Desktop/MyCoin3.sol:MyCoin =======

Binary: 60a060405234801561001057600080fd5b506040516101d43803806101d4833981810160405281019061003291906100be565b8060808181525050806000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550506100eb565b600080fd5b6000819050919050565b61009b81610088565b81146100a657600080fd5b50565b6000815190506100b881610092565b92915050565b6000602082840312156100d4576100d3610083565b5b60006100e2848285016100a9565b91505092915050565b60805160d161010360003960006049015260d16000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e91906082565b60405180910390f35b7f000000000000000000000000000000000000000000000000000000000000000081565b6000819050919050565b607c81606b565b82525050565b6000602082019050609560008301846075565b9291505056fea26469706673582212205d7bfe172b611c06fba2648d535d5437f63e0f25b22919f9c17fb605c7ad916f64736f6c634300080d0033

运行时字节码的偏移量73:105(0x49:0x69)包含了一个神秘的32字节的零块。这个块只不过是一个占位,由编译器放置在那里,当immutable在构造过程中被赋值时,它是一个占位符。有趣的是,无论我们为immutable 变量声明什么类型,编译器都会使用32字节的 0 作为它的占位符。

在我们检查init code之前,我们鼓励读者自己使用evm.codes 运行。
让我们重新审视一下 init code的反编译情况:

contract Contract {
  function main() {
    memory[0x40: 0x60] = 0xa0;
    var var0 = msg.value;

    if (var0) {
      revert(memory[0x00: 0x00]);
    }

    var temp0 = memory[0x40: 0x60];
    var temp1 = code.length - 0x01d4;
    memory[temp0: temp0 + temp1] = code[0x01d4: 0x01d4 + temp1];
    memory[0x40: 0x60] = temp1 + temp0;
    var0 = 0x0032;
    var var2 = temp0;
    var var1 = var2 + temp1;
    var0 = func_00BE(var1, var2);
    var temp2 = var0;
    memory[0x80: 0xa0] = temp2;
    memory[0x00: 0x20] = msg.sender;
    memory[0x20: 0x40] = 0x00;
    storage[keccak256(memory[0x00: 0x40])] = temp2;
    var temp3 = memory[0x80: 0xa0];
    memory[0x00: 0xd1] = code[0x0103: 0x01d4];
    memory[0x49: 0x69] = temp3;
    return memory[0x00: 0xd1];
  }

  function func_0088(var arg0) returns(var r0) {
    return arg0;
  }

  function func_0092(var arg0) {
    var var0 = 0x009b;
    var var1 = arg0;
    var0 = func_0088(var1);

    if (arg0 == var0) {
      return;
    } else {
      revert(memory[0x00: 0x00]);
    }
  }

  function func_00A9(var arg0, var arg1) returns(var r0) {
    var var0 = memory[arg1: arg1 + 0x20];
    var var1 = 0x00b8;
    var var2 = var0;
    func_0092(var2);
    return var0;
  }

  function func_00BE(var arg0, var arg1) returns(var r0) {
    var var0 = 0x00;

    if (arg0 - arg1 i >= 0x20) {
      var var1 = 0x00;
      var var2 = 0x00e2;
      var var3 = arg0;
      var var4 = arg1 + var1;
      return func_00A9(var3, var4);
    } else {
      var1 = 0x00d3;
      revert(memory[0x00: 0x00]);
    }
  }
}

在这里看到的与我们在上一节中看到的相似。
让我们关注main()函数,它检索构造函数参数并将其存储在内存偏移量[0x80:0xa0]中:

pragma solidity ^0.8.13;
...

var0 = func_00BE(var1, var2);
var temp2 = var0;
memory[0x80: 0xa0] = temp2;

然后,它把runtime_code全部复制到内存偏移量[0x00:0xd1]中。它还取了构造函数参数的值,并在内存中修补了偏移量[0x49:0x69]。当然,这些是零块在字节码中的准确偏移量。

pragma solidity ^ 0.8 .13;
...

var temp3 = memory[0x80: 0xa0];
memory[0x00: 0xd1] = code[0x0103: 0x01d4];
memory[0x49: 0x69] = temp3;
return memory[0x00: 0xd1];

关于使用 constants和 immutable,有几件事需要注意:因为 immutable 总是使用32字节,而不管其类型的实际大小,有时使用常量会更便宜。但是常量的灵活性较差:因为它们的值是由编译器复制到字节码中的,它们的值在编译时必须是已知和固定的。与immutables不同,它们的值不能依赖于以太坊状态或环境变量,如msg.value。

你可能也注意到在这两个例子中,构造函数参数的值没有用SSTORE放入合约存储。

执行环境

当 init code 被EVM执行时,新创建的合约存在,但只是部分存在:它有一个地址,但还没有代码。
在这个部分存在的过程中,init code可以改变合约的状态,自由地执行存储操作。因为在这个阶段(部分存在期间)没有与合约相关的代码,所以一些操作码可能会产生意想不到的结果。
例如,诸如 EXTCODESIZE 这样的操作码在针对正在初始化的合约地址运行时应该返回0。在使用EXTCODEHASH时也同样应该如此。

部署方法

我们已经讨论了在合约部署过程中如何执行init code,但如何触发执行流?为了让事情在链上发生,它必须由一个外部拥有的账户(EOA)用私钥发起的交易启动。

在本节中,我们将介绍两种在链上执行 init code的方法:

合约创建交易

以太坊有两种类型的交易,一种是消息调用(对合约的调用)的交易,另一种是创建带有相关代码的新账户。
这两种类型可以通过查看它们各自的 to 字段来加以区分:

  • 合约创建交易:to 字段为空。
  • 信息调用:to 字段是一个地址。
    让我们来看看在这篇文章中编写的第一个合约的合约创建交易:
{
  "data": "0x608060405234801561001057600080fd5b506b033b2e3c9fd0803ce80000006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220a7134163728f1a3b08ff82e20f2b89e7223e63617ff221cc42e2f9a1a4c94e9664736f6c63430008110033",
  "gasLimit": 281474976710655,
  "gasPrice": 10,
  "nonce": 2,
  "to": ,
  "value": 0
}

注意该交易的data字段包含合约的 init code,其to字段为空。
在这个交易的value字段中发送的任何数值都将被用作新创建合约的初始余额。
也请注意:由于发送了一个合约创建交易,已经从发起者的余额中扣除了32000 gas 的费用。
在合约主体从 init code返回后,我们知道它必须作为新创建账户的代码存储。但新创建账户的地址是什么?
当发送合约创建交易时,地址是RLP编码结构的keccak-256散列的最低阶(最右边)160位,它由发起者的地址和发起者的nonce组成。
让我们来演示一下地址是如何计算的,示例交易部署合约在: 0xb66a603f4cfe17e3d27b87a8bfcad319856518b8 。
让我们首先定义一个RLP对象来表示需要根据协议进行编码的结构:

import binascii
from rlp
import Serializable, encode
from rlp.sedes
import big_endian_int, Binary

address = Binary.fixed_length(20, allow_empty = True)

class Struct(Serializable):
  fields = [
    ('sender', address),
    ('nonce', big_endian_int)
  ]

现在我们可以创建该结构,并将其字段赋值为示例交易中的值:

  • sender: 0x3482549fca7511267c9ef7089507c0f16ea1dcc1.
  • 发起者的nonce: 135

所得的字节:

sender = binascii.unhexlify('3482549fca7511267c9ef7089507c0f16ea1dcc1')

# create a new struct with the sender 's address and the nonce  
s = Struct(sender, 135)

# this is what the RLP encoded struct looks like
binascii.hexlify(encode(s))
b 'd7943482549fca7511267c9ef7089507c0f16ea1dcc18187'

然后我们需要使用keccak-256对结果进行Hash,并从结果字节中提取最低位的160位:

from Crypto.Hash
import keccak

# this
function does the keccak - 256 hashing
def do_sha3(to_hash: bytes) - > str:
  k = keccak.new(digest_bits = 256)
k.update(to_hash)
return k.hexdigest()

# keccak - 256 hash the RLP encoded struct
do_sha3(encode(s))
'441f3147356e22bb52c90d65b66a603f4cfe17e3d27b87a8bfcad319856518b8'

# extract only the lowest order 160 - bits
_[-40: ]
'b66a603f4cfe17e3d27b87a8bfcad319856518b8'

这就给了我们在Etherscan中相同的地址。太好了

CREATE/2

到目前为止,我们已经看到了EOA账户是如何启动一个交易,从而部署一个新合约。但是通过合约来部署合约的情况又是怎样的呢?

这种操作对很多项目都很有用。例如,当你调用它的 创建交易池 功能时,你最喜欢的DEX的工厂合约可以按需为你部署合约。这都要归功于CREATE操作码的魔力。

为了更好地理解这个操作码,我们可以使用Solidity的new关键字,它允许合约创建其他合约,条件是新合约的代码被编译器知道(当创建合约被编译时)。

让我们来看看这个操作的一个例子:

// SPDX-License-Identifier: BUSL-1.1  
pragma solidity ^ 0.8 .13;

contract MyCoin {
  uint public constant totalSupply = 1000000000000000000000000000;
  mapping(address => uint256) balances;

  constructor() {
    balances[msg.sender] = totalSupply;
  }
}

contract MyCoinDeployer {
  MyCoin c = new MyCoin();
}

看看 MyCoinDeployer 合约。这个合约的(隐式定义的)构造函数只创建了一个MyCoin合约,并将新创建的合约的地址存储在storage(存储)中。

让我们来看看MyCoinDeployer的init code:

======= Desktop/MyCoin.sol:MyCoinDeployer =======

Binary:
60806040526040516100109061007e565b604051809103906000f08015801561002c573d6000803e3d6000fd5b506000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555034801561007857600080fd5b5061008b565b61012b806100d883390190565b603f806100996000396000f3fe6080604052600080fdfea26469706673582212209983b99bc11f2e9d7ae4320e06581f610c06531ff26a7e6872ffe9f9b27cdeb564736f6c634300080d0033608060405234801561001057600080fd5b506b033b2e3c9fd0803ce80000006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220fa3ea064ac1eacf0fcb8a977e85160987245ae4739fac9b2d1f6718fcf987a4564736f6c634300080d0033

乍一看,编译这个单行合约使用相当多的init code。仔细观察可以发现,这段相当长的代码是原始的 MyCoin 合约的 init code,嵌入到 MyCoinDeployer 合约的 init code 中:

my_coin_deployer = binascii.unhexlify('60806040526040516100109061007e565b604051809103906000f080158015
61002c573d6000803e3d6000fd5b506000806101000a81548173ffffffffffffffffffffffffffffffffff
ffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055503480156100785760
0080fd5b5061008b565b61012b806100d883390190565b603f806100996000396000f3fe60806040526000
80fdfea26469706673582212209983b99bc11f2e9d7ae4320e06581f610c06531ff26a7e6872ffe9f9b27c
deb564736f6c634300080d0033608060405234801561001057600080fd5b506b033b2e3c9fd0803ce80000
006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffff
ffffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe608060405234
8015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b603360
47565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b60008190
50919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056
fea2646970667358221220fa3ea064ac1eacf0fcb8a977e85160987245ae4739fac9b2d1f6718fcf987a45
64736f6c634300080d0033')

my_coin = binascii.unhexlify('608060405234801561001057600080fd5b506b033b2e3c9fd0803ce8000000
6000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffff
ffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe60806040523480
15600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047
565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050
919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fe
a2646970667358221220fa3ea064ac1eacf0fcb8a977e85160987245ae4739fac9b2d1f6718fcf987a4564
736f6c634300080d0033')

# verify that my_coin's init code is in the init code of the deployer
my_coin in my_coin_deployer
True

# find the index (offset) of my_coin's init code in the deployer's init code
my_coin_deployer.index(my_coin)
216

# hex representation of the offset
hex(_)
'0xd8'

在 MyCoinDeployer 的 init code 中的偏移量0xd8,我们可以找到 MyCoin 的全部代码。

让我们进一步探索当我们执行 "MyCoinDeployer "的 init code时,会发生什么,可以反编译它查看这里。鼓励读者通过这个evm.codes链接 运行一下。

在反汇编的第0008行,我们看到偏移量0x0010被推到了堆栈。这将在以后被使用, 在000C-000F行,我们看到一个JUMP到0x007e,看起来像:

label_007E:
  007E 5B JUMPDEST
  007F 61 PUSH2 0x012b
  0082 80 DUP1
  0083 61 PUSH2 0x00d8
  0086 83 DUP4
  0087 39 CODECOPY
  0088 01 ADD
  0089 90 SWAP1
  008A 56 JUMP

啊哈,0x00d8! 从偏移量0xd8到偏移量0xd8+0x12b的这段代码将被复制,并通过DUP4存储在偏移量0x80的内存中。

然后堆栈中的0x10被带到前面,我们JUMP到它:

label_0010:
  0010 5B JUMPDEST
  0011 60 PUSH1 0x40
  0013 51 MLOAD
  0014 80 DUP1
  0015 91 SWAP2
  0016 03 SUB
  0017 90 SWAP1
  0018 60 PUSH1 0x00
  001A F0 CREATE
  001B 80 DUP1
  001C 15 ISZERO
  001D 80 DUP1
  001E 15 ISZERO
  001F 61 PUSH2 0x002c
  0022 57 JUMPI

现在我们终于可以看到CREATE操作码了! 如果你在执行CREATE操作码之前在你的 evm.codes环境中设置一个断点,堆栈应该看起来像这样:

我们将使用evm.codes的操作码表来进一步解释CREATE 操作码

在这里,我们案例中的堆栈输入是:

  • value: 0x0
    新创建合约的初始余额。这个值将从当前执行账户的余额中扣除。
  • offset: 0x80
    内存中的偏移量,我们从这里开始复制新合约的初始化代码。
  • size: 0x12b
    我们从内存中复制初始化代码片断的大小。

如果部署成功,这个操作的预期输出将是新部署的合约的地址,如果部署失败,则是0。

使用 evm.codes调试器,我们可以看到CREATE操作码部署成功了,输出了新部署的合约的地址:0x43a61f3f4c73ea0d444c5c1c1a8544067a86219b。

就像合约创建交易一样,这个地址是用发起者的地址和nonce计算出来的。然而,我们不能可靠地预测发起者的nonce。这种不可预测性限制了我们在合约部署前试图与之交互的可能。

例如,我们想给一个账户提供资金,并允许另一个地址在账户部署后索取这些资金。当需要提前确定一个部署合约的地址时,这种限制可能会很麻烦。

CREATE2解决了这种情况下的问题。在 EIP-1014中引入,这个操作码允许创建可预先确定地址的合约。我们现在来分析一下CREATE2是如何工作的。首先,让我们看一下下面的Solidity代码,它隐含地使用了CREATE2:

// SPDX-License-Identifier: BUSL-1.1  
pragma solidity ^ 0.8 .13;

contract MyCoin {
  uint public constant totalSupply = 1000000000000000000000000000;
  mapping(address => uint256) balances;

  constructor() {
    balances[msg.sender] = totalSupply;
  }
}

contract MyCoinDeployer {
  MyCoin c = new MyCoin {
    salt: bytes32(0)
  }();
}

我们看到与先前的 CREATE 示例相同的MyCoinDeployer。这里唯一的变化是我们使用了一个新的参数,叫做salt,传递给CREATE2操作码。

同样,CREATE2的使用在纯Solidity中是不明显的,尽管它可以用Yul来显式使用,但它是由编译器隐式使用。

所以让我们观察一下新编译的代码:

======= Desktop/MyCoin.sol:MyCoinDeployer =======

Binary:
60806040526000801b60405161001490610086565b8190604051809103906000f5905080158015610034573d6000803e3d6000fd5b506000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555034801561008057600080fd5b50610093565b61012b806100e083390190565b603f806100a16000396000f3fe6080604052600080fdfea2646970667358221220fbe71703e26e2f04db54fc3812dd16a3cca9b7e4f7f07e461c0c556d1641c90d64736f6c634300080d0033608060405234801561001057600080fd5b506b033b2e3c9fd0803ce80000006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220e98c52b4cebc217f7cd697fac8a76189401a6a7bc860e0c9405eefa8e819634864736f6c634300080d0033

反编译代码这这里 ,我们可以用evm.codes调试这段代码。

接下来,我们看到反汇编中的主要变化在第0x14行:

label_0014:
    0014 5B JUMPDEST
    0015 81 DUP2
    0016 90 SWAP1
    0017 60 PUSH1 0x40
    0019 51 MLOAD
    001A 80 DUP1
    001B 91 SWAP2
    001C 03 SUB
    001D 90 SWAP1
    001E 60 PUSH1 0x00
    0020 F5 CREATE2
    0021 90 SWAP1
    0022 50 POP
    0023 80 DUP1
    0024 15 ISZERO
    0025 80 DUP1
    0026 15 ISZERO
    0027 61 PUSH2 0x0034
    002A 57 *JUMPI

这里,CREATE操作码被替换成了CREATE2。当我们在它被执行之前设置断点,堆栈看起来是这样的:

现在栈上有更多的项目了。让我们回顾一下CREATE2的操作码表参考,并将这些项目映射出来:

  • value: 0x0
  • offset: 0x80
  • size: 0x12b
  • salt: 0x0
    这是从堆栈中使用的新值。

这个salt值是什么,它是如何使地址具有确定性的?正如EIP-1014中所描述的,计算地址的新方法与CREATE/合约创建交易的方法有很大不同。我们传递给keccak-256哈希函数的值现在必须由以下部分组成。0xff与发起者的地址相连接,一个32字节长的salt和新部署合约的init code的keccak-256哈希值。不再有nonce!

在evm.codes playground 环境中给定以下参数:

  • sender: 0x9bbfed6889322e016e0a02ee459d306fc19545d8。
    注意:这里的发起者是部署者合约的地址(不要与部署者合约的 playground 账户混淆!)。
  • salt: 0x0
  • init code: 我们已经知道它,它是 MyCoin 合约的初始代码。

我们可以写一些代码来计算CREATE2的确定性地址,并验证它与我们在CREATE2返回0xfce80a394f575e00cb59c2f84155292f855d75b6后在堆栈中看到的地址相匹配。

my_coin = binascii.unhexlify('608060405234801561001057600080fd5b506b033b2e3c9fd0
803ce80000006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffff
ffffffffffffffffffffffffff1681526020019081526020016000208190555060bd8061006e6000
396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160d
dd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b
2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b60006020
82019050608160008301846061565b9291505056fea2646970667358221220e98c52b4cebc217f7c
d697fac8a76189401a6a7bc860e0c9405eefa8e819634864736f6c634300080d0033')

# '318dd75a494f082e9adf7f0cf71cdbeb96bc5e2601b0a076e6f26e61643cf6ae'
keccak_my_coin = do_sha3(my_coin)

# the sender is the deployer contract's address
sender = binascii.unhexlify('9bbfed6889322e016e0a02ee459d306fc19545d8')

# keccak-256(b'ff' + sender + salt + keccak-256(init_code))
do_sha3(b'\xff' + sender + int(0).to_bytes(length=32, byteorder='big', signed=False) + binascii.unhexlify(keccak_my_coin))

'237cc2ba643fff86788e2c0bfce80a394f575e00cb59c2f84155292f855d75b6'

# extract only the lowest order 160-bits
_[-40:]
'fce80a394f575e00cb59c2f84155292f855d75b6'

很好,我们得到了相同的地址!
CREATE/2对发起者征收的费用是32000 gas,与发送合约创建交易的费用相同。
本文结束, 结合 通过逆向和调试深入EVM 系列文章 一起学习,可以有更多的收获。

转载自:https://learnblockchain.cn/article/5148

Solidity优化-减少合约gas消耗

背景

在以太坊系公链中,合约部署和调用是需要发送交易并消耗 gas 的,而 gas 的使用量决定了该笔交易的费用。因此,设计省钱的合约是很重要的。
在部署合约时,我们希望减小合约编译后的字节码大小,来减少合约部署时的 gas 消耗。而好的代码实现,能够减少合约调用时的 gas 消耗。

减少 gas 消耗的方法

以下介绍一些减少合约 gas 消耗的具体方法。

1、编译合约时使用优化器

使用编译器 solc 编译合约时启动优化器 optimizer,它将简化复杂的表达方式,能减小编译后的合约字节码大小,从而减少部署时的 gas 消耗,同时也能减少合约调用时的消耗。
基于 opcode 的优化器会执行一系列的简化规则,把重复的代码合并,把多余的代码删除。目前(v0.8.11 版本),--optimize 参数激活的是基于 opcode 的优化器。

solc --optimize --optimize-runs 200

运行次数 --optimize-runs 指定了部署的代码的每个操作码在合同的生命周期内被执行的大致频率。这意味着它是代码大小(部署成本)和代码执行成本(部署后的成本)之间的一个折衷参数。次数越小,编译出的字节码越小,但是调用该合约函数可能需要更多 gas。
此外还有基于 Yul 的优化器,更加强大,因为它可以跨函数调用工作。
详情见文档

2、SSTORE 指令

在链上存储变量的值,要用到 SSTORE 指令。在不同情况下,该指令消耗的 gas 不同。
https://eips.ethereum.org/EIPS/eip-1087
我们使用如下合约代码部署在 QEasyWeb3测试链上进行测试:

pragma solidity ^0.8.0;

contract Test {
    uint256 x;

    function emptySet(uint256 _x) public {}

    function set(uint256 _x) public {
        x = _x;
    }
}

实际测试结果如下:

  • 将 x 变量从零值设置为非零值 1,消耗 41406 gas,其中交易基础 gas 21000,推算存储实际消耗 20000 gas。
  • 将 x 变量从非零值设置为零值,消耗 13197 gas,其中交易基础 gas 21000,推算存储实际返还约 8000 gas。
  • 将 x 变量从非零值 1 设置为另一个非零值 10000,消耗 26418 gas,推算存储实际消耗 5000 gas。
  • 将 x 变量从非零值设置为同一个非零值,消耗 22218 gas,推算存储实际消耗约 800 gas。
  • 将 x 变量从零值设置为零值,消耗 22194 gas,推算存储实际消耗约 800 gas。

这里扩展一点,合约自毁和将变成从非零值设置为零值一样,是会返回 gas 的。

3、变量存储原则

从 SSTORE 指令的 gas 消耗测试结果可以看到,存储操作是非常消耗 gas 的,特别是将变量从零值设置为非零值时。

因此,我们应该考虑:

  • 避免在链上存储用不上、不重要的数据,如介绍、描述信息等。
  • 考虑使用事件来存储,要比将它们存储在变量中便宜得多。
  • 在 IPFS 上存储较大的数据,如图片、文档等,在合约中只储存其哈希值。
  • 无状态合约,即用交易数据和事件等来保存数据,而不是改变合约的存储状态。比如发送一个交易并传递你想要存储的值,而不是真正存储它。更多可见这篇文章

同样消耗 gas 比较多的操作指令还有 CREATE 、 CREATE2 等,我们使用时应该注意。

4、选择变量数据类型

不同数据类型的存储消耗不同。在满足业务的情况下,我们应该选择 gas 消耗更小的数据类型。
在没有办法将多个变量放入同一个插槽时(在【5、紧凑状态变量打包】中说明),尽量使用 256 位的变量,例如 uint256 和 bytes32。

在使用小于 32 字节的变量数据类型时,合约的 gas 使用量可能会高于使用 32 字节的类型。这是因为 EVM 每次操作 32 个字节, 所以如果变量大小比 32 字节小,EVM 必须执行额外的操作以便将 32 字节大小缩减到到所需的大小。

我们将如下两个合约部署在 QEasyWeb3测试链上:

// 消耗 gas 68820
contract A {
    uint8 x = 0;
}

// 消耗 gas 67900
contract A {
    uint256 x = 0;
}

发现存储一个 uint256 变量比 uint8 变量消耗的 gas 更少。
此外,在 EVM 执行计算也需要额外的操作,除 uint256 之外的其他 uint 类型在计算时需要耗费额外的 gas 进行转换。

尽量使用定长数组,通常它们更省 gas。比如使用定长字节数组 bytes1, bytes2 ... bytes32,而不是变长字节数组 bytes。如果要使用变长字节数组,则尽量使用 bytes 而不是 []byte,后者会更加浪费存储空间。详情可见文档

5、紧凑状态变量打包

首先,Solidity 合约数据存储的方案是为合约每个变量指定一个可计算的存储位置,数据存在容量为 2 ** 256 超级数组中,数组中每项数据的初始值为 0。

每个插槽可存储 256 位/32 字节数据:

合约状态变量存储结构相关描述可见文档

根据文档描述,静态大小的变量(除映射 mapping 和动态数组之外的所有类型)都从位置 0 开始连续放置在存储插槽(storage slot)中的。如果可能的话,存储大小少于 32 字节的多个变量会被打包到一个存储插槽中(每个存储插槽 256 位/32 字节),规则如下:

  • 存储插槽中的第一项会以低位对齐(即右对齐)的方式储存。
  • 值类型仅使用存储它们所需的字节数。
  • 如果存储插槽中的剩余空间不足以储存一个值类型变量,那么它会被移入下一个存储插槽。
  • 结构体和数组数据会使用一个新插槽进行存储,但结构体或数组中的各项,都会以这些规则进行打包。
  • 结构体和数组数据之后的变量会使用一个新插槽。

紧凑状态变量打包,就是将多个不需要用到 32 字节的值类型数据存储在一个插槽中。通过合理地排列状态变量的顺序、结构体的字段的顺序,使得尽可能多的状态变量打包到一个存储插槽中,最终使用更少的存储插槽,减少 gas 消耗。

注意,要在编译的时候使用优化器进行优化。

以定义一个结构体为例,我们将如下两个合约部署在QEasyWeb3测试链上:

contract Test {
    // 字段 a, b, c 分别使用了一个存储插槽,共使用 3 个
    struct A {
        uint a;
        uint b;
        uint c;
    }

    A a = A(10, 20, 30);
}

contract Test {
    // 字段 a, b 共需要 8 字节,可以共用一个存储插槽
    // 字段 c 需要 32 字节,前一个插槽不够放,因此开启使用一个新的存储插槽
    // 共使用 2 个存储插槽
    struct A {
        uint32 a;           // uint32 类型大小为 32 位/4 字节
        uint32 b;
        uint c;
    }

    A a = A(10, 20, 30);
}

// 第一个合约部署消耗 gas 127633,第二个合约部署消耗 gas 108833,减少 18800,近 20000 gas。

因为使用了紧凑变量打包,所以第二个合约少使用了一个存储插槽,减少了 gas 消耗。


因此我们可以考虑使用更小的 uint 子类型或者 bytes 子类型,通过合理地排序它们的位置,可以将存储空间最小化。
除结构体和数组数据外的其他变量,同样部署以下两个合约进行测试:

// 部署消耗 gas 129366
contract Test {
    uint128 x = 10;
    uint256 y = 10;
    uint128 z = 10;
}

// 部署消耗 gas 108674,少 20692 gas
contract Test {
    uint256 y = 10;
    uint128 x = 10;
    uint128 z = 10;
}

6、紧凑状态变量赋值

当我们使用紧凑状态变量打包时,多个变量被打包在一个存储插槽中,这时,同时读取和写入该插槽中的多个变量,多个读或写会合并为一个单一的操作,这样能够节省 gas。而如果你只是读或者写该插槽中的一个变量,效果可能相反,当一个变量的值被写入一个多变量存储插槽中时,存储槽必须先被读取,然后与新值结合,这样同一个插槽中的其他数据就不会被破坏。

在实际测试中,我们也发现,某些情况下统一插槽内变量的读或写没有优化合并为一个操作。
以下四种设置方式,我们设置 a 的值为 2,b 的值为 1,看实际的 gas 消耗。

contract structWrite {
  struct Object {
    uint64 v1;
    uint64 v2;
    uint64 v3;
    uint64 v4;
  }

  Object obj = Object(1, 1, 1, 1);

    // gas cost 33211
  function set1(uint64 a, uint64 b) public {
    obj.v1 = a + b;
    obj.v2 = a - b;
    obj.v3 = a * b;
    obj.v4 = a / (b + 1);
  }

    // gas cost 28411
  function set2(uint64 a, uint64 b) public {
    setObject(a + b, a - b, a * b, a / (b + 1));
  }

  function setObject(uint64 v1, uint64 v2, uint64 v3, uint64 v4) private {
    obj.v1 = v1;
    obj.v2 = v2;
    obj.v3 = v3;
    obj.v4 = v4;
  }

  // gas cost 28381
  function set3(uint64 a, uint64 b) public {
    uint64 v1 = a + b;
    uint64 v2 = a - b;
    uint64 v3 = a * b;
    uint64 v4 = a / (b + 1);
    obj.v1 = v1;
    obj.v2 = v2;
    obj.v3 = v3;
    obj.v4 = v4;
  }

    // gas cost 28613
  function set4(uint64 a, uint64 b) public {
    obj = Object(a + b, a - b, a * b, a / (b + 1));
  }

  // gas cost 22383
  function set5(uint64 a, uint64 b) public {
    uint64 v1 = a + b;
    uint64 v2 = a - b;
    uint64 v3 = a * b;
    uint64 v4 = a / (b + 1);
  }
}

实际我们看几个 set 方法编译出来的 opcode,发现 set1 使用了 4 个 SLOAD 和 4 个 SSTORE,而其他 set 方法只使用了 1 个 SSLOAD 和 1 个 SSTORE,编译器对其他写法进行了优化。

编译器将 4 个字段的读取和写入优化为一次操作,而第一种写法无法优化,因此要多消耗 5000 左右的 gas。因此,我们应该避免第一种写法。

7、内联汇编打包变量

编写内联汇编 (Inline Assembly) ,手动将多个变量堆叠在一起,打包到单个插槽中。

语法:使用 assembly{ ... } 来嵌入汇编代码段。

// 编码时将多个变量一起储存。
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)
    }
}

这种方式虽然节省了 gas,但是牺牲了代码的可读性,容易出错。

更多资料:Solidity Tutorial : all about Assembly

8、无需使用默认值初始化变量

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

// 部署消耗 gas 67054
contract Test {
    uint256 x;
}

// 部署消耗 gas 67912
contract Test {
    uint256 x = 0;
}

9、常量

在 solidity 中,声明为 constant 或者 immutable 的状态变量即常量。
constant 修饰的常量的值在编译时确定,而 immutable 修饰的常量的值在部署时确定。详情可见文档
尽量使用常量,常量是合约字节码的一部分,不占用存储插槽,使用常量比变量更省 gas。
在部署时,常量消耗的 gas 更少。

// 消耗 83681 gas,相比使用变量节省 20078 gas
contract A {
    uint256 public constant x = 1000;
}

// 消耗 90046 gas,相比使用变量节省 13713 gas
contract A {
    uint256 public immutable x = 1000;
}

// 消耗 103759 gas
contract A {
    uint256 public x = 1000;
}

在读取时,常量消耗的 gas 也更少。

contract A {
    uint256 public result;
    uint256 public constant x = 100;            // 调用 cal 方法消耗 41236 gas
    // uint256 public immutable x = 100;    // 调用 cal 方法消耗 41236 gas
    // uint256 public x = 100;                      // 调用 cal 方法消耗 42036 gas,读取存储变量消耗多消耗 800 gas,

    function cal() public {
        result = x;
    }

    // function cal() public {                      // 调用 cal 方法消耗 41236 gas
    //     result = 100;
    // }
}

10、函数修饰符

使用函数修饰符 view、pure。

  • 函数声明为 view,表示该函数不修改状态。
  • 函数声明为 pure,表示该函数不读取或修改状态。

详情可见文档

在以太坊中,如果不对状态进行修改,则可以发起一笔调用进行查询或其他操作,调用是不需要费用的。如果要对状态产生变更,则需发起一笔交易,交易是需要消耗 gas 和支付费用的。

在智能合约中,函数如果声明为 view 或者 pure ,则外部账户直接调用这些函数只需发起一次调用即可。如果不加这些修饰符,以太坊网络会把你的操作理解为一笔交易。

需要注意的是,如果在一笔交易中,某个未声明为 view 或者 pure 的合约函数的内部调用了声明为 view 或者 pure 的函数,还是需要消耗 gas 的。

部署下面的合约进行测试,其中 add 和 sub 方法都没有读取或修改状态,add 方法没有声明为 pure,则需要发起一笔交易并支付交易费用才能调用,而 sub 方法声明为 pure,则无需发起交易只发起调用即可,无需费用。

contract Test {

    function add(uint _x, uint _y) public returns (uint) {
        return _x + _y;
    }

    function sub(uint _x, uint _y) public pure returns (uint) {
        return _x - _y;
    }
}

11、避免重复修改状态变量

避免重复修改状态变量,比如在循环中重复修改状态变量的值。

contract Test {
    uint256 public count;

        // bad,消耗 gas 58582
    function set1() public {
        for (uint256 i = 0; i < 10; i++) {
            count++;
        }
    }

        // good,消耗 gas 24046
    function set2() public {
        uint256 temp;
        for (uint256 i = 0; i < 10; i++) {
            temp++;
        }
        count = temp;
    }
}

12、使用短路规则

操作符 || 和 && 适用常见的短路规则。
这意味着,假设f(x) 和 g(y) 返回 true 的概率一样,那么:

  • 在表达式 f(x) || g(y) 中,如果 f(x) 的计算结果为真,则不会执行 g(y)。因此应该将贵的方法放在后面。
  • 在表达式 f(x) && g(y) 中,如果 f(x) 的计算结果为假,则不会执行 g(y)。因此应该将贵的方法放在后面。

当然,实际情况是还需要考虑两个方法执行的失败概率,从而整体评估方法的排序。

13、布尔类型

在 solidity 中,布尔类型 bool 实际为 uint8,即使用 8 位的存储空间,每个存储插槽能装入 32 个布尔类型值。而布尔值只能有两个值:True 或 False,其实只需要在单个存储位中就可以保存布尔值。

在有非常多个布尔类型变量,或者是需要布尔类型的数组时,你可以考虑使用一个 uint256 变量,并使用其所有 256 位来表示各个布尔值。

要从 uint256 中获取单个布尔值,请使用以下函数:

function getBoolean(uint256 _packedBools, uint256 _boolNumber) public view returns(bool) {
    uint256 flag = (_packedBools >> _boolNumber) & uint256(1);
    return (flag == 1 ? true : false);
}

要设置或清除布尔值:

function setBoolean(
    uint256 _packedBools,
    uint256 _boolNumber,
    bool _value
) public view returns(uint256) {
    if (_value)
        return _packedBools | uint256(1) << _boolNumber;
    else
        return _packedBools & ~(uint256(1) << _boolNumber);
}

可以使用 BitMap 代替 mapping(uint256 => bool), 同样是使用了位操作处理。

14、默克尔树

使用默克尔树。在合约中保存一组数据的 merkleRoot,提供 verify 方法验证某条数据在这组数据中。相比使用一个 mapping 或数组来保存全部数据,减少了 gas 消耗。

以 ERC20 代币空投为例。参考 ENS 空投合约
核心代码:

bytes32 public merkleRoot;

function claimTokens(uint256 amount, address delegate, bytes32[] calldata merkleProof) external {
    bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
    (bool valid, uint256 index) = MerkleProof.verify(merkleProof, merkleRoot, leaf);
    require(valid, "ENS: Valid proof required.");
    require(!isClaimed(index), "ENS: Tokens already claimed.");

    claimed.set(index);
    emit Claim(msg.sender, amount);

    _delegate(msg.sender, delegate);
    _transfer(address(this), msg.sender, amount);
}

function verify(
    bytes32[] memory proof,
    bytes32 root,
    bytes32 leaf
) internal pure returns (bool, uint256) {
    bytes32 computedHash = leaf;
    uint256 index = 0;

    for (uint256 i = 0; i < proof.length; i++) {
        index *= 2;
        bytes32 proofElement = proof[i];

        if (computedHash <= proofElement) {
            // Hash(current computed hash + current element of the proof)
            computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
        } else {
            // Hash(current element of the proof + current computed hash)
            computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
            index += 1;
        }
    }

    // Check if the computed hash (root) is equal to the provided root
    return (computedHash == root, index);
}

同样可参考 Uniswap 空投使用的 merkle-distributor 以及 OneSwap 空投。

15、压缩交易输入数据

在函数参数较多的时候,我们可以压缩输入数据,类似紧凑状态变量打包,当有多个小于 32 字节大小的参数时,将多个参数打包为一个参数。
参考 Compress input in smart contract

16、调用外部合约

调用外部合约函数比调用内部函数消耗更多 gas。除非必要,否则不建议拆分多个合约,可以使用多个继承来管理和组织代码。
首先测试内部函数调用的 gas 消耗:

contract Math {
    function add(uint _x, uint _y) public pure returns (uint) {
        return _x + _y;
    }
}

contract Test is Math {
    uint sum;

        // 消耗 41710 gas
    function calculate(uint _x, uint _y) public {
        sum = add(_x, _y); 
    }
}

再测试外部函数调用的 gas 消耗,先部署被调用外部合约,得到合约地址:

contract Math {
    function add(uint _x, uint _y) public pure returns (uint) {
        return _x + _y;
    }
}

再部署调用合约,并测试:

contract Math {
    function add(uint _x, uint _y) public pure returns (uint) {
        return _x + _y;
    }
}

contract Test {
    uint sum;
    address constant MathContractAddr = 0x9549DfbBd66b3Cc078AD834C74b9EE1808Ef3AEB;

        // 消耗 43693 gas
    function calculate(uint _x, uint _y) public {
        sum = Math(MathContractAddr).add(_x, _y); 
    }
}

17、状态变量重复读取

多次读取状态变量,不会重复使用 SLOAD 指令,而是将值缓存起来。
我们部署下面两个合约进行测试:

contract Test {

    uint one = 1;

        // 消耗 22218 gas
    function test() public returns (uint) {
        return one + one + one;
    }
} 

contract Test {

    uint one = 1;

        // 消耗 22218 gas
    function test() public returns (uint) {
        return one + 1 + 1;
    }
}

因此我们不用另外增加一个内存变量来避免重复读取。

18、操作合约和数据合约分离

在使用工厂合约创建合约的情况下,可以将创建的合约分离为操作合约和数据合约。
操作合约只创建一次,工厂合约每次只创建数据合约,而不是每次都创建一整个合约,从而减少 gas 消耗。

19、将复杂的计算逻辑放在链下

考虑在链下进行复杂的计算逻辑,在链上存储结果。

20、尽量使用批量操作

因为一笔交易的基础 gas 消耗是 21000,批量操作相比多次操作,能减少多次的交易带来的基础 gas 消耗。

转载自:https://www.jianshu.com/p/330525940d79

站长求职或技术合作

经验时间

10年以上开发经验,含五年以上区块链开发经验

大型公链

多个大型公链开发设计与维护经验(火币Heco,Cube,FShares,EOS等)

交易所

FDex去中心化交易所设计与开发维护

矿池

Galaxy Pool去中心化矿池设计与开发

智能合约

多个大型Defi和游戏智能合约设计与开发经验(EOSDoda,FDex,类compound),以及各个公链系统合约

领导组织

为产品组提供去中心化方案的设计与开发以及组内任务分配与跨部门协调

技术推广

维护区块链中文技术社区(bcskill.com)

孵化产品

兼容以太坊高性能公链解决方案(ethdpos.com)
NSetup一键增量升级安装包制作(nsetup.cn)

传统互联网

金山软件5年工作经验(金山词霸,WPS Mail),其余公司和项目若干

联系方式

QQ:652511569
微信:bcskill