1. 程式人生 > >[以太坊原始碼分析] V. 從錢包到客戶端

[以太坊原始碼分析] V. 從錢包到客戶端

以太坊作為一種數字貨幣以太幣的執行系統,顯然它也會有類似於錢包的客戶端程式,用來提供管理賬戶餘額等功能。我們知道,存放(或者繫結,掛靠)以太幣的賬戶,在程式碼中以Address型別變數存在,所以能夠管理多個以太坊賬戶應該屬於客戶端程式基本功能之一。本文會從管理賬戶資訊的程式碼包開始,自底向上的介紹以太坊客戶端程式的一些主要模組。

1. 管理賬戶資訊的程式碼包accounts

在以太坊原始碼的accounts程式碼包中,呈現賬戶地址的最小結構體叫Account{},它的主要成員就是一個common.Address型別變數;管理Account的介面類叫Wallet,類如其名,<Wallet>聲明瞭諸如快取Account物件及解析Account物件等操作,管理多個<Wallet>物件的結構體叫Manager,這些型別的UML關係如下圖所示:


在accounts程式碼包內部的各種結構體/介面中,accounts.Manager在相互呼叫關係上無疑是處於頂端的,它本身是公共類,向外暴露包括查詢單個Account,返回單個或多個Wallet物件,訂閱Wallet更新事件等方法。在其內部它維持一個Wallet列表,通過每個Wallet實現類持有一組Account賬戶物件,並通過一個event.Feed成員變數來管理所有向它訂閱Wallet更新事件的需求。

Manager訂閱Wallet的更新事件

著重介紹一下這裡的訂閱(subscribe)操作,Manager的Subscribe()函式定義如下:

  1. // /accounts/manager.go  
  2. func (am *Manager) Subscribe(sink chan<- WallletEvent) event.Subscription {  
  3.     return am.feed.Subscribe(sink)  
  4. }  

首先注意這個Subscribe()函式是讓外部呼叫物件 向該Manager作訂閱的操作,事實上該Manager本身也是通過相同的訂閱機制去獲知新添的Wallet物件,它的成員變數updates就是該Manager本身得到所訂閱事件的通道。其次,Manager.Subscribe()函式只有一個chan引數,由於golang語言中channel機制的強大,訂閱操作僅僅需要一個chan物件
就足夠了,真是簡單之極,根本不必知道背後是誰發起了訂閱。儘管如此,這裡依然值得思考的是,究竟是什麼物件向Manager發起了訂閱呢?其實,向某個Manager物件訂閱Wallet更新事件的,正是另外一個Manager物件,也就是<Backend>的實現類。

得出以上這個結論,是很有意義的。後面可以瞭解到,accounts.Manager主要作為eth.Ethereum(或者les.Ethereum)的一個成員存在,而這個eth.Ethereum是以太坊客戶端程式中最主要的部分,它以服務的形式提供幾乎所有以太坊系統執行所需的功能,所以一個以太坊客戶端可視為一個accounts.Manager的存在,那麼真相就是,所有以太坊客戶端之間在通過accouts.Manager相互訂閱Wallet更新事件

除Manager之外,這裡其他幾個重要的結構體還包括:

  • event.Feed{}:它可以管理一對多的訂閱模式,每個呼叫者提供一個chan物件,用以傳送所訂閱的內容。Feed{}處理的訂閱內容是型別泛化的,而每一個Feed{}物件,在其生命週期內,只能處理一種型別的訂閱內容,即向chan物件傳送的value。Feed.Subscribe()方法返回<Subscription>介面的實現體feedSub{},Feed.Subscribe()幫助Manager實現了<Backend>所宣告的方法Subscribe()。在Feed結構體內部,CaseList被用來管理所有訂閱者發過來的chan物件。
  • accounts.Account{}:它的成員除了一個common.Address型別,即20bytes長的地址變數外,還有一個可選成員URL,可以是網址,也可以是本地儲存的路徑+檔案全名。在以網址形式存在時,URL.Scheme就是網路協議名,而作為本地儲存檔案時,URL.Scheme是字串常量”keystore”。
  • accounts.<Wallet>:它很像一般意義上的“錢包”,其管理的多個Account,恰似個人使用者在現實中擁有的多個銀行賬戶,每個Account上的Ether餘額,可從資料庫(core.state.StateDB)中查詢。<Wallet>介面宣告的函式中,尤其需要注意的是SignXXX(),其中SignTx()是對一個Transaction(tx)物件進行數字簽名,SignHash()是對一個Hash值進行數字簽名,由於任何一個物件(只要可序列化)可以作Hash運算,所以這裡SignHash()其實是針對任何一個物件,尤其是Block區塊作數字簽名。

