【以太坊源碼】I.區塊和交易,合約和虛擬機

轉載自:https://blog.csdn.net/teaspring/article/details/75389151

最近在看以太坊(Ethereum)的源代碼, 初初看出點眉目。 區塊鏈是近年熱點之一,面向大衆讀者介紹概念的文章無數,有興趣的朋友可自行搜索。我會從源代碼實現入手,較系統的介紹一下以太坊的系統設計和協議實現等,希望能提供有一定深度的內容,歡迎有興趣的朋友多多討論。

注:1.源代碼在github上, 分C++和Golang兩個版本,這裏我選擇的是Go語言版(github.com/ethereum/go-ethereum),以下文中提到的Ethereum 代碼部分,如無特別說明,均指go-ethereum; 2.github 主幹代碼還在持續更新中,所以此文中摘錄的代碼將來可能會跟讀者的本地版本有所不同,如有差異我會作相應修改。

1. 基本概念

1.1 SHA-3哈希加密,RLP編碼

Ethereum 代碼裏哈希(hash)無處不在,許許多多的類型對象通過給定的哈希算法,可以得到一個哈希值。注意,算法中所使用的哈希函數是不可逆的,即對於h = hash(x), 僅僅通過哈希運算的結果h 無法作逆運算得到輸入x。哈希值在數學上的唯一性使得它可以用作某個對象的全局唯一標識符。

Ethereum 中用到的哈希函數全部採用SHA-3(Secure Hash Algorithm 3,wikipedia)。SHA-3在2015年8月由美國標準技術協會(NIST)正式發佈,作爲Secure Hash Algorithm家族的最新一代標準,它相比於SHA-2和SHA-1,採用了完全不同的設計思路,性能也比較好。需要注意的是,SHA-2目前並沒有出現被成功攻克的案例,SHA-3也沒有要立即取代SHA-2的趨勢,NIST只是考慮到SHA-1有過被攻克的案例,未雨綢繆的徵選了採用全新結構和思路的SHA-3來作爲一種最新的SHA方案。

RLP(Recursive Length Prefix)編碼,其定義可見wiki,它可以將一個任意嵌套的字節數組([]byte),編碼成一個“展平”無嵌套的[]byte。1 byte取值範圍0x00 ~ 0xff,可以表示任意字符,所以[]byte可以線性的表示任意的數據。最簡單比如一個字符串,如果每個字符用ASCII碼的二進制表示,整個字符串就變成一個[]byte。 RLP 編碼其實提供了一種序列化的編碼方法,無論輸入是何種嵌套形式的元素或數組,編碼輸出形式都是[]byte。RLP是可逆的,它提供了互逆的編碼、解碼方法。

Ethereum 中具體使用的哈希算法,就是對某個類型對象的RLP編碼值做了SHA3哈希運算,可稱爲RLP Hash。 Ethereum 在底層存儲中特意選擇了專門存儲和讀取[k, v] 鍵值對的第三方數據庫,[k, v] 中的v 就是某個結構體對象的RLP編碼值([]byte),k大多數情況就是v的RLP編碼後的SHA-3哈希值

1.2 常用數據類型 哈希值和地址

兩個最常用的自定義數據類型common.Hash用來表示哈希值,common.Address表示地址

  1. # /commons/types.go
  2. const (
  3. HashLength = 32
  4. AddressLength = 20
  5. )
  6. type Hash [HashLength]byte
  7. type Address [AddressLength]byte

在Ethereum 代碼裏,所有用到的哈希值,都使用該Hash類型,長度爲32bytes,即256 bits;Ethereum 中所有跟帳號(Account)相關的信息,比如交易轉帳的轉出帳號(地址)和轉入帳號(地址),都會用該Address類型表示,長度20bytes。

big.Int是golang提供的數據類型,用來處理比較大的整型數,當然它也可以處理諸如64bit,32bit的常用整數。

  1. # /go-1.x/src/math/big/int.go
  2. package big
  3. type Int struct {
  4. neg bool // sign, whether negaive
  5. abs nat // absolute value of integer
  6. }

big.Int是一個結構體(struct),相當於C++中的class,所以每次新建big.Int時可以用 x := new(big.Int), 返回一個指針。注意對Int的算術操作,要使用該對象的成員函數,比如Add():

