深入了解以太坊虚拟机是一个系列的文章,本文是最后一篇,前部分的译文链接在本文结尾处
在该系列文章的前部分,我们学了EVM汇编基础,也学了ABI编码是如何允许外部程序与合约进行通信的。在本文中,我们将会学习一个合约是如何从零创建的。
本系列的相关文章(按照顺序):
我们目前所见的EVM字节码都是比较清晰明朗的,就是EVM从上往下的执行指令,没有什么隐藏的魔法。合约创建的过程更有意思一些,它将数据和代码之间的界限模糊化。
在学习合约是如何创建的时候,我们将会看到有时候数据就是代码,有时候代码就是数据。
带上你最喜欢的魔术帽子🎩,我们来开始吧!
合约出生证明
让我们创建一个简单(完全没用)的合约:1
2
3pragma solidity ^0.4.11;
contract C {
}
编译它:1
solc --bin --asm c.sol
字节码是:1
60606040523415600e57600080fd5b5b603680601c6000396000f30060606040525b600080fd00a165627a7a723058209747525da0f525f1132dde30c8276ec70c4786d4b08a798eda3c8314bf796cc30029
为了创建这个合约,我们需要先通过发送一个eth_sendtransaction RPC请求给以太坊的节点来创建一个交易。你可以使用Remix或Metamask来做这件事情。
不管你使用什么开发工具,RPC调用的参数就差不多类似于:1
2
3
4
5
6
7{
"from": "0xbd04d16f09506e80d1fd1fd8d0c79afa49bd9976",
"to": null,
"gas": "68653", // 30400,
"gasPrice": "1", // 10000000000000
"data": "0x60606040523415600e57600080fd5b603580601b6000396000f3006060604052600080fd00a165627a7a723058204bf1accefb2526a5077bcdfeaeb8020162814272245a9741cc2fddd89191af1c0029"
}
没有什么特殊的RPC调用或交易类型来创建一个合约。相同的交易机制也被用于其他机制:
- 转移Ether到一个账户或合约
- 调用一个带参数的合约方法
根据你指定的参数,以太坊会以不同方式解释交易。创建一个合约,to
地址应该为null
(或被忽略)。
我用下面这个交易创建了一个合约例子:
https://rinkeby.etherscan.io/tx/0x58f36e779950a23591aaad9e4c3c3ac105547f942f221471bf6ffce1d40f8401
打开Etherscan,你应该可以看到该交易的输入数据就是Solidity编译器产生的字节码:
当处理该交易的时候,EVM会将输入数据作为代码执行。瞧,一个合约就被创建了。
字节码是干什么的?
我们可以将上面的字节码分成3个独立的块:1
2
3
4
5
6//部署代码
60606040523415600e57600080fd5b5b603680601c6000396000f300
//合约代码
60606040525b600080fd00
// Auxdata
a165627a7a723058209747525da0f525f1132dde30c8276ec70c4786d4b08a798eda3c8314bf796cc30029
- 创建合约时运行部署代码
- 合约创建成功之后当它的方法被调用时,运行合约代码
- (可选)Auxdata是源码的加密指纹,用来验证。这只是数据,永远不会被EVM执行
部署代码有两个主要作用:
- 运行构造器函数,并设置初始化内存变量(就像合约的拥有者)
- 计算合约代码,并返回给EVM
Solidity编译器产生的部署代码会从字节码中加载60606040525b600080fd00
到内存中,然后将它作为合约代码返回。在这个例子中,“计算”只是读取一块数据到内存中。原则上,我们可以编程地产生合约代码。
构造器到底做什么取决于语言,但是EVM语言最后会返回合约代码。
合约创建
那么当部署代码运行完并返回合约代码之后会发生什么?以太坊是如何从返回的合约代码中创建一个合约的?
让我们一起深入的去了解一下源码,看看细节。
我发现了Go-Ethereum 的实现方式是找到需要信息的最简单参考。我们得到了正确的变量名、静态类型信息和符号交叉引用。尝试超越这个,黄皮书!
使用Sourcegraph(当鼠标停留在一个变量上的时候会有类型信息,非常好用)软件阅读的源码,找到的相关方法是evm.Create。让我们略读一下代码,忽略一些错误检查和过于详细的细节。从上到下:
检测调用者是否拥有足够的余额来做转账
1
2
3if !evm.CanTransfer(evm.StateDB, caller.Address(), value) {
return nil, common.Address{}, gas, ErrInsufficientBalance
}从调用者的地址派生一个新合约的地址(通过创建者账户的
nonce
传递):1
contractAddr = crypto.CreateAddress(caller.Address(), nonce)
使用派生的合约地址来创建新合约账户(改变”世界状态“
StateDB
):1
evm.StateDB.CreateAccount(contractAddr)
将初始的Ether基金从调用者转到新合约中:
1
evm.Transfer(evm.StateDB, caller.Address(), contractAddr, value)
设置输入数据为合约的部署代码,然后使用EVM来执行。
ret
变量是返回的合约代码:1
2
3contract := NewContract(caller, AccountRef(contractAddr), value, gas)
contract.SetCallCode(&contractAddr, crypto.Keccak256Hash(code), code)
ret, err = run(evm, snapshot, contract, nil)检查错误。或如果合约代码太长则会失败。收取用户的gas然后设置合约代码:
1
2
3
4
5
6
7
8if err == nil && !maxCodeSizeExceeded {
createDataGas := uint64(len(ret)) * params.CreateDataGas
if contract.UseGas(createDataGas) {
evm.StateDB.SetCode(contractAddr, ret)
} else {
err = ErrCodeStoreOutOfGas
}
}
部署代码的代码
让我们来看看汇编代码的细节,看看当一个合约被创建的时候”部署代码“是如何返回”合约代码“的。我们将会再一次分析合约列子:1
2
3pragma solidity ^0.4.11;
contract C {
}
将该合约的字节码分成独立的块:1
2
3
4
5
6// 部署代码
60606040523415600e57600080fd5b5b603680601c6000396000f300
//合约代码
60606040525b600080fd00
// Auxdata
a165627a7a723058209747525da0f525f1132dde30c8276ec70c4786d4b08a798eda3c8314bf796cc30029
部署代码的汇编代码是:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// 为Solidity内部保留0x60个字节的内存
mstore(0x40, 0x60)
// 非支付合约。如果调用者发送ether就会归还
jumpi(tag_1, iszero(callvalue))
0x0
dup1
revert
// 将合约代码拷贝到内存中并返回
tag_1:
tag_2:
dataSize(sub_0)
dup1
dataOffset(sub_0)
0x0
codecopy
0x0
return
stop
为返回合约代码跟踪上面的汇编代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31// 60 36 (PUSH 0x36)
dataSize(sub_0)
stack: [0x36]
dup1
stack: [0x36 0x36]
// 60 1c == (PUSH 0x1c)
dataOffset(sub_0)
stack: [0x1c 0x36 0x36]
0x0
stack: [0x0 0x1c 0x36 0x36]
codecopy
// 消耗三个参数
// 将数据的 `length` 从`codeOffset` 拷贝到`memoryOffset`
// memoryOffset = 0x0
// codeOffset = 0x1c
// length = 0x36
stack: [0x36]
0x0
stack: [0x0 0x36]
memory: [
0x0:0x36 => calldata[0x1c:0x36]
]
return
// 消耗两个参数
// 返回 `memoryOffset`中的数据`length`
// memoryOffset = 0x0
// length = 0x36
stack: []
memory: [
0x0:0x36 => calldata[0x1c:0x36]
]
dataSize(sub_0)
和dataOffset(sub_0)
实际上不是真正的指令。它们实际上是PUSH指令,将常量压入栈中。两个0x1C
(28) 和0x36
(54) 常量指定一个字节码子字符串作为代码合约返回。
部署代码的汇编代码大致对应于下面的Python3 代码:1
2
3
4
5
6
7
8memory = []
calldata = bytes.fromhex("60606040523415600e57600080fd5b5b603680601c6000396000f30060606040525b600080fd00a165627a7a72305820b5090d937cf89f134d30e54dba87af4247461dd3390acf19d4010d61bfdd983a0029")
size = 0x36 // dataSize(sub_0)
offset = 0x1c // dataOffset(sub_0)
// 将调用数据的子字符串拷贝到内存
memory[0:size] = calldata[offset:offset+size]
// 将内存的内容用十六进制打印出来而不返回
print(bytes(memory[0:size]).hex())
产生的内存内容是:1
260606040525b600080fd00
a165627a7a72305820b5090d937cf89f134d30e54dba87af4247461dd3390acf19d4010d61bfdd983a0029
对应的汇编代码(加上auxdata):1
2
3
4
5
6
7// 6060604052600080fd00
mstore(0x40, 0x60)
tag_1:
0x0
dup1
revert
auxdata: 0xa165627a7a723058209747525da0f525f1132dde30c8276ec70c4786d4b08a798eda3c8314bf796cc30029
再次看下Etherscan,这正是部署的合约代码:
以太坊账户0x2c7f561F1fc5c414C48d01E480fDAAE2840B8AA2 信息
以太坊区块链探险者,API和分析平台
rinkeby.etherscan.io
CODECOPY
部署代码使用codecopy
将交易的输入数据拷贝到内存。
codecopy
指令的行为和参数比其他的简单指令要复杂一点。如果我在黄皮书中查找这个指令,可能会更加的困惑一些。相反,让我们看看go-ethereum 源代码来研究一下到底怎么回事。
看看CODECOPY:1
2
3
4
5
6
7
8
9
10
11func opCodeCopy(pc *uint64, evm *EVM, contract *Contract, memory *Memory, stack *Stack) ([]byte, error) {
var (
memOffset = stack.pop()
codeOffset = stack.pop()
length = stack.pop()
)
codeCopy := getDataBig(contract.Code, codeOffset, length)
memory.Set(memOffset.Uint64(), length.Uint64(), codeCopy)
evm.interpreter.intPool.put(memOffset, codeOffset, length)
return nil, nil
}
没有难懂的字母!
evm.interpreter.intPool.put(memOffset, codeOffset, length)
这一行回收对象(大整数)后面使用。这只是一个高效的优化。
构造器参数
除了产生合约代码,部署代码的其他作用是运行构造器来进行设置。如果存在构造器参数,那么部署代码就需要从某地放加载参数。
Solidity传递构造器参数的惯例是在调用eth_sendtransaction
时在字节码末尾附加ABI编码的参数值。RPC调用将字节码和ABI编码参数放到一起作为输入数据进行传递,就像:1
2
3
4{
"from": "0xbd04d16f09506e80d1fd1fd8d0c79afa49bd9976"
"data": hexencode(compiledByteCode + encodedParams),
}
看看有一个构造器参数的合约例子:1
2
3
4
5
6
7pragma solidity ^0.4.11;
contract C {
uint256 a;
function C(uint256 _a) {
a = _a;
}
}
我已经创建了这个合约,传递了66
值。Etherscan上的交易:
https://rinkeby.etherscan.io/tx/0x2f409d2e186883bd3319a8291a345ddbc1c0090f0d2e182a32c9e54b5e3fdbd8
输入数据是:1
0x60606040523415600e57600080fd5b6040516020806073833981016040528080519060200190919050508060008190555050603580603e6000396000f3006060604052600080fd00a165627a7a7230582062a4d50871818ee0922255f5848ba4c7e4edc9b13c555984b91e7447d3bb0e7400290000000000000000000000000000000000000000000000000000000000000042
我们可以在最后面看到构造器的参数,也就是66
,但是作为ABI编码的32位字节就是:1
0000000000000000000000000000000000000000000000000000000000000042
为了处理构造器里面的参数,部署代码从calldata
中的结尾拷贝ABI参数到内存中,然后从内存中拷贝到栈中。
创建合约的合约
FooFactory
合约可以通过调用makeNewFoo
来创建一个新的Foo
实例:1
2
3
4
5
6
7
8
9pragma solidity ^0.4.11;
contract Foo {
}
contract FooFactory {
address fooInstance;
function makeNewFoo() {
fooInstance = new Foo();
}
}
这个合约完整的汇编代码在Gist里。编译器输出的结构更加的复杂,因为这里有两套”安装时间“和”运行时间“的字节码。就像这样组织的:1
2
3
4
5
6FooFactoryDeployCode
FooFactoryContractCode
FooDeployCode
FooContractCode
FooAUXData
FooFactoryAUXData
FooFactoryContractCode
本质就是为tag_8
中Foo
拷贝字节码然后跳转回tag_7
去执行create
指令。
create
指令就像eth_sendtransaction
RPC调用。它提供了一个在EVM里面创建新合约的方法。
看一下go-ethereum 源代码里面的opCreate 。这个指令调用evm.Create
来创建合约:1
res, addr, returnGas, suberr := evm.Create(contract, input, gas, value)
我们在前面就已经看见了evm.Create
,但是这次的调用者是一个智能合约而不是人类。
AUXDATA
如果你想要完全的理解auxdata是什么,那么可以阅读 合约元数据。它的要点就是auxdata
是一个哈希值,你可以使用它来抓取部署合约的元数据。
auxdata的格式就是:1
0xa1 0x65 'b' 'z' 'z' 'r' '0' 0x58 0x20 <32 bytes swarm hash> 0x00 0x29`
解构我们之前看到过的auxdata字节序列:1
2
3
4
5
6
7a1 65
// b z z r 0 (ASCII)
62 7a 7a 72 30
58 20
// 32 bytes hash
62a4d50871818ee0922255f5848ba4c7e4edc9b13c555984b91e7447d3bb0e74
00 29
总结
合约创建的方式和自我解压软件安装程序的工作方式比较类似。当安装程序运行时,它会配置系统环境,然后会从程序包中读取目标程序放入到系统中。
- 在”安装时间“和”运行时间“之间有一个强制的分离。无法运行构造器两次
- 智能合约可以使用相同的处理来创建其他的智能合约
- 使用非Solidity语言实现会容易一点
首先,我对”智能合约安装程序“的不同部分被打包到一起作为字节字符串data
放在交易里感到很困惑:1
2
3{
"data": constructorCode + contractCode + auxdata + constructorData
}
data
是如何被编码的,阅读文档中的eth_sendtransaction
无法获得明显的答案。我一直都没弄明白构造器的参数是如何传递给交易的,直到有一个朋友告诉我它们被ABI进行编码之后附加到字节码的后面,才明白是怎么回事。
另一个可以使它看起来更加清晰一点的替代设计也许就是将这些作为交易独立的属性进行发送:1
2
3
4
5
6
7
8{
// For "install time" bytecode
"constructorCode": ...,
// For "run time" bytecode
"constructorBody": ...,
// For encoding arguments
"data": ...,
}
不过进行更多的思考,我认为交易对象简单化实际上是非常强大的。对于一个交易,data
只是一个字节字符串,而且它不涉及数据是如何被解释的语言模型。通过让交易对象简单化,语言的实现者就有一个空白的画布进行设计和实验。
确实,data
在未来甚至可以被一个不同的虚拟机进行解释。
翻译作者: 许莉
原文地址:Diving Into The Ethereum VM Part Five