go-ethereum 源码笔记(core 模块-世界状态,交易收据管理)

通过前面的文章,我们了解了 geth 的大致原理,知道了区块链是怎么组织构成的,有了 rlp,MPT 这些数据结构的基础,也知道了账户是怎么组织的,交易是如何管理的,挖矿是一个什么样的流程,这一篇我们将深入到交易的具体处理过程,看看交易过程中是如何修改世界状态,生成交易收据的。

这篇文章将涉及到 core/types/transaction.go, core/state 模块,需要对交易池,账户等基本概念有所了解。

core/state 包提供了用户和合约的状态管理的功能。包括 trie,数据库,cache,日志和回滚相关功能。还记得go-ethereum 源码笔记(概览) 中曾给出的总体架构图吗?

https://i.stack.imgur.com/afWDt.jpg 按照定义,日志应该属于交易树的部分,但 core/state 中的 journal.go 却提供了日志的管理,这里有点不符合逻辑,可以稍稍注意一下。

前面的文章中有提过,在 geth 中存在三棵主要的 MPT 树,状态树,交易树,收据树。在交易上链的过程中,状态树,收据树会不断改变,

世界状态其实是一个账户地址和账户状态的映射,不管是外部账户还是合约账户,账户状态都包括:

  • nonce,如果账户是一个外部拥有账户,nonce 代表从此账户地址发送的交易序号。如果账户是一个合约账户,nonce 代表此账户创建的合约序号
  • balance,该地址拥有 Wei 的数量。1Ether=10^18Wei
  • storageRoot, MPT 树的根节点哈希值。这个 MPT 会将此账户存储内容的哈希值进行编码,默认是空值
  • codeHash,该账户 EVM 代码的哈希值。对于合约账户,就是被哈希之后的代码,以 codeHash 保存。对于外部拥有账户,codeHash 是一个空字符串的哈希值

以太坊还会为每笔交易生成收据,每张收据都包含有关交易的某些信息。包括以下内容:

  • 区块号
  • 区块哈希
  • 事务哈希值
  • 当前交易使用的 gas
  • 在当前交易执行后当前块中使用的 gas
  • 执行当前事务时创建的日志

我们先从收据树入手。

插入区块更新状态

Process

还记得在 go-ethereum 源码笔记(core 模块-区块链操作) 这一篇的 insertChain 方法中,有一个调用 receipts, logs, usedGas, err := bc.processor.Process(block, state, bc.vmConfig) 的动作,这是在插入转账区块后,更新状态树,收据树,我们来看看 Process 具体做了什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (p *StateProcessor) Process(block *types.Block, statedb *state.StateDB, cfg vm.Config) (types.Receipts, []*types.Log, uint64, error) {
var (
receipts types.Receipts
usedGas = new(uint64)
header = block.Header()
allLogs []*types.Log
gp = new(GasPool).AddGas(block.GasLimit())
)
if p.config.DAOForkSupport && p.config.DAOForkBlock != nil && p.config.DAOForkBlock.Cmp(block.Number()) == 0 {
misc.ApplyDAOHardFork(statedb)
}
for i, tx := range block.Transactions() {
statedb.Prepare(tx.Hash(), block.Hash(), i)
receipt, _, err := ApplyTransaction(p.config, p.bc, nil, gp, statedb, header, tx, usedGas, cfg)
if err != nil {
return nil, nil, 0, err
}
receipts = append(receipts, receipt)
allLogs = append(allLogs, receipt.Logs...)
}
p.engine.Finalize(p.bc, header, statedb, block.Transactions(), block.Uncles(), receipts)

return receipts, allLogs, *usedGas, nil
}

Process 根据以太坊规则运行交易来对改变 statedb 的状态,以奖励挖矿者或其他的叔父节点。Process 会返回执行过程中累计的收据和日志,并返回过程中使用的 gas,如果 gas 不足导致任何交易执行失败,返回错误。

