技术背景
本文我们探讨智能合约是如何创建和部署的。
当谈论以太坊上的合约构建时,我们必须区分在EVM层面上发生的事情和作为Solidity开发者,在编写我们想要部署的合约时看到的事情之间的区别。
在这份文件中,我们将探讨智能合约是如何在链上部署的,以及与EVM执行合约创建代码有关的微妙之处。
前备知识点
- 构造函数用于初始化状态变量,与普通函数不同,它在合约部署后是不可访问的。
- 以太坊的合约部署是独特的,因为这个动作本身就是运行EVM字节码的副产品。
- 部署者附加构造函数参数,这使得初始化的合约状态可以改变而不必重新编译。
- 尽管常量(constants)的使用成本较低,但不可变的变量(immutable)有更大的灵活性,因为它们可以在构造函数中初始化,也可以运行时复制值。
- 当运行一个合约的构造函数时,新创建的合约存在,但只是部分存在,这可能会引起不可预测的操作码执行结果。
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个基本部分:
- 运行构造函数(设置代码)
这一部分来自0000-005F - 返回将被部署在链上的运行时字节码
这一部分来自于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 系列文章 一起学习,可以有更多的收获。