通过前面的文章,我们了解了 geth 的大致原理,知道了区块链是怎么组织构成的,有了 rlp,MPT 这些数据结构的基础,也知道了账户是怎么组织的,交易是如何管理的,挖矿是一个什么样的流程,这一篇我们将深入到交易的具体处理过程,看看交易过程中是如何修改世界状态,生成交易收据的。
这篇文章将涉及到 core/types/transaction.go
, core/state
模块,需要对交易池,账户等基本概念有所了解。
core/state
包提供了用户和合约的状态管理的功能。包括 trie,数据库,cache,日志和回滚相关功能。还记得go-ethereum 源码笔记(概览) 中曾给出的总体架构图吗?
按照定义,日志应该属于交易树的部分,但 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
首先会调用 preCheck
,preCheck
的作用是检测 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.go
,core/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.go
中 AddBalance
方法为:
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 }
|
还记得我们在 go-ethereum 源码笔记(trie 模块-MPT 的实现) 提到 stateTrie
比较特别,交易执行时,stateTrie
一直在变化,前文中提到,交易执行的入口函数 StateProcessor.Process()
会调用 Engine
的 Finalize()
方法,而 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.go
的 revert
实现。
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