<Wallet>是介面型別,它的實現體包括軟體錢包(keystore.keystoreWallet)和硬體錢包(usbwallet.wallet),注意這裡的硬體錢包是有實物的。<Wallet>之下的程式碼體系對於外部都不是公共的,所有向外暴露的“錢包”物件以及相關更新事件,都是以<Wallet>形式存在。

軟體實現的Wallet - keystore

軟體實現Wallet主要通過本地儲存檔案的方式來管理賬戶地址。同時,<Wallet>物件需要對交易或區塊物件提供數字簽名,這需要用到橢圓曲線數字簽名(ECDSA)中的公鑰+金鑰,而每個公鑰也是某個賬戶地址(Address)的來源,所以我們也需要本地儲存ECDSA的公鑰金鑰資訊。以太坊中這個通過本地儲存檔案的方案實現accounts.<Wallet>功能的機制被成為keystore。

<Wallet>的軟體錢包實現的相關程式碼都處於/accounts/keystore/路徑下,這組程式碼的主要UML關係如下圖:


keystoreWallet{}:它是accounts.<Wallet>的實現類,它有一個Account物件,用來表示自身的地址,並通過Account.URL()方法,來實現上層介面<Wallet>.URL()方法;另外有一個KeyStore{}物件,這是這組程式碼中的核心類。

KeyStore{}:它為keystoreWallet結構體提供所有與Account相關的實質性的資料和操作。KeyStore{}內部有兩個作資料快取用的成員:

  • accountCache型別的成員cache,是所有待查詢的地址資訊(Account{}型別)集合;
  • map[Address]unlocked{}形式的成員unlocked,由於unlocked{}結構體僅僅簡單封裝了Key{}物件(Key{}中顯式含有數字簽名公鑰金鑰對),所以map[]中可通過Address變數查詢到該地址對應的原始公鑰以及金鑰。

另外,KeyStore{}中有一個<keyStore>介面型別的成員storage,用來對儲存在本地檔案中的公鑰資訊Key做操作。

Unlocked{}:公鑰金鑰資料類Key{}的封裝類,其內部成員除了Key{}之外,還提供了一個chan型別變數abort,它會在KeyStore對於公鑰金鑰資訊的管理機制中發揮作用。

Key{}:存放數字簽名公鑰金鑰的資料類,其內部顯式儲存了一個ecdsa.PrivateKey{}型別的成員變數,前文介紹過,Golang原生程式碼包中的ecdsa.PrivateKey{}中含有PublicKey{}型別的成員。而Key{}中同時攜帶Address型別成員變數,也可以避免公鑰向地址型別轉化的操作重複發生。

<keyStore>:這個介面型別聲明瞭操作Key的函式,注意它與KeyStore{}在名字上僅有一個字母大小寫的差異。

keyStorePassphrase{}:<keyStore>介面的實現類,它實現了以Web3 Secret Storage加密方法為公鑰金鑰資訊進行加密管理。

accountCache{}:在記憶體中快取keystore中某個已知路徑下所有Account物件,可提供由Address型別查詢到對應Account物件的操作。