func (z *Int) Add(x, y *Int) *Int   // Add sets z to sum x+y and returns z

 Ethereum 代碼中, 很多整型變量的類型都選用big.Int,比如Gas和Ether。

1.3 汽油(Gas)和以太幣(Ether)

Gas, 是Ethereum裏對所有活動進行消耗資源計量的單位。這裏的活動是泛化的概念,包括但不限於:轉帳,合約的創建,合約指令的執行,執行中內存的擴展等等。所以Gas可以想象成現實中的汽油或者燃氣。

Ether, 是Ethereum世界中使用的數字貨幣,也就是常說的以太幣。如果某個帳號,Address A想要發起一個交易,比如一次簡單的轉帳,即向 Address B 發送一筆金額H,那麼Address A 本身擁有的Ether,除了轉帳的數額H之外,還要有額外一筆金額用以支付交易所耗費的Gas。

如果可以實現Gas和Ether之間的換算,那麼Ethereum系統裏所有的活動,都可以用Ether來計量。這樣,Ether就有了點一般等價物,也就是貨幣的樣子。

1.4 區塊是交易的集合

區塊(Block)是Ethereum的核心結構體之一。在整個區塊鏈(BlockChain)中,一個個Block是以單向鏈表的形式相互關聯起來的。Block中帶有一個Header(指針), Header結構體帶有Block的所有屬性信息,其中的ParentHash 表示該區塊的父區塊哈希值, 亦即Block之間關聯起來的前向指針。只不過要想得到父區塊(parentBlock)對象,直接解析這個ParentHash是不夠的, 而是要將ParentHash同其他字符串([]byte)組合成合適的key([]byte), 去kv數據庫裏查詢相應的value才能解析得到。 Block和Header的部分成員變量定義如下:

  1. # /core/types/block.go
  2. type Block struct {
  3. header *Header
  4.     transactions Transactions  // type Transactions []*Transaction
  5. ...
  6. }
  7. type Header struct {
  8.     ParentHash common.Hash
  9.     Number *big.Int
  10.     ...
  11. }

Header的整型成員Number表示該區塊在整個區塊鏈(BlockChain)中所處的位置,每一個區塊相對於它的父區塊,其Number值是+1。這樣,整個區塊鏈會存在一個原始區塊,即創世塊(GenesisBlock), 它的Number是0,由系統自然生成而不必去額外挖掘(mine)。Block和BlockChain的實現細節,之後會有更詳細的討論。

Block中還有一個Tranction(指針)數組,這是我們這裏關注的。Transaction(簡稱tx),是Ethereum裏標示一次交易的結構體, 它的成員變量包括轉帳金額,轉入方地址等等信息。Transaction的完整聲明如下:

  1. # /core/types/transaction.go
  2. type Transaction struct {
  3. data txdata
  4. hash, size, from atomic.Value // for cache
  5. }
  6. type txdata struct {
  7. AccountNonce uint64
  8. Price *big.Int
  9. GasLimit *big.Int
  10. Recipient *common.Address
  11. Amount *big.Int
  12. Payload []byte
  13. V, R, S *big.Int // for signature
  14. Hash *common.Hash // for marshaling
  15. }
每個tx都聲明瞭自己的(Gas)Price 和 GasLimit。 Price指的是單位Gas消耗所折抵的Ether多少,它的高低意味着執行這個tx有多麼昂貴。GasLimit 是該tx執行過程中所允許消耗資源的總上限,通過這個值,我們可以防止某個tx執行中出現惡意佔用資源的問題,這也是Ethereum中有關安全保護的策略之一。擁有獨立的Price和GasLimit, 也意味着每個tx之間都是相互獨立的。

轉帳轉入方地址Recipient可能爲空(nil),這時在後續執行tx過程中,Ethereum 需要創建一個地址來完成這筆轉帳。Payload是重要的數據成員,它既可以作爲所創建合約的指令數組,其中每一個byte作爲一個單獨的虛擬機指令;也可以作爲數據數組,由合約指令進行操作。合約由以太坊虛擬機(Ethereum Virtual Machine, EVM)創建並執行。

細心的朋友在這裏會有個疑問,爲何交易的定義裏沒有聲明轉帳的轉出方地址? 問的好,tx 的轉帳轉出方地址確實沒有如轉入方一樣被顯式的聲明出來,而是被加密隱藏起來了,在Ethereum裏這個轉出方地址是機密,不能直接暴露。這個對tx加密的環節,在Ethereum裏被稱爲簽名(sign), 關於它的實現細節容後再述。