Process 首先会声明变量,值得注意的是 GasPool 变量,它告诉你剩下还有多少 Gas 可以使用,在每一个交易的执行过程中,以太坊设计了 refund 的机制进行处理,偿还的 gas 也会加到 GasPool 中。接着处理 DAO 事件的硬分叉,接着遍历区块中的所有交易,通过调用 ApplyTransaction 获得每个交易的收据。这个过程结束后,调用一致性引擎的 Finalize,拿到区块奖励或叔块奖励,最终将世界状态写入到区块链中,也就是说 stateTrie 随着每次交易的执行变化,这个过程在 go-ethereum 源码笔记(trie 模块-MPT 的实现) 也有提及,它通过 StateDB 的 IntermediateRoot() 方法实现。我们先深入到 ApplyTransaction 看看每一个交易是如何构成收据的。

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
func ApplyTransaction(config *params.ChainConfig, bc ChainContext, author *common.Address, gp *GasPool, statedb *state.StateDB, header *types.Header, tx *types.Transaction, usedGas *uint64, cfg vm.Config) (*types.Receipt, uint64, error) {
msg, err := tx.AsMessage(types.MakeSigner(config, header.Number))
if err != nil {
return nil, 0, err
}
vmenv := vm.NewEVM(context, statedb, config, cfg)
_, gas, failed, err := ApplyMessage(vmenv, msg, gp)
if err != nil {
return nil, 0, err
}
var root []byte
if config.IsByzantium(header.Number) {
statedb.Finalise(true)
} else {
root = statedb.IntermediateRoot(config.IsEIP158(header.Number)).Bytes()
}
*usedGas += gas

receipt := types.NewReceipt(root, failed, *usedGas)
receipt.TxHash = tx.Hash()
receipt.GasUsed = gas
if msg.To() == nil {
receipt.ContractAddress = crypto.CreateAddress(vmenv.Context.Origin, tx.Nonce())
}
receipt.Logs = statedb.GetLogs(tx.Hash())
receipt.Bloom = types.CreateBloom(types.Receipts{receipt})

return receipt, gas, err
}

对于每个交易,都会创建一个新的虚拟机环境,即根据输入参数封装 EVM 对象,接着调用 ApplyMessage 将交易应用于当前的状态中,Finalise 方法会调用 update 方法,将存放在 cache 的修改写入 trie 数据库中,返回的是使用的 gas,这部分代码涉及到 core/state_transition.go

接着,如果是拜占庭硬分叉,直接调用 statedb.Finalise(true),否则调用 statedb.IntermediateRoot(config.IsEIP158(header.Number)).Bytes(),最后,初始化一个收据对象,其 TxHash 为交易哈希,Logs 字段通过 statedb.GetLogs(tx.Hash()) 拿到,Bloom 字段通过 types.CreateBloom(types.Receipts{receipt}) 创建,最后返回收据和消耗的 gas。

Transition

Process 部分的代码是插入区块时被调用的,而我们发现 Process 在处理每一个交易时,最终会调用 core/state_transition.go 中的 Finalise,实际上,如果说 core/state_processor.go 是用来处理区块级别的交易,那么可以说 core/state_transition.go 是用来处理一个一个的交易,最终将获得世界状态,收据,gas 等信息。

Transition 完成世界状态转换的工作,它会利用世界状态来执行交易,然后改变当前的世界状态。我们可以细看一下 TransitionDb() 的执行过程。

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
32
33
34
35
36
37
38
func (st *StateTransition) TransitionDb() (ret []byte, usedGas uint64, failed bool, err error) {
if err = st.preCheck(); err != nil {
return
}
msg := st.msg
sender := vm.AccountRef(msg.From())
homestead := st.evm.ChainConfig().IsHomestead(st.evm.BlockNumber)
contractCreation := msg.To() == nil

gas, err := IntrinsicGas(st.data, contractCreation, homestead)
if err != nil {
return nil, 0, false, err
}
if err = st.useGas(gas); err != nil {
return nil, 0, false, err
}

var (
evm = st.evm
vmerr error
)
if contractCreation {
ret, _, st.gas, vmerr = evm.Create(sender, st.data, st.gas, st.value)
} else {
st.state.SetNonce(msg.From(), st.state.GetNonce(sender.Address())+1)
ret, st.gas, vmerr = evm.Call(sender, st.to(), st.data, st.gas, st.value)
}
if vmerr != nil {
log.Debug("VM returned with error", "err", vmerr)
if vmerr == vm.ErrInsufficientBalance {
return nil, 0, false, vmerr
}
}
st.refundGas()
st.state.AddBalance(st.evm.Coinbase, new(big.Int).Mul(new(big.Int).SetUint64(st.gasUsed()), st.gasPrice))

return ret, st.gasUsed(), vmerr != nil, err
}