fileCache{}:keystore中可觀察到的檔案的快取,它可對某個路徑下存放的檔案進行掃描,分別返回新增檔案,缺失檔案,改動檔案的集合。

watcher{}:用來監測某個路徑中儲存的賬戶檔案的變化,可以定時呼叫accountCache的方法對檔案進行掃描。

本地檔案顯式儲存賬戶資訊

accountCache快取的帳號資訊,均來自於某個已知路徑下儲存的本地檔案集合。每個檔案都是JSON格式,以顯式存放Address: {Address: “@Address”},所以accountCache在讀取檔案後,可以直接轉化成Account{}物件,在程式碼中使用。這裡以顯式檔案儲存Address資訊沒有任何問題,既不用擔心Address資訊洩露造成危害(無法從Address反向解析出源頭的ECDSA所用公鑰),又可以方便程式碼呼叫。

在使用中,watcher物件會維護一個定時器,不斷的通知accountCache掃描某個給定的路徑;accountCache會呼叫fileCache物件去掃描該路徑下的檔案,並根據fileCache返回的三種檔案集合:新添檔案、缺失檔案、改動檔案,在自身維護的Account集合中作相應操作。

以本地加密檔案儲存公鑰金鑰

Key{}通過ecdsa.PrivateKey物件從而同時攜帶ECDSA所用的公鑰金鑰,所以這裡涉及到公鑰金鑰部分,都是針對Key物件做的操作。keystore機制中,在本地儲存的是經過加密的Key物件的JSON格式,所用的加密方法被稱為Web3 Secret Storage,其實現細節可在ethereum git wiki上找到。下圖是該儲存方式的簡單示意圖:


對一個加密儲存的Key物件做操作時,總共需要三個引數,包括呼叫方提供一個名為passphrase的任意字串,以及keyStorePassphrase{}中給定的兩個整型數scryptN,scryptP,這兩個整型引數在keyStorePassphrase物件生命週期內部是固定不變的,只能在建立時賦值。這樣不管是每次新儲存一個Key物件,還是取出一個已存的Key物件,呼叫方都必須傳入正確的引數passphrase,所以在實際應用中,以太坊錢包的客戶必須自行記憶該字串。實際上,客戶為每個賬戶建立的密碼password,程式中正是這個加密引數passphrase。

取出的公鑰金鑰,在記憶體中限時公開

Key{}物件從加密過的本地檔案中取出後,會被封裝成unlocked{}物件,並被KeyStore放進其map[Address]*unlocked型別成員中。由於公鑰金鑰的重要性,顯然keystore中存有的unlocked物件也應該控制公開時長。對於不同的時限需求,KeyStore{}提供瞭如下兩個函式:

  1. // accounts/keystore/keystore.go  
  2. func (ks *KeyStore) Unlock(a accounts.Account, passphrase string) error {  
  3.     return ks.TimedUnlock(a, passphrase, timeout:0)  
  4. }  
  5. func (ks *KeyStore) TimedUnlock(a accounts.Account, passphrase string, timeout time.Duration) error   

TimedUnlock()函式會在給定的時限到達後,立即將已知Account對應的unlocked物件中的PrivateKey的私鑰銷燬(逐個bit清0),並將該unlocked物件從KeyStore成員中刪除。而Unlock()函式會將該unlocked物件一直公開,直到程式退出。注意,這裡的清理工作僅僅是針對記憶體中的Key物件,而以加密方式存在本地的key檔案不受影響。

keystore機制以本地檔案的形式提供對賬戶資訊和數字簽名公鑰私鑰的儲存和讀取,從而以軟體方式實現了accounts.<Wallet>的功能。它的兩套獨立的本地儲存檔案,既考慮了公鑰私鑰的加密又兼顧了賬戶資訊的快速讀取,體現出很全面的設計思路。

硬體裝置實現的Wallet

以太坊除了提供軟體實現的錢包之外,還有硬體實現的錢包。當然,對於硬體錢包,以太坊程式碼中肯定有上層程式碼對此進行封裝。這些程式碼都處於/accounts/usbwallet/下,它們的UML關係如下圖所示:


pkg accounts/usbwallet中 主要的結構包括wallet{}, Hub{}以及<driver>介面。

  • wallet{}結構體實現了上層介面accounts.<Wallet>,向外提供accounts.<Wallet>的函式實現;
  • <driver> 介面從命名就看得出來,它用來封裝下層硬體實現錢包的程式碼。儘管嚴格來說,這個介面及其實現體跟一般意義上的”驅動程式”沒什麼關係。
  • ledgerDriver{}trezorDriver{} 分別對應於兩家供應商釋出的硬體數字貨幣錢包,Ledger 和 Trezor 分別是品牌名。它們都可以支援包括以太幣在內的多種數字貨幣。
  • <Hub> 結構體,它實現了上層accounts.<Backend>介面,地位相當於account.Manager。從程式碼來看,所有硬體實現的<Wallet>部分,都會由這個Hub物件來管理。Hub{}向外以<Backend>介面的形式暴露,這樣更上層的程式碼就不必區分下層錢包的具體實現是軟體還是硬體了。

需要注意的是,在目前以太坊的主幹程式碼中,硬體實現錢包有關數字簽名部分,目前只能提供針對交易進行原生的數字簽名功能,即僅僅<Wallet>.SignTx()函式可用,其他簽名功能包括SignHash(),以及SignXXXWithPassphrase()均不支援,不知道其他分支程式碼是否有所不同。

2. Ethereum服務

在瞭解accounts程式碼包之後,我們就可以來看看以太坊原始碼中最著名的型別,同時也是客戶端程式中最核心的部分 - eth.Ethereum。能夠以整個系統名命名的結構體型別,想必功能應該非常強大,下圖是它的一個簡單UML圖:


上圖中央就是eth.Ethereum型別,四周都是它的成員變數型別,我們來看看其中哪些是已經瞭解過的:

  • ethdb.<Database> 是對應於core.state.StateDB{}的函式介面,有了<Database>介面型別的成員變數,可以在使用中呼叫StateDB{}
  • consensus.<Engine> 是共識演算法程式碼包向外暴露的函式介面,其實現包括基於PoW的Ethash演算法,和基於PoA的Clique演算法。
  • accounts.Manager 是管理賬戶資訊和數字簽名公鑰金鑰資訊的程式碼。
  • miner.Miner 是挖掘新區塊的程式碼,它可以管理挖掘新區塊的整個流程,呼叫consensus.<Engine>完成新區塊的授勳/認證,並向外廣播 新區塊事件。
  • core.TxPool 是積累新交易(Transaction, tx)物件的程式碼,每個新挖掘區塊,都需要從TxPool中監聽Tx更新事件並獲取新交易集合以組裝成新區塊。
  • core.BlockChain 是管理整個區塊鏈資料結構的結構體。

以上這些都是前文中都已經具體介紹過的程式碼部分,接著再來看看那些新的型別:

  • node.<Service>,這是客戶端程式用以對節點進行功能抽象的介面。每個客戶端都把自身視為網路中的一個節點(node),這個節點向外所提供的所有功能,由<Service>介面來定義。
  • <LesServer>:實現LES協議的函式介面,eth.<LesServer>其實是為了呼叫les.LesServer{}而專門建立的本地函式介面。
  • EthApiBackend, 它是幫助Ethereum把各項功能以RPC 服務(service)的方式暴露出去的模組,外部呼叫方以API的方式呼叫這些功能/服務。
  • ProtocolManager,用來管理p2p通訊。以太坊內部把每個個體(peer)與其他個體群之間的通訊協議稱為一種基於p2p通訊協議的新協議。考慮到eth.Ethereum提供功能的全面性,它也被稱為全節點服務的通訊協議。
  • ProtocolManager的成員變數中,Fetcher用以接收其他個體發來的宣佈挖掘出新區塊的訊息並決定向對方獲取需要的部分,Downloader負責整個區塊鏈結構的同步(下載)。