2. 交易的執行

Block 類型的基本目的之一,就是爲了執行交易。狹義的交易可能僅僅是一筆轉帳,而廣義的交易同時還會支持許多其他的意圖。Ethereum 中採用的是廣義交易概念。按照其架構設計,交易的執行可大致分爲內外兩層結構第一層是虛擬機外,包括執行前將Transaction類型轉化成Message,創建虛擬機(EVM)對象,計算一些Gas消耗,以及執行交易完畢後創建收據(Receipt)對象並返回等;第二層是虛擬機內,包括執行轉帳,和創建合約並執行合約的指令數組。

2.1 虛擬機外

2.1.1 入口和返回值

執行tx的入口函數是StateProcessor的Process()函數,其實現代碼如下:

  1. # /core/state_processor.go
  2. func (p *StateProcessor) Process(block *Block, statedb *StateDB, cfg vm.Config) (types.Receipts, []*types.Log, *big.Int, error) {
  3. var {
  4. receipts types.Receipts
  5. totalUsedGas = big.NewInt(0)
  6. header = block.Header()
  7. allLogs  []*types.Log
  8. gp = new(GasPool).AddGas(block.GasLimit())
  9. }
  10. ...
  11. for i, tx := range block.Transactions() {
  12. statedb.Prepare(tx.Hash(), block.Hash(), i)
  13. receipt, _, err := ApplyTransaction(p.config, p.bc, author:nil, gp, statedb, header, tx, totalUsedGas, cfg)
  14. if err != nil { return nil, nil, nil, err}
  15. receipts = append(receipts, receipt)
  16. allLogs = append(allLogs, receipt.Logs...)
  17. }
  18. p.engine.Finalize(p.bc, header, statedb, block.Transactions(), block.Uncles(), receipts)
  19. return receipts, allLogs, totalUsedGas, nil
  20. }

GasPool 類型其實就是big.Int。在一個Block的處理過程(即其所有tx的執行過程)中,GasPool 的值能夠告訴你,剩下還有多少Gas可以使用。在每一個tx執行過程中,Ethereum 還設計了償退(refund)環節,所償退的Gas數量也會加到這個GasPool裏。

Process()函數的核心是一個for循環,它將Block裏的所有tx逐個遍歷執行。具體的執行函數叫ApplyTransaction(),它每次執行tx, 會返回一個收據(Receipt)對象。Receipt結構體的聲明如下:


Receipt 中有一個Log類型的數組,其中每一個Log對象記錄了Tx中一小步的操作。所以,每一個tx的執行結果一個Receipt對象來表示;更詳細的內容,由一組Log對象來記錄。這個Log數組很重要,比如在不同Ethereum節點(Node)的相互同步過程中,待同步區塊的Log數組有助於驗證同步中收到的block是否正確和完整,所以會被單獨同步(傳輸)。

Receipt的PostState保存了創建該Receipt對象時,整個Block內所有“帳戶”的當時狀態。Ethereum 裏用stateObject來表示一個賬戶Account,這個賬戶可轉帳(transfer value), 可執行tx, 它的唯一標示符是一個Address類型變量。 這個Receipt.PostState 就是當時所在Block裏所有stateObject對象的RLP Hash值。

Bloom類型是一個Ethereum內部實現的一個256bit長Bloom Filter。 Bloom Filter概念定義可見wikipedia,它可用來快速驗證一個新收到的對象是否處於一個已知的大量對象集合之中。這裏Receipt的Bloom,被用以驗證某個給定的Log是否處於Receipt已有的Log數組中。

2.1.2 消耗Gas,亦獎勵Gas

我們來看下StateProcessor.ApplyTransaction()的具體實現,它的基本流程如下圖:


ApplyTransaction()首先根據輸入參數分別封裝出一個Message對象和一個EVM對象,然後加上一個傳入的GasPool類型變量,由TransitionDb()函數完成tx的執行,待TransitionDb()返回之後,創建一個收據Receipt對象,最後返回該Recetip對象,以及整個tx執行過程所消耗Gas數量。

