0%

StudyRecord-以太坊源码分析-通过EVM执行转账或合约调用-Call()

Pre:

在了解合约调用之前,我们需要知道调用合约的本质是什么?

在我们创建合约的时候,由run函数初始化的智能合约code(Runtime bytecode)(ret)储存在stateDB中。
也就是说在内存中并没有Contract这个对象,而只是存在智能合约code

那我们如何调用合约呢?

本质上,调用合约实际上是

  1. 通过StateDB.GetCode()从合约账户中取出合约代码

  2. NewContract()创建出一个临时的contract对象(如下数据结构)

  3. 执行contract对象的SetCallCode()或其他方法,确定智能合约的执行环境

  4. 执行interpreter.run()函数,返回执行后的代码

Contract对象的数据结构:

一个 Contract 对象包含和维护了合约在执行过程中的必要信息,比如合约创建者合约自身地址合约剩余 gas合约代码代码的 jumpdests 记录

core/vm/contract.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Contract represents an ethereum contract in the state database. It contains
// the contract code, calling arguments. Contract implements ContractRef
// 数据库中的以太坊智能合约,包括合约代码和调用参数
type Contract struct {
// CallerAddress is the result of the caller which initialised this
// contract. However when the "call method" is delegated this value
// needs to be initialised to that of the caller's caller.
// CallerAddress是初始化这个合约的人。 如果是delegate,这个值被设置为调用者的调用者。
CallerAddress common.Address
caller ContractRef
self ContractRef

// JUMPDEST指令分析的结果
jumpdests map[common.Hash]bitvec // Aggregated result of JUMPDEST analysis.
analysis bitvec // Locally cached result of JUMPDEST analysis

Code []byte // 合约代码
CodeHash common.Hash //代码的HASH
CodeAddr *common.Address //代码地址 // 合约地址
Input []byte // 入参

Gas uint64 // 合约还有多少Gas
value *big.Int
}

知道了这个过程,我们再来看看 智能合约的调用或普通交易——Call()的具体实现。

evm.Call()的参数:

core/vm/evm.go
1
func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {}
  • caller:转出方地址

  • addr:转入方地址,如果是调用智能合约,那就是智能合约的地址

  • input:调用函数的参数

  • gas:当前交易的剩余gas

  • value:转账额度

evm.Call()的实现:

evm.Call()的主要功能是执行一笔交易,具体的步骤如下:

  1. 交易执行前的检查:深度判断和余额状况;

  2. 判断StateDB是否存在合约地址

  3. 进行转账;

  4. 创建一个待执行的合约对象,并执行;

  5. 处理交易执行的返回值

交易执行前的检查:

第一步,执行前检查:判断递归层次和合约调用者是否有足够的交易中约定的以太币。

core/vm/evm.go
1
2
3
4
5
6
7
8
9
10
// Fail if we're trying to execute above the call depth limit
// 调用深度检查
if evm.depth > int(params.CallCreateDepth) {
return nil, gas, ErrDepth
}
// Fail if we're trying to transfer more than the available balance
// 余额检查,如果不够直接退出
if value.Sign() != 0 && !evm.Context.CanTransfer(evm.StateDB, caller.Address(), value) {
return nil, gas, ErrInsufficientBalance
}

判断合约地址是否存在:

第二步,是判断合约地址是否存在;
一般情况下,被调用的合约地址应该存在于以太坊状态数据库中,也就是说合约已经创建成功了。否则就返回失败。
但有一种例外,就是被调用的合约地址是预先定义的情况,此时即使地址不在状态数据库中,也要立即创建一个。

core/vm/evm.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// StateDB是否存在 合约地址
if !evm.StateDB.Exist(addr) {
if !isPrecompile && evm.chainRules.IsEIP158 && value.Sign() == 0 {
// Calling a non existing account, don't do anything, but ping the tracer
if evm.Config.Debug {
if evm.depth == 0 {
evm.Config.Tracer.CaptureStart(evm, caller.Address(), addr, false, input, gas, value)
evm.Config.Tracer.CaptureEnd(ret, 0, 0, nil)
} else {
evm.Config.Tracer.CaptureEnter(CALL, caller.Address(), addr, input, gas, value)
evm.Config.Tracer.CaptureExit(ret, 0, nil)
}
}
return nil, gas, nil
}
// 创建账号
evm.StateDB.CreateAccount(addr)
}