TransitionDb 首先会调用 preCheckpreCheck 的作用是检测 Nonce 的值是否正确,然后通过 buyGas() 购买 Gas。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (st *StateTransition) buyGas() error {
mgval := new(big.Int).Mul(new(big.Int).SetUint64(st.msg.Gas()), st.gasPrice)
if st.state.GetBalance(st.msg.From()).Cmp(mgval) < 0 {
return errInsufficientBalanceForGas
}
if err := st.gp.SubGas(st.msg.Gas()); err != nil {
return err
}
st.gas += st.msg.Gas()

st.initialGas = st.msg.Gas()
st.state.SubBalance(st.msg.From(), mgval)
return nil
}

buyGas 会从交易的转出方账户扣除 GasLimit * gasPrice,GasPool 也会减掉这笔 Gas 消耗。

回到 TransitionDb 方法,在 preCheck() 之后,会调用 IntrinsicGas 方法,计算交易的固有 Gas 消耗,这个消耗有两部分,一部分是交易(或创建合约)预设消耗量,一部分是根据交易 data 的非0字节和0字节长度决定的消耗量,TransitionDb 方法中最终会从 gas 中减去这笔消耗。再接下来就是 EVM 的执行,如果是创建合约,调用的是 evm.Create(sender, st.data, st.gas, st.value),如果是普通的交易,调用的是 evm.Call(sender, st.to(), st.data, st.gas, st.value),这两个方法很重要,在接下来的 (go-ethereum 源码笔记(core 模块-EVM))[#TODO] 这篇文章中会有讲解,这两个方法同样也会消耗 gas。接着调用 refundGas 方法执行退 gas 的操作。

1
2
3
4
5
6
7
8
9
10
11
func (st *StateTransition) refundGas() {
refund := st.gasUsed() / 2
if refund > st.state.GetRefund() {
refund = st.state.GetRefund()
}
st.gas += refund

remaining := new(big.Int).Mul(new(big.Int).SetUint64(st.gas), st.gasPrice)
st.state.AddBalance(st.msg.From(), remaining)
st.gp.AddGas(st.gas)
}

refundGas 首先会将用户剩下的 gas 还回去,但退回的金额不会超过用户使用的 gas 的 1/2。

TransitionDb 最后会通过 st.state.AddBalance 奖励区块的挖掘者,这笔钱等于 gasPrice*(initialGas-gas),至此,这个交易的 gas 消耗量计算就完成了。

state 模块

在上面的 Process, Transition 小节,我们了解了 geth 中状态管理的部分功能,实际上就管理世界状态而言,core/state 还提供了很多其他功能,不过这些功能大多只是在 EVM 进行合约的操作时才使用到,例如 core/vm/instructions.go 中的 makeLog 指令,在进行出栈操作后,最终还是会通过 StateDB 的 AddLog 方法实现写日志的功能。这里我们将深入 core/state 模块的实现,限于篇幅,无法面面俱到,我们只分析几个应用场景。

存储以太坊合约和账户 StateDB

stateDB 用来与以太坊中的世界状态进行交互,它可以与状态树进行交互,提供了检索合约,账户的功能。

数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type StateDB struct {
db Database
trie Trie

stateObjects map[common.Address]*stateObject
stateObjectsDirty map[common.Address]struct{}

dbErr error
refund uint64

thash, bhash common.Hash
txIndex int
logs map[common.Hash][]*types.Log
logSize uint

preimages map[common.Hash][]byte

journal *journal
validRevisions []revision
nextRevisionId int

lock sync.Mutex
}

其中 stateObjects 用来缓存状态对象,stateObjectsDirty 用来缓存被修改过的对象,这部分代码在 core/state/state_object.go 里。stateObject 主要依赖于 core/state/database.gocore/state/database.go 中的 Database(也是 StateDB 中的 Database) 封装了一下对 MPT 树的操作,可以增删改查世界状态,余额等。
journal 表示操作日志,core/state/journal.go 针对各种操作日志提供了对应的回滚功能,可以基于这个日志做一些事务类型的操作。

初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func New(root common.Hash, db Database) (*StateDB, error) {
tr, err := db.OpenTrie(root)
if err != nil {
return nil, err
}
return &StateDB{
db: db,
trie: tr,
stateObjects: make(map[common.Address]*stateObject),
stateObjectsDirty: make(map[common.Address]struct{}),
logs: make(map[common.Hash][]*types.Log),
preimages: make(map[common.Hash][]byte),
journal: newJournal(),
}, nil
}

初始化的时候会利用 OpenTrie 获取 trie,这部分功能由 core/state/database.go 提供,core/state/database.go 封装了与数据库交互的代码,从 database 提供的接口中可以一窥一二。

1
2
3
4
5
6
7
8
type Database interface {
OpenTrie(root common.Hash) (Trie, error)
OpenStorageTrie(addrHash, root common.Hash) (Trie, error)
CopyTrie(Trie) Trie
ContractCode(addrHash, codeHash common.Hash) ([]byte, error)
ContractCodeSize(addrHash, codeHash common.Hash) (int, error)
TrieDB() *trie.Database
}

OpenTrie 的会先从缓存中查找 trie,如果没有缓存的话构建个 trie 返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (db *cachingDB) OpenTrie(root common.Hash) (Trie, error) {
db.mu.Lock()
defer db.mu.Unlock()

for i := len(db.pastTries) - 1; i >= 0; i-- {
if db.pastTries[i].Hash() == root {
return cachedTrie{db.pastTries[i].Copy(), db}, nil
}
}
tr, err := trie.NewSecure(root, db.db, MaxTrieCacheGen)
if err != nil {
return nil, err
}
return cachedTrie{tr, db}, nil
}

Database 中还有 OpenStorage 方法,该方法和 OpenTrie 类似;ContractCode 可以用来访问合约代码;ContractCodeSize 用来获取合约的大小;CopyTrie 返回一个指定 trie 的 copy。代码都没有什么特别的,这里不再赘述,

我们接着看 stateDB 提供的功能。我们通过几个例子看看 StateDB 如何与 stateObject,journal,Database 进行交互的。

AddBalance 增加余额

1
2
3
4
5
6
func (self *StateDB) AddBalance(addr common.Address, amount *big.Int) {
stateObject := self.GetOrNewStateObject(addr)
if stateObject != nil {
stateObject.AddBalance(amount)
}
}

core/state/state_object.goAddBalance 方法为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (c *stateObject) AddBalance(amount *big.Int) {
if amount.Sign() == 0 {
if c.empty() {
c.touch()
}

return
}
c.SetBalance(new(big.Int).Add(c.Balance(), amount))
}

func (self *stateObject) SetBalance(amount *big.Int) {
self.db.journal.append(balanceChange{
account: &self.address,
prev: new(big.Int).Set(self.data.Balance),
})
self.setBalance(amount)
}

func (self *stateObject) setBalance(amount *big.Int) {
self.data.Balance = amount
}

通过 journal 记录了一条日志,方便回滚操作:

1
2
3
4
5
6
func (j *journal) append(entry journalEntry) {
j.entries = append(j.entries, entry)
if addr := entry.dirtied(); addr != nil {
j.dirties[*addr]++
}
}

GetBalance 获取余额

1
2
3
4
5
6
7
func (self *StateDB) GetBalance(addr common.Address) *big.Int {
stateObject := self.getStateObject(addr)
if stateObject != nil {
return stateObject.Balance()
}
return common.Big0
}

core/state/state_object.go 中,Balance 方法为:

1
2
3
func (self *stateObject) Balance() *big.Int {
return self.data.Balance
}

data 是 账户类型,在 stateObject 中已有缓存。

通过写入余额,获取余额的两个例子,我们知道了 StateDB 提供的功能,以及 journal,state_object 模块的职责,可以看到目前为止这些功能还是在内存里操作的,而世界状态最终还是会写到 LevelDB 里。

Commit 提交当前状态

通过 StateDB 的 Commit 方法可以写入当前的世界状态到数据库中。

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
32
33
34
35
36
37
38
39
40
func (s *StateDB) Commit(deleteEmptyObjects bool) (root common.Hash, err error) {
defer s.clearJournalAndRefund()

for addr := range s.journal.dirties {
s.stateObjectsDirty[addr] = struct{}{}
}
for addr, stateObject := range s.stateObjects {
_, isDirty := s.stateObjectsDirty[addr]
switch {
case stateObject.suicided || (isDirty && deleteEmptyObjects && stateObject.empty()):
s.deleteStateObject(stateObject)
case isDirty:
if stateObject.code != nil && stateObject.dirtyCode {
s.db.TrieDB().InsertBlob(common.BytesToHash(stateObject.CodeHash()), stateObject.code)
stateObject.dirtyCode = false
}
if err := stateObject.CommitTrie(s.db); err != nil {
return common.Hash{}, err
}
s.updateStateObject(stateObject)
}
delete(s.stateObjectsDirty, addr)
}
root, err = s.trie.Commit(func(leaf []byte, parent common.Hash) error {
var account Account
if err := rlp.DecodeBytes(leaf, &account); err != nil {
return nil
}
if account.Root != emptyState {
s.db.TrieDB().Reference(account.Root, parent)
}
code := common.BytesToHash(account.CodeHash)
if code != emptyCode {
s.db.TrieDB().Reference(code, parent)
}
return nil
})
log.Debug("Trie cache stats after commit", "misses", trie.CacheMisses(), "unloads", trie.CacheUnloads())
return root, err
}

最终通过 core/state/database.go 中的 Commit 实现。

1
2
3
4
5
6
7
func (m cachedTrie) Commit(onleaf trie.LeafCallback) (common.Hash, error) {
root, err := m.SecureTrie.Commit(onleaf)
if err == nil {
m.db.pushTrie(m.SecureTrie)
}
return root, err
}

IntermediateRoot 获取当前世界状态哈希

还记得我们在 go-ethereum 源码笔记(trie 模块-MPT 的实现) 提到 stateTrie 比较特别,交易执行时,stateTrie 一直在变化,前文中提到,交易执行的入口函数 StateProcessor.Process() 会调用 EngineFinalize() 方法,而 Finalize 方法最终会调用 IntermediateRoot 方法获取世界状态的当前 root 值并返回给 header.Root 进行赋值。

1
2
3
4
func (s *StateDB) IntermediateRoot(deleteEmptyObjects bool) common.Hash {
s.Finalise(deleteEmptyObjects)
return s.trie.Hash()
}

前面提到这个方法会在交易执行的过程中被调用,返回的哈希值会被存到交易收据树里。

snapshot 快照功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (self *StateDB) Snapshot() int {
id := self.nextRevisionId
self.nextRevisionId++
self.validRevisions = append(self.validRevisions, revision{id, self.journal.length()})
return id
}

func (self *StateDB) RevertToSnapshot(revid int) {
idx := sort.Search(len(self.validRevisions), func(i int) bool {
return self.validRevisions[i].id >= revid
})
if idx == len(self.validRevisions) || self.validRevisions[idx].id != revid {
panic(fmt.Errorf("revision id %v cannot be reverted", revid))
}
snapshot := self.validRevisions[idx].journalIndex

self.journal.revert(self, snapshot)
self.validRevisions = self.validRevisions[:idx]
}

Snapshot 方法用来创建当前世界状态的快照,RevertToSnapshot 可以根据快照 id 进行回滚,这个功能通过 core/state/journal.gorevert 实现。

1
2
3
4
5
6
7
8
9
10
11
12
func (j *journal) revert(statedb *StateDB, snapshot int) {
for i := len(j.entries) - 1; i >= snapshot; i-- {
j.entries[i].revert(statedb)

if addr := j.entries[i].dirtied(); addr != nil {
if j.dirties[*addr]--; j.dirties[*addr] == 0 {
delete(j.dirties, *addr)
}
}
}
j.entries = j.entries[:snapshot]
}

References