GasPool對象是在一個Block執行開始時創建,並在該Block內所有tx的執行過程中共享,對於一個tx的執行可視爲“全局”存儲對象; Message由此次待執行的tx對象轉化而來,並攜帶了解析出的tx的(轉帳)轉出方地址,屬於待處理的數據對象;EVM 作爲Ethereum世界裏的虛擬機(Virtual Machine),作爲此次tx的實際執行者,完成轉帳和合約(Contract)的相關操作。

我們來細看下TransitioinDb()的執行過程(/core/state_transition.go)。假設有StateTransition對象st,  其成員變量initialGas表示初始可用Gas數量,gas表示即時可用Gas數量,初始值均爲0,於是st.TransitionDb() 可由以下步驟展開:

  1. 購買Gas。首先從交易的(轉帳)轉出方賬戶扣除一筆Ether,費用等於tx.data.GasLimit * tx.data.Price;同時 st.initialGas = st.gas = tx.data.GasLimit;然後(GasPool) gp -= st.gas。
  2. 計算tx的固有Gas消耗 - intrinsicGas。它分爲兩個部分,每一個tx預設的消耗量,這個消耗量還因tx是否含有(轉帳)轉入方地址而略有不同;以及針對tx.data.Payload的Gas消耗,Payload類型是[]byte,關於它的固有消耗依賴於[]byte中非0字節和0字節的長度。最終,st.gas -= intrinsicGas
  3. EVM執行。如果交易的(轉帳)轉入方地址(tx.data.Recipient)爲空,調用EVM的Create()函數;否則,調用Call()函數。無論哪個函數返回後,更新st.gas。
  4. 計算本次執行交易的實際Gas消耗: requiredGas = st.initialGas - st.gas
  5. 償退Gas。它包括兩個部分:首先將剩餘st.gas 折算成Ether,歸還給交易的(轉帳)轉出方賬戶;然後,基於實際消耗量requiredGas,系統提供一定的補償,數量爲refundGas。refundGas 所折算的Ether會被立即加在(轉帳)轉出方賬戶上,同時st.gas += refundGas,gp += st.gas,即剩餘的Gas加上系統補償的Gas,被一起歸併進GasPool,供之後的交易執行使用。
  6. 獎勵所屬區塊的挖掘者:系統給所屬區塊的作者,亦即挖掘者賬戶,增加一筆金額,數額等於 st.data,Price * (st.initialGas - st.gas)。注意,這裏的st.gas在步驟5中被加上了refundGas, 所以這筆獎勵金所對應的Gas,其數量小於該交易實際消耗量requiredGas。

由上可見,除了步驟3中EVM 函數的執行,其他每個步驟都在圍繞着Gas消耗量作文章(EVM 虛擬機的運行原理容後再述)。到這裏,大家可以對Gas在以太坊系統裏的作用有個初步概念,Gas就是Ethereum系統中的血液。

步驟5的償退機制很有意思,設立它的目的何在?目前爲止我只能理解它可以避免交易執行過程中過快消耗Gas,至於對其全面準確的理解尚需時日。

步驟6就更有趣了,正是這個獎勵機制的存在纔會吸引社會上的礦工(miner)去賣力“挖礦”(mining)。越大的運算能力帶來越多的的區塊(交易)產出,礦工也就能通過該獎勵機制賺取越多的以太幣。

2.1.3 交易的數字簽名

Ethereum 中每個交易(transaction,tx)對象在被放進block時,都是經過數字簽名的,這樣可以在後續傳輸和處理中隨時驗證tx是否經過篡改。Ethereum 採用的數字簽名是橢圓曲線數字簽名算法(Elliptic Cure Digital Signature Algorithm,ECDSA)。ECDSA 相比於基於大質數分解的RSA數字簽名算法,可以在提供相同安全級別(in bits)的同時,僅需更短的公鑰(public key)。關於ECDSA的算法理論和實現細節,本系列會有另外一篇文章專門加以介紹。這裏需要特別留意的是,tx的轉帳轉出方地址,就是對該tx對象作ECDSA簽名計算時所用的公鑰publicKey

Ethereum中的數字簽名計算過程所生成的簽名(signature), 是一個長度爲65bytes的字節數組,它被截成三段放進tx中,前32bytes賦值給成員變量R, 再32bytes賦值給S,末1byte賦給V,當然由於R、S、V聲明的類型都是*big.Int, 上述賦值存在[]byte -> big.Int的類型轉換。