转账:

第三步进行转账。

core/vm/evm.go
1
2
// 转账
evm.Context.Transfer(evm.StateDB, caller.Address(), addr, value)

创建合约对象,并执行:

第四步 使用当前的信息创建一个待执行的合约对象,并执行。
其中 StateDB.GetCode 从状态数据库中获取合约的代码,填充到合约对象中。

core/vm/evm.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if isPrecompile {
ret, gas, err = RunPrecompiledContract(p, input, gas)
} else {
// Initialise a new contract and set the code that is to be used by the EVM.
// The contract is a scoped environment for this execution context only.
// 初始化新合同并设置EVM要使用的代码。
code := evm.StateDB.GetCode(addr)
if len(code) == 0 {
ret, err = nil, nil // gas is unchanged
} else {
addrCopy := addr
// If the account has no code, we can abort here
// The depth-check is already done, and precompiles handled above
// 如果账户没有代码,我们可以在此终止
// 创建一个待执行的合约对象
contract := NewContract(caller, AccountRef(addrCopy), value, gas)
contract.SetCallCode(&addrCopy, evm.StateDB.GetCodeHash(addrCopy), code)
// 执行
ret, err = evm.interpreter.Run(contract, input, false)
gas = contract.Gas
}
}

这里与create()不同之处:

  • 构建新contract对象的时候,create()调用了SetCodeOptionalHash(&address, codeAndHash)

  • 而这里Call()调用的是SetCallCode(&addr, evm.StateDB.GetCodeHash(addr), evm.StateDB.GetCode(addr))

  • create()中构建的contract对象中的Code是来自原始transaction中的Payload

  • 而在Call()构建的contract对象中的Code则是create()中初始化智能合约即执行run()之后返回的ret,这两者在结构上是有区别的。

20220724114632

没看到哪里调用合约代码呀?只不过是使用 StateDB.GetCode 获取合约代码,然后使用 input 中的数据作为参数,调用解释器运行合约代码而已哪里有调用合约 public 方法的影子?

这里确实与创建合约时类似,没有「调用」的影子。
还记得前面我们介绍合约创建时,提到过合约编译器在编译时,会插入一些代码吗?
在介绍合约创建时,我们只介绍了编译器插入的创建合约的代码,解释器执行这些代码,就可以将合约的真正代码返回。
类似的,编译器还会插入一些调用合约的代码,只要使用正确的参数执行这些代码,就可以「调用」到我们想调用的合约的 public 方法。

想要了解这整个机制,我们需要先介绍一下「函数选择器」这个概念。

什么是「函数选择器」?

简单来说,就是合约的 public 方法的声明字符串的 Keccak-256 哈希的前 4 个字节。
「函数选择器」告诉了以太坊虚拟机我们想要调用合约的哪个方法,它和参数数据一起,被编码到了交易的 data 数据中。

但我们刚才通过对合约调用的分析,并没有发现有涉及到解析「函数选择器」的地方呀?

这是因为 「函数选择器」和参数的解析功能并不是由以太坊虚拟机的 go 代码码完成的,而是由合约编译器在编译时插入的代码完成了

合约调用的方法:

EVM 对象实现合约的调用,有下面几种方法:

  1. evm.Call()

  2. evm.CallCode()

  3. evm.DelegateCall()

  4. evm.StaticCall()

后面三个的调用方式都是与 evm.Call() 比较产生的差异。

CallCode()和DelegateCall():

首先需要了解的是,evm.CallCodeevm.DelegateCall 的存在是为了实现合约的「库」的特性

我们知道编程语言都有自己的库,比如 go 的标准库,C++ 的 STL 或 boost。
作为合约的编写语言,solidity 也想有自己的库。
但与普通语言的实现不同,solidity 写出来的代码要想作为库被调用,必须和普通合约一样,布署到区块链上取得一个固定的地址,其它合约才能调用这个「库合约」提供的方法。

但合约又涉及到一些特有的属性,比如合约的调用者自身地址自身所拥有的以太币的数量等。