特別介紹下LES:Light Ethereum Subprotocol(LES) 是為輕量級客戶端專門設計的子協議。相比於eth.Ethereum提供全節點服務的客戶端,那些輕量級客戶端不參與挖掘新區塊,在與其他節點的通訊中僅僅下載每個區快的頭部(Block.Header),對於區塊鏈的其他部分僅僅按需對部分同步。eth.Ehereum同時也支援LES,這樣一個提供全節點服務的客戶端就可以與其他輕量級客戶端以相同的協議通訊了。

對數字貨幣稍有了解的人應該都清楚p2p通訊協議對於此類“去中心化”系統的重大意義。的確,把p2p通訊協議稱為以太坊系統的基石之一都不為過,從程式碼角度考慮, ProtocolManager及其程式碼族 也屬於eth程式碼包的一部分,不過由於這部分程式碼比較複雜,會在下一篇文章中專門介紹這些通訊協議的實現細節。

3.以太坊客戶端程式

在瞭解eth.Ethereum這個核心服務之後,客戶端執行程式也就呼之欲出了。首先有一個node.Node{}作為承載類似eth,Ethereum這樣服務模組的容器:

Node{}物件內部有一個Service列表,所有實現了node.<Service>介面的物件都可以存放在Node裡,比如eth.Ethereum。

接著,go-ethereum的客戶端程式geth的程式碼就很簡單了:

  1. // /cmd/geth/main.go  
  2. func main() {  
  3.     if err := app.Run(os.Args); err != nil {  
  4.         fmt.Fprintln(os.Stderr, err)  
  5.         os.Exit(1)  
  6.     }  
  7. }  
  8. func geth(ctx *cil.Context) error {  
  9.     node := makeFullNode(ctx)  
  10.     startNode(ctx, node)  
  11.     node.Wait()  
  12.     return nil  
  13. }  
  14. …  

從命令列啟動geth客戶端的程式就是以上,建立一個node.Node物件,從配置中讀出想要註冊的服務名,然後一一建立相應的服務物件,Node去啟動它們。

geth是go-ethereum自帶的命令列客戶端程式,目前市場上也存在許多種其他的以太坊客戶端程式,有興趣的讀者可以去找來看看,有原始碼就最好了可以比較一下。

小結:

以太坊的客戶端程式,原本應該是剛接觸以太坊的初學者最早遇到的部分之一。因為下載完整個原始碼包之後,按照相應語言的提示進行編譯,就會得到一個客戶端的可執行程式。我最初首先看的客戶端的程式碼,當追溯到eth.Ethereum{}結構體,看到那麼多模組的成員變數時,就一下子明白了,整個以太坊系統執行起來的基礎模組是哪些部分。

  1. 以太坊中程式碼中,accounts.Manager是管理賬戶資訊的模組。Manager可以管理多個<Wallet>的實現,每個<Wallet>實現擁有多個Account賬戶,每個Account對應一個Address地址,而以太幣Ether存放於每個Address上。以太坊同時提供軟體版和硬體版的<Wallet>實現。
  2. 以太坊中,每個Address型別變數均來自於橢圓曲線數字簽名演算法(ECDSA)所用的公鑰,因此錢包程式還必須提供管理數字簽名公鑰金鑰的功能。軟體版accounts.<Wallet>實現叫keystore,通過在本地檔案系統中分別顯式儲存賬戶資訊和加密儲存公鑰金鑰的方式,提供以上功能。
  3. 以太坊客戶端程式之間,會通過accounts.Manager模組相互訂閱Wallet更新事件,以保證每個客戶端個體(peer),都能及時更新全網路中的完整Wallet列表。
  4. 客戶端程式的核心是eth.Ethereum,它以RPC service的形式,向外提供內部各模組的功能,諸如挖掘區塊, 資料庫讀寫,p2p下載等。