當需要恢復出tx對象的轉帳轉出方地址時(比如在需要執行該交易時),Ethereum 會先從tx的signature中恢復出公鑰,再將公鑰轉化成一個common.Address類型的地址,signature由tx對象的三個成員變量R,S,V轉化成字節數組[]byte後拼接得到。

Ethereum 對此定義了一個接口Signer, 用來執行掛載簽名,恢復公鑰,對tx對象做哈希等操作。

  1. // core/types/transaction_signing.go
  2. type Signer innterface {
  3.     Sender(tx *Transaction) (common.Address, error)
  4.     SignatureValues(tx *Transaction, sig []byte) (r, s, v *big.Int, err error)
  5.     Hash(tx *Transaction) common.Hash
  6.     Equal(Signer) bool
  7. }

生成數字簽名的函數叫SignTx(),它會先調用其他函數生成signature, 然後調用tx.WithSignature()將signature分段賦值給tx的成員變量R,S,V。

func SignTx(tx *Transaction, s Signer, prv *ecdsa.PrivateKey) (*Transaction, error)

恢復出轉出方地址的函數叫Sender(), 參數包括一個Signer, 一個Transaction,代碼如下:

  1. func Sender(signer Signer, tx *Transaction) (common.Address, error) {
  2. if sc := tx.from().Load(); sc != null {
  3. sigCache := sc.(sigCache)// cache exists,
  4. if sigCache.signer.Equal(signer) {
  5. return sigCache.from, nil
  6. }
  7. }
  8. addr, err := signer.Sender(tx)
  9.     if err != nil {
  10.         return common.Address{}, err
  11.     }
  12. tx.from.Store(sigCache{signer: signer, from: addr}) // cache it
  13. return addr, nil
  14. }
Sender()函數體中,signer.Sender()會從本次數字簽名的簽名字符串(signature)中恢復出公鑰,並轉化爲tx的(轉帳)轉出方地址。

在上文提到的ApplyTransaction()實現中,Transaction對象需要首先被轉化成Message接口,用到的AsMessage()函數即調用了此處的Sender()。

  1. // core/types/transaction.go
  2. func (tx *Transaction) AsMessage(s Signer) (Message,error) {
  3. msg := Message{
  4. price: new(big.Int).Set(tx.data.price)
  5. gasLimit: new(big.Int).Set(tx.data.GasLimit)
  6. ...
  7. }
  8. var err error
  9. msg.from, err = Sender(s, tx)
  10. return msg, err
  11. }

在Transaction對象tx的轉帳轉出方地址被解析出以後,tx 就被完全轉換成了Message類型,可以提供給虛擬機EVM執行了。

2.2 虛擬機內

每個交易(Transaction)帶有兩部分內容需要執行:1. 轉帳,由轉出方地址向轉入方地址轉帳一筆以太幣Ether; 2. 攜帶的[]byte類型成員變量Payload,其每一個byte都對應了一個單獨虛擬機指令。這些內容都是由EVM(Ethereum Virtual Machine)對象來完成的。EVM 結構體是Ethereum虛擬機機制的核心,它與協同類的UML關係圖如下:


其中Context結構體分別攜帶了Transaction的信息(GasPrice, GasLimit),Block的信息(Number, Difficulty),以及轉帳函數等,提供給EVM;StateDB 接口是針對state.StateDB 結構體設計的本地行爲接口,可爲EVM提供statedb的相關操作; Interpreter結構體作爲解釋器,用來解釋執行EVM中合約(Contract)的指令(Code)。

注意,EVM 中定義的成員變量Context和StateDB, 僅僅聲明瞭變量名而無類型,而變量名同時又是其類型名,在Golang中,這種方式意味着宗主結構體可以直接調用該成員變量的所有方法和成員變量,比如EVM調用Context中的Transfer()。

2.2.1 完成轉帳

交易的轉帳操作由Context對象中的TransferFunc類型函數來實現,類似的函數類型,還有CanTransferFunc, 和GetHashFunc。

  1. // core/vm/evm.go
  2. type {
  3.     CanTransferFunc func(StateDB, common.Address, *big.Int)
  4.     TransferFunc func(StateDB, common.Address, common.Address, *big.Int)
  5.     GetHashFunc func(uint64) common.Hash
  6. }

這三個類型的函數變量CanTransfer, Transfer, GetHash,在Context初始化時從外部傳入,目前使用的均是一個本地實現:

  1. // core/evm.go
  2. func NewEVMContext(msg Message, header *Header, chain ChainContext, author *Address){
  3.     return vm.Context {
  4.         CanTransfer: CanTransfer,
  5.         Transfer: Transfer,
  6.         GetHash: GetHash(header, chain),
  7.         ...
  8.     }
  9. }
  10. func CanTransfer(db vm.StateDB, addr common.Address, amount *big.Int) {
  11.     return db.GetBalance(addr).Cmp(amount) >= 0
  12. }
  13. func Transfer(db vm.StateDB, sender, recipient common.Address, amount *big.Int) {
  14. db.SubBalance(sender, amount)
  15. db.AddBalance(recipient, amount)
  16. }
可見目前的轉帳函數Transfer()的邏輯非常簡單,轉帳的轉出賬戶減掉一筆以太幣,轉入賬戶加上一筆以太幣。由於EVM調用的Transfer()函數實現完全由Context提供,所以,假設如果基於Ethereum平臺開發,需要設計一種全新的“轉帳”模式,那麼只需寫一個新的Transfer()函數實現,在Context初始化時賦值即可。

有朋友或許會問,這裏Transfer()函數中對轉出和轉入賬戶的操作會立即生效麼?萬一兩步操作之間有錯誤發生怎麼辦?答案是不會立即生效。StateDB 並不是真正的數據庫,只是一行爲類似數據庫的結構體。它在內部以Trie的數據結構來管理各個基於地址的賬戶,可以理解成一個cache;當該賬戶的信息有變化時,變化先存儲在Trie中。僅當整個Block要被插入到BlockChain時,StateDB 裏緩存的所有賬戶的所有改動,纔會被真正的提交到底層數據庫。

2.2.2 合約的創建和賦值

合約(Contract)是EVM用來執行(虛擬機)指令的結構體。先來看下Contract的定義:

  1. // core/vm/contract.go
  2. type ContractRef interface {
  3.     Address() common.Address
  4. }
  5. type Contract struct {
  6. CallerAddress common.Address
  7. caller ContractRef
  8. self ContractRef
  9. jumpdests destinations
  10. Code []byte
  11. CodeHash common.Hash
  12. CodeAddr *Address
  13. Input []byte
  14. Gas uint64
  15. value *big.Int
  16. Args []byte
  17. DelegateCall bool
  18. }
在這些成員變量裏,caller是轉帳轉出方地址(賬戶),self是轉入方地址,不過它們的類型都用接口ContractRef來表示;Code是指令數組,其中每一個byte都對應於一個預定義的虛擬機指令;CodeHash 是Code的RLP哈希值;Input是數據數組,是指令所操作的數據集合;Args 是參數。

有意思的是self這個變量,爲什麼轉入方地址要被命名成self呢? Contract實現了ContractRef接口,返回的恰恰就是這個self地址。

  1. func (c *Contract) Address() common.Address {
  2. return c.self.Address()
  3. }

所以當Contract對象作爲一個ContractRef接口出現時,它返回的地址就是它的self地址。那什麼時候Contract會被類型轉換成ContractRef呢?當Contract A調用另一個Contract B時,A就會作爲B的caller成員變量出現。Contract可以調用Contract,這就爲系統在業務上的潛在擴展,提供了空間。

創建一個Contract對象時,重點關注對self的初始化,以及對Code, CodeAddr 和Input的賦值。 

另外,StateDB 提供方法SetCode(),可以將指令數組Code存儲在某個stateObject對象中; 方法GetCode(),可以從某個stateObject對象中讀取已有的指令數組Code。

  1. func (self *StateDB) SetCode(addr common.Address, code []byte)
  2. func (self *StateDB) GetCode(addr common.Address) code []byte

stateObject 是Ethereum裏用來管理一個賬戶所有信息修改的結構體,它以一個Address類型變量爲唯一標示符。StateDB 在內部用一個巨大的map結構來管理這些stateObject對象。所有賬戶信息-包括Ether餘額,指令數組Code, 該賬戶發起合約次數nonce等-它們發生的所有變化,會首先緩存到StateDB裏的某個stateObject裏,然後在合適的時候,被StateDB一起提交到底層數據庫。注意,一個Contract所對應的stateObject的地址,是Contract的self地址,也就是轉帳的轉入方地址

EVM 目前有五個函數可以創建並執行Contract,按照作用和調用方式,可以分成兩類:

  • Create(), Call(): 二者均在StateProcessor的ApplyTransaction()被調用以執行單個交易,並且都有調用轉帳函數完成轉帳
  • CallCode(), DelegateCall(), StaticCall():三者由於分別對應於不同的虛擬機指令(1 byte)操作,不會用以執行單個交易,也都不能處理轉帳

考慮到與執行交易的相關性,這裏着重探討Create()和Call()。先來看Call(),它用來處理(轉帳)轉入方地址不爲空的情況:


Call()函數的邏輯可以簡單分爲以上6步。其中步驟(3)調用了轉帳函數Transfer(),轉入賬戶caller, 轉出賬戶addr;步驟(4)創建一個Contract對象,並初始化其成員變量caller, self(addr), value和gas; 步驟(5)賦值Contract對象的Code, CodeHash, CodeAddr成員變量;步驟(6) 調用run()函數執行該合約的指令,最後Call()函數返回。相關代碼可見:

  1. // core/vm/evm.go
  2. func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftGas *big.Int, error){
  3. ...
  4.     var snapshot = evm.StateDB.Snapshot()
  5.     contract.SetCallCode(&addr, evm.StateDB.GetCodeHash(addr), evm.StateDB.GetCode(addr))
  6.     ret, err = run(evm, snapshot, contract, input)
  7.     return ret, contract.Gas, err
  8. }
因爲此時(轉帳)轉入地址不爲空,所以直接將入參addr初始化Contract對象的self地址,並可從StateDB中(其實是以addr標識的賬戶stateObject對象)讀取出相關的Code和CodeHash並賦值給contract的成員變量。注意,此時轉入方地址參數addr同時亦被賦值予contract.CodeAddr。

再來看看EVM.Create(),它用來處理(轉帳)轉入方地址爲空的情況。


與Call()相比,Create()因爲沒有Address類型的入參addr,其流程有幾處明顯不同:

  • 步驟(3)中創建一個新地址contractAddr,作爲(轉帳)轉入方地址,亦作爲Contract的self地址;
  • 步驟(6)由於contracrAddr剛剛新建,db中尚無與該地址相關的Code信息,所以會將類型爲[]byte的入參code,賦值予Contract對象的Code成員;
  • 步驟(8)將本次執行合約的返回結果,作爲contractAddr所對應賬戶(stateObject對象)的Code儲存起來,以備下次調用。

還有一點隱藏的比較深,Call()有一個入參input類型爲[]byte,而Create()有一個入參code類型同樣爲[]byte,沒有入參input,它們之間有無關係?其實,它們來源都是Transaction對象tx的成員變量Payload!調用EVM.Create()或Call()的入口在StateTransition.TransitionDb()中,當tx.Recipent爲空時,tx.data.Payload 被當作所創建Contract的Code;當tx.Recipient 不爲空時,tx.data.Payload 被當作Contract的Input。

2.2.3 預編譯的合約

EVM中執行合約(指令)的函數是run(),其實現代碼如下:

  1. // core/vm/evm.go
  2. func run(evm *EVM, snapshot int, contract *Contract, input []byte) ([]byte, error) {
  3. if contract.CodeAddr != nil {
  4. precompiles := PrecompiledContractsHomestead
  5.         ...
  6. if p := precompiles[*contract.CodeAddr]; p != nil {
  7. return RunPrecompiledContract(p, input, contract)
  8. }
  9. }
  10. return evm.interpreter.Run(snapshot, contract, input)
  11. }

可見如果待執行的Contract對象恰好屬於一組預編譯的合約集合-此時以指令地址CodeAddr爲匹配項-那麼它可以直接運行;沒有經過預編譯的Contract,纔會由Interpreter解釋執行。這裏的"預編譯",可理解爲不需要編譯(解釋)指令(Code)。預編譯的合約,其邏輯全部固定且已知,所以執行中不再需要Code,僅需Input即可。

在代碼實現中,預編譯合約只需實現兩個方法Required()和Run()即可,這兩方法僅需一個入參input。

  1. // core/vm/contracts.go
  2. type PrecompiledContract interface {
  3. RequiredGas(input []byte) uint64
  4. Run(input []byte) ([]byte, error)
  5. }
  6. func RunPrecompiledContract(p PrecompiledContract, input []byte, contract *Contract) (ret []byte, err error) {
  7. gas := p.RequiredGas(input)
  8. if contract.UseGas(gas) {
  9. return p.Run(input)
  10. }
  11. return nil, ErrOutOfGas
  12. }
目前,Ethereuem 代碼中已經加入了多個預編譯合約,功能覆蓋了包括橢圓曲線密鑰恢復,SHA-3(256bits)哈希算法,RIPEMD-160加密算法等等。相信基於自身業務的需求,二次開發者完全可以加入自己的預編譯合約,大大加快合約的執行速度。

2.2.4 解釋器執行合約的指令

解釋器Interpreter用來執行(非預編譯的)合約指令。它的結構體UML關係圖如下所示:


Interpreter結構體通過一個Config類型的成員變量,間接持有一個包括256個operation對象在內的數組JumpTable。operation是做什麼的呢?每個operation對象正對應一個已定義的虛擬機指令,它所含有的四個函數變量execute, gasCost, validateStack, memorySize 提供了這個虛擬機指令所代表的所有操作。每個指令長度1byte,Contract對象的成員變量Code類型爲[]byte,就是這些虛擬機指令的任意集合。operation對象的函數操作,主要會用到Stack,Memory, IntPool 這幾個自定義的數據結構。

這樣一來,Interpreter的Run()函數就很好理解了,其核心流程就是逐個byte遍歷入參Contract對象的Code變量,將其解釋爲一個已知的operation,然後依次調用該operation對象的四個函數,流程示意圖如下:


operation在操作過程中,會需要幾個數據結構: Stack,實現了標準容器 -棧的行爲;Memory,一個字節數組,可表示線性排列的任意數據;還有一個intPool,提供對big.Int數據的存儲和讀取。

已定義的operation,種類很豐富,包括:

  • 算術運算:ADD,MUL,SUB,DIV,SDIV,MOD,SMOD,EXP...;
  • 邏輯運算:LT,GT,EQ,ISZERO,AND,XOR,OR,NOT...;
  • 業務功能:SHA3,ADDRESS,BALANCE,ORIGIN,CALLER,GASPRICE,LOG1,LOG2...等等

需要特別注意的是LOGn指令操作,它用來創建n個Log對象,這裏n最大是4。還記得Log在何時被用到麼?每個交易(Transaction,tx)執行完成後,會創建一個Receipt對象用來記錄這個交易的執行結果。Receipt攜帶一個Log數組,用來記錄tx操作過程中的所有變動細節,而這些Log,正是通過合適的LOGn指令-即合約指令數組(Contract.Code)中的單個byte,在其對應的operation裏被創建出來的。每個新創建的Log對象被緩存在StateDB中的相對應的stateObject裏,待需要時從StateDB中讀取。

3. 小結

以太坊的出現大大晚於比特幣,雖然明顯受到比特幣系統的啓發,但在整個功能定位和設計架構上卻做了很多更廣更深的思考和嘗試。以太坊更像是一個經濟活動平臺,而並不侷限一種去中心化數字代幣的產生,分發和流轉。本文從交易執行的角度切入以太坊的系統實現,希望能提供一點管中窺豹的作用。

  • Gas是Ethereum系統的血液。一切資源,活動,交互的開銷,都以Gas爲計量單元。如果定義了一個GasPrice,那麼所有的Gas消耗亦可等價於以太幣Ether。
  • Block是Transaction的集合。Block在插入BlockChain前,需要將所有Transaction逐個執行。Transaction的執行會消耗發起方的Ether,但系統在其執行完成時,會給予其作者(挖掘出這個Block的賬戶)一筆補償,這筆補償是“礦工”賺取收入的來源之一。
  • Ethereum 定義了自己的虛擬機EVM, 它與合約(Contract)機制相結合,能夠在提供非常豐富的操作的同時,又能很好的控制存儲空間和運行速度。Contract由Transaction轉化得到。
  • Ethereum 裏的哈希函數,用的是SHA-3,256 bits;數據(數組)的序列化,用的是RLP編碼,所以所有對象,數組的哈希算法,實際用的RLP + SHA-3。數字簽名算法,使用了橢圓曲線數字簽名算法(ECDSA)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章