如果我们直接去调用「库合约」的代码,用到这些属性时必然是「库合约」自己的属性,但这可能不是我们想要的。
例如,设想一个「库合约」的方法实现了一个这样的操作:从自已账户中给指定账户转一笔钱。
如果这里的「自己账户」指的是「库合约」的账户,那肯定是不现实的(因为没有人会出钱布署一个有很多以太币的合约,并且让别人把这些币转走)。

此时 evm.DelegateCall 就派上用场了。
这个调用方式将「库合约」的调用者,设置成自己的调用者;将「库合约」的地址,设置成自己的地址(但代码还是「库合约」的代码)。
如此一来,「库合约」的属性,就完全和自己的属性一样了,「库合约」的代码就像是自己的写的代码一样。

例如

  • A 账户调用了 B 合约

  • 而在 B 合约中通过 DelegateCall 调用了 C 合约

  • 那么 C 合约的调用者将被修改成 A

  • C 合约的地址将被修改成 B 合约的地址。

所以在刚才用来转账的「库合约」的例子中,「自己账户」指的不再是「库合约」的账户了,而是调用「库合约」的账户,转账也就可以按我们想要的方式进行了。

CallCodeDelegateCall 类似,不同的是 CallCode 不改变「库合约」的调用者,只是改变「库合约」的合约地址。
也就是说,如果 A 通过 CallCode 的方式调用 B,那么 B 的调用者是 A,而 B 的账户地址也被改成了 A。

总结一下就是,CallCodeDelegateCall 修改了被调用合约的上下文环境,可以让被调用的合约代码就像自己写的代码一样,从而达到「库合约」的目的
具体来说,DelegateCall 会修改被调用合约的调用者和合约本身的地址,而 CallCode 只会修改被调用合约的本身的地址。

了解了这两个方法的目的和功能,我们来看看代码中它们是如何实现各自的功能的。
对于 evm.CallCode 来说,它通过下面展示的几行代码来修改被调用合约的地址:

1
2
3
4
5
6
7
8
func (evm *EVM) CallCode(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {
...
contract := NewContract(caller, AccountRef(caller.Address()), value, gas) // !!!
contract.SetCallCode(&addrCopy, evm.StateDB.GetCodeHash(addrCopy), evm.StateDB.GetCode(addrCopy))
ret, err = evm.interpreter.Run(contract, input, false)
gas = contract.Gas
...
}

可以看到,在利用现有数据生成一个合约对象时,将合约对象的地址 to 变量设置成调用者,也就是 caller 的地址。
对于 evm.DelegateCall 来说,它是通过下面几行代码修改被调用合约的调用者和自身地址的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (evm *EVM) DelegateCall(caller ContractRef, addr common.Address, input []byte, gas uint64) (ret []byte, leftOverGas uint64, err error) {
// to 变量设置为caller 的地址
contract := NewContract(caller, AccountRef(caller.Address()), nil, gas).AsDelegate()
...

}

func (c *Contract) AsDelegate() *Contract {
// NOTE: caller must, at all times be a contract. It should never happen
// that caller is something other than a Contract.
parent := c.caller.(*Contract)
c.CallerAddress = parent.CallerAddress
c.value = parent.value

return c
}

这里首先也是通过将 to 变量设置成调用者,也就是 caller 的地址,达到修改被调用合约的自身地址的目的。
被调用合约的调用者是通过 Contract.AsDelegate 修改的。
这个方法里,将合约的调用者地址 CallerAddress 设置成目前调用者的 CallerAddress,也即 当前调用者的调用者的地址(有些绕,仔细看一下就能明白)。

三种调用方法的比较:

Call():

1
2
3
4
5
6
7
8
9
10
11
12
13
to = AccountRef(addr)
contract := NewContract(caller, to, value, gas)

// 假设有外部账户A,合约账户B和合约账户C
A Call B ——> ContractB
CallerAddress: A
Caller: A
self: B

B Call C ——> ContractC
CallerAddress: B
Caller: B
self: C

CallCode():

1
2
3
4
5
6
7
8
9
10
11
12
13
to = AccountRef(caller.Address())
contract := NewContract(caller, to, value, gas)

// 假设有外部账户A,合约账户B和合约账户C
A Call B ——> ContractB
CallerAddress: A
Caller: A
self: B

B Callcode C ——> ContractC
CallerAddress: B
Caller: B
self: B

delegateCall():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

to = AccountRef(caller.Address())
contract := NewContract(caller, to, nil, gas).AsDelegate()

func (c *Contract) AsDelegate() *Contract {
parent := c.caller.(*Contract)
c.CallerAddress = parent.CallerAddress
c.value = parent.value

return c
}

// 假设有外部账户A,合约账户B和合约账户C
A Call B ——> ContractB
CallerAddress: A
Caller: A
self: B

B DelegateCall C ——> ContractC
CallerAddress: A
Caller: B
self: B

从代码上看,这三者的主要区别就是这一点,主要就是在contract对象中的callerAddresscallerself这三个的值不同。

如果外部账户A的某个操作通过Call方法调用B合约,而B合约又通过Call方法调用了C合约,那么最后实际上修改的是合约C账户的值;
如果外部账户A的某个操作通过Call方法调用B合约,而B合约通过CallCode方法调用了C合约,那么B只是调用了C中的函数代码,而最终改变的还是合约B账户的值。

DelegateCall其实跟CallCode方法的目的类似,都是只调用指定地址(合约C)的代码,而操作B的值。
只不过它明确了CallerAddress是来自A,而不是B。

所以这两种方法都可以用来实现动态库:即你调用我的函数和方法改动的都是你自己的数据。

连环调用:

我们说过智能合约EVM的递归调用深度为1024,也就是指通过一个合约调用另一个合约,像这样的调用可以递归1024次。

为什么说是“递归”?因为从一个智能合约调用另一个智能合约,比如通过Call()方法,都要重新构建contract实例,然后执行run()
run()的执行是通过EVMinterpreter.Run()进行的。而在EVMInterpreter结构体中又传入了*EVM的地址,然后执行了evm.depth++
所以实际上每一次调用都是在同一个EVM内进行的。

StaticCall():

evm.StaticCallevm.Call 类似,唯一不同的是 StaticCall 不允许执行会修改永久存储的数据的指令
如果执行过程中遇到这样的指令,就会失败。

StaticCall 是如何实现拒绝执行会修改永久存储数据的指令的呢?

StaticCall调用解释器执行时,会传入readOnlytrue

1
2
3
4
5
6
func (evm *EVM) StaticCall(caller ContractRef, addr common.Address, input []byte, gas uint64) (ret []byte, leftOverGas uint64, err error) {
contract := NewContract(caller, AccountRef(addrCopy), new(big.Int), gas)
contract.SetCallCode(&addrCopy, evm.StateDB.GetCodeHash(addrCopy), evm.StateDB.GetCode(addrCopy))
// run 函数的最后一个参数 readOnly 为 true
ret, err = evm.interpreter.Run(contract, input, true)
}

在解释器的 EVMInterpreter.Run 方法中,会记录readOnly参数

core/vm/interpreter.go
1
2
3
4
5
6
7
8
9
10
11
func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
...

// Make sure the readOnly is only set if we aren't in readOnly yet.
// This also makes sure that the readOnly flag isn't removed for child calls.
// 将readOnly设置为true
if readOnly && !in.readOnly {
in.readOnly = true
defer func() { in.readOnly = false }()
}
}

解释器的执行过程中:

  • 根据pc取指令

  • 根据指令从JumpTable中获得操作码

  • instructions.go中,找到执行操作码的具体实现函数,对堆栈进行操作

20220724225445

在部分操作码(opSstoreopCreateopCreate2opCallopSelfdestruct)对应的实现函数中,会对readOnly参数进行检查

core/vm/instructions.go
1
2
3
4
5
6
7
8
9
10
func opSstore(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
if interpreter.readOnly { // 检查只读
return nil, ErrWriteProtection
}
loc := scope.Stack.pop()
val := scope.Stack.pop()
interpreter.evm.StateDB.SetState(scope.Contract.Address(),
loc.Bytes32(), val.Bytes32())
return nil, nil
}

Refs: