上文我們總結了以太坊最主要的共識算法:ethash算法,本文將重點分析以太坊的另一個共識算法:clique。
關鍵字:clique,共識算法,puppeth,以太坊地址原理,區塊校驗,認證結點,POA,選舉投票,snapshot,Comma-ok斷言
clique
以太坊的官方共識算法是ethash算法,這在前文已經有了詳細的分析:
它是基於POW的共識機制的,礦工需要通過計算nonce值,會消耗大量算力來匹配target值。
如果在聯盟鏈或者私鏈的方案里,繼續使用ethash就會浪費算力,POW也沒有存在的意義。所以以太坊有了另一種共識方案:基於POA的clique。
POA, Proof of Authority。權力證明,不同於POW的工作量證明,POA是能夠直接確定幾個節點具備出塊的權力,這幾個節點出的塊會被全網其他節點驗證為有效塊。
通過這篇文章的操作可以建立一個私有鏈,觀察這個流程可以看到,通過puppeth工具建立創世塊時,會提示你選擇哪種共識方式,有ethash和clique兩個選項,說到這里我們就明白了為什么文章中默認要選擇clique。
源碼分析
講過了基本概念,下面我們深入以太坊源碼來仔細分析clique算法的具體實現。
入口仍然選擇seal方法,這里與前文分析ethash算法的入口是保持一致的,因為他們是Seal的不同實現。
// 我們的注釋可以對比着來看,clique的seal函數的目的是:嘗試通過本地簽名認證(權力簽名與認證,找到有權力的結點)來創建一個已密封的區塊。
func (c *Clique) Seal(chain consensus.ChainReader, block *types.Block, stop <-chan struct{}) (*types.Block, error) {
header := block.Header()
number := header.Number.Uint64()
if number == 0 {// 不允許密封創世塊
return nil, errUnknownBlock
}
// 跳轉到下方Clique對象的分析。不支持0-period的鏈,同時拒絕密封空塊,沒有獎勵但是能夠旋轉密封
if c.config.Period == 0 && len(block.Transactions()) == 0 {
return nil, errWaitTransactions
}
// 在整個密封區塊的過程中,不要持有signer簽名者字段。
c.lock.RLock() // 上鎖獲取config中的簽名者和簽名方法。
signer, signFn := c.signer, c.signFn
c.lock.RUnlock()
snap, err := c.snapshot(chain, number-1, header.ParentHash, nil)// snapshot函數見下方分析
// 校驗處理:如果我們未經授權去簽名了一個區塊
if err != nil {
return nil, err
}
if _, authorized := snap.Signers[signer]; !authorized {
return nil, errUnauthorized
}
// 如果我們是【最近簽名者】的一員,則等待下一個區塊,// 見下方[底層機制三](http://www.cnblogs.com/Evsward/p/clique.html#%E4%B8%89%E8%AE%A4%E8%AF%81%E7%BB%93%E7%82%B9%E7%9A%84%E5%87%BA%E5%9D%97%E6%9C%BA%E4%BC%9A%E5%9D%87%E7%AD%89)
for seen, recent := range snap.Recents {
if recent == signer {
// Signer當前簽名者在【最近簽名者】中,如果當前區塊沒有剔除他的話只能繼續等待。
if limit := uint64(len(snap.Signers)/2 + 1); number < limit || seen > number-limit {
log.Info("Signed recently, must wait for others")
<-stop
return nil, nil
}
}
}
// 通過以上校驗,到了這里說明協議已經允許我們來簽名這個區塊,等待此工作完成
delay := time.Unix(header.Time.Int64(), 0).Sub(time.Now())
if header.Difficulty.Cmp(diffNoTurn) == 0 {
// 這不是我們的輪次來簽名,delay
wiggle := time.Duration(len(snap.Signers)/2+1) * wiggleTime // wiggleTime = 500 * time.Millisecond // 隨機推延,從而允許並發簽名(針對每個簽名者)
delay += time.Duration(rand.Int63n(int64(wiggle)))
log.Trace("Out-of-turn signing requested", "wiggle", common.PrettyDuration(wiggle))
}
log.Trace("Waiting for slot to sign and propagate", "delay", common.PrettyDuration(delay))
select {
case <-stop:
return nil, nil
case <-time.After(delay):
}
// 核心工作:開始簽名
sighash, err := signFn(accounts.Account{Address: signer}, sigHash(header).Bytes())// signFn函數見下方
if err != nil {
return nil, err
}
copy(header.Extra[len(header.Extra)-extraSeal:], sighash)//將簽名結果替換區塊頭的Extra字段(專門支持記錄額外信息的)
return block.WithSeal(header), nil //通過區塊頭重新組裝一個區塊
}
Clique對象的分析
// Clique是POA共識引擎,計划在Ropsten攻擊以后,用來支持以太坊私測試鏈testnet(也可以自己搭建聯盟鏈或者私有鏈)
type Clique struct {
config *params.CliqueConfig // 共識引擎配置參數,見下方CliqueConfig源碼介紹
db ethdb.Database // 數據庫,用來存儲以及獲取快照檢查點
recents *lru.ARCCache // 最近區塊的快照,用來加速快照重組
signatures *lru.ARCCache // 最近區塊的簽名,用來加速挖礦
proposals map[common.Address]bool // 目前我們正在推動的提案清單,存的是地址和布爾值的鍵值對映射
signer common.Address // 簽名者的以太坊地址
signFn SignerFn // 簽名方法,用來授權哈希
lock sync.RWMutex // 鎖,保護簽名字段
}
CliqueConfig源碼分析
// CliqueConfig是POA挖礦的共識引擎的配置字段。
type CliqueConfig struct {
Period uint64 `json:"period"` // 在區塊之間執行的秒數(可以理解為距離上一塊出塊后的流逝時間秒數)
Epoch uint64 `json:"epoch"` // Epoch['iːpɒk]長度,重置投票和檢查點
}
snapshot函數分析
// snapshot函數可通過給定點獲取認證快照
func (c *Clique) snapshot(chain consensus.ChainReader, number uint64, hash common.Hash, parents []*types.Header) (*Snapshot, error) {
// 在內存或磁盤上搜索一個快照以檢查檢查點。
var (
headers []*types.Header// 區塊頭
snap *Snapshot// 快照對象,見下方
)
for snap == nil {
// 如果找到一個內存里的快照,使用以下方案:
if s, ok := c.recents.Get(hash); ok {
snap = s.(*Snapshot)
break
}
// 如果一個在磁盤檢查點的快照被找到,使用以下方案:
if number%checkpointInterval == 0 {// checkpointInterval = 1024 // 區塊號,在數據庫中保存投票快照的區塊。
if s, err := loadSnapshot(c.config, c.signatures, c.db, hash); err == nil {// loadSnapshot函數見下方
log.Trace("Loaded voting snapshot form disk", "number", number, "hash", hash)
snap = s
break
}
}
// 如果我們在創世塊,則做一個快照
if number == 0 {
genesis := chain.GetHeaderByNumber(0)
if err := c.VerifyHeader(chain, genesis, false); err != nil {
return nil, err
}
signers := make([]common.Address, (len(genesis.Extra)-extraVanity-extraSeal)/common.AddressLength)
for i := 0; i < len(signers); i++ {
copy(signers[i][:], genesis.Extra[extraVanity+i*common.AddressLength:])
}
snap = newSnapshot(c.config, c.signatures, 0, genesis.Hash(), signers)// 創建一個新的快照的函數,見下方
if err := snap.store(c.db); err != nil {
return nil, err
}
log.Trace("Stored genesis voting snapshot to disk")
break
}
// 沒有針對這個區塊頭的快照,則收集區塊頭並向后移動
var header *types.Header
if len(parents) > 0 {
// 如果我們有明確的父類,從這里強制挑揀出來。
header = parents[len(parents)-1]
if header.Hash() != hash || header.Number.Uint64() != number {
return nil, consensus.ErrUnknownAncestor
}
parents = parents[:len(parents)-1]
} else {
// 如果沒有明確父類(或者沒有更多的),則轉到數據庫
header = chain.GetHeader(hash, number)
if header == nil {
return nil, consensus.ErrUnknownAncestor
}
}
headers = append(headers, header)
number, hash = number-1, header.ParentHash
}
// 找到了先前的快照,那么將所有pending的區塊頭都放在它的上面。
for i := 0; i < len(headers)/2; i++ {
headers[i], headers[len(headers)-1-i] = headers[len(headers)-1-i], headers[i]
}
snap, err := snap.apply(headers)//通過區塊頭生成一個新的snapshot對象
if err != nil {
return nil, err
}
c.recents.Add(snap.Hash, snap)//將當前快照區塊的hash存到recents中。
// 如果我們生成了一個新的檢查點快照,保存到磁盤上。
if snap.Number%checkpointInterval == 0 && len(headers) > 0 {
if err = snap.store(c.db); err != nil {
return nil, err
}
log.Trace("Stored voting snapshot to disk", "number", snap.Number, "hash", snap.Hash)
}
return snap, err
}
Snapshot對象源碼分析:
// Snapshot對象是在給定點的一個認證投票的狀態
type Snapshot struct {
config *params.CliqueConfig // 配置參數
sigcache *lru.ARCCache // 簽名緩存,最近的區塊簽名加速恢復。
Number uint64 `json:"number"` // 快照建立的區塊號
Hash common.Hash `json:"hash"` // 快照建立的區塊哈希
Signers map[common.Address]struct{} `json:"signers"` // 當下認證簽名者的集合
Recents map[uint64]common.Address `json:"recents"` // 最近簽名區塊地址的集合
Votes []*Vote `json:"votes"` // 按時間順序排列的投票名單。
Tally map[common.Address]Tally `json:"tally"` // 當前的投票結果,避免重新計算。
}
loadSnapshot函數源碼分析:
// loadSnapshot函數用來從數據庫中加載一個現存的快照,參數列表中很多都是Snapshot對象的關鍵字段屬性。
func loadSnapshot(config *params.CliqueConfig, sigcache *lru.ARCCache, db ethdb.Database, hash common.Hash) (*Snapshot, error) {
blob, err := db.Get(append([]byte("clique-"), hash[:]...))// ethdb使用的是leveldb,對外開放接口Dababase見下方
if err != nil {
return nil, err
}
snap := new(Snapshot)
if err := json.Unmarshal(blob, snap); err != nil {
return nil, err
}
snap.config = config
snap.sigcache = sigcache
return snap, nil
}
ethdb數據庫對外開放接口:
// Database接口包裹了所有的數據庫相關操作,所有的方法都是線程安全的。
type Database interface {
Putter
Get(key []byte) ([]byte, error)//通過某key獲取值
Has(key []byte) (bool, error)//某key是否包含有效值
Delete(key []byte) error
Close()
NewBatch() Batch
}
newSnapshot函數源碼:
// newSnapshot函數創建了一個新的快照,通過給出的特定的啟動參數。這個方法沒有初始化最近簽名者的集合,所以只有使用創世塊。
func newSnapshot(config *params.CliqueConfig, sigcache *lru.ARCCache, number uint64, hash common.Hash, signers []common.Address) *Snapshot {
snap := &Snapshot{// 就是組裝一個Snapshot對象,安裝相應參數
config: config,
sigcache: sigcache,
Number: number,
Hash: hash,
Signers: make(map[common.Address]struct{}),
Recents: make(map[uint64]common.Address),
Tally: make(map[common.Address]Tally),
}
for _, signer := range signers {
snap.Signers[signer] = struct{}{}
}
return snap
}
signFn函數:
// SignerFn是一個簽名者的回調函數,用來請求一個能夠被后台賬戶簽名生成的哈希
type SignerFn func(accounts.Account, []byte) ([]byte, error)
clique常量配置:
blockPeriod = uint64(15) // clique規定,兩個區塊的生成時間至少間隔15秒,timestamp類型。
Clique底層機制
在進入共識引擎之前,當前結點已經生成了一個完整的區塊,包括區塊頭和密封的交易列表,然后進入seal函數,通過ethash或者clique算法引擎來操作出塊確權。本文重點講述了針對clique算法的源碼分析,clique算法基於POA共識,是在結點中找出有權力的幾個“超級結點”,只有這些結點可以生成合法區塊,其他結點的出塊都會直接丟棄。
一:clique是如何確定簽名者以及簽名方法的?
我在clique文件中搜索,發現有一個方法做了這個工作:
// Authorize函數注入共識引擎clique一個私鑰地址(簽名者)以及簽名方法signFn,用來挖礦新塊
func (c *Clique) Authorize(signer common.Address, signFn SignerFn) {
c.lock.Lock()
defer c.lock.Unlock()
c.signer = signer
c.signFn = signFn
}
那么繼續搜索,該函數是在何時被調用的,找到了位於/eth/backend.go中的函數StartMining:
func (s *Ethereum) StartMining(local bool) error {
eb, err := s.Etherbase()// 用戶地址
if err != nil {
log.Error("Cannot start mining without etherbase", "err", err)//未找到以太賬戶地址,報錯
return fmt.Errorf("etherbase missing: %v", err)
}
// 如果是clique共識算法,則走if分支,如果是ethash則跳過if。
if clique, ok := s.engine.(*clique.Clique); ok {// Comma-ok斷言語法見下方分析。
wallet, err := s.accountManager.Find(accounts.Account{Address: eb})// 通過用戶地址獲得wallet對象
if wallet == nil || err != nil {
log.Error("Etherbase account unavailable locally", "err", err)
return fmt.Errorf("signer missing: %v", err)
}
clique.Authorize(eb, wallet.SignHash)//在這里!注入了簽名者以及通過wallet對象獲取到簽名方法
}
if local {
// 如果本地CPU挖礦已啟動,我們可以禁止注入機制以加速同步時間。
// CPU挖礦在主網是荒誕的,所以沒有人能碰到這個路徑,然而一旦CPU挖礦同步標志完成以后,將保證私網工作也在一個獨立礦工結點。
atomic.StoreUint32(&s.protocolManager.acceptTxs, 1)
}
go s.miner.Start(eb)//並發啟動挖礦工作
return nil
}
最終,通過miner.Start(eb),調用到work -> agent -> CPUAgent -> update -> seal,回到最上方我們的入口。
這里要補充一點,挖礦機制是從miner.start()作為入口開始分析的,而上面的StartMining函數是在miner.start()之前的。這樣就把整個這一條線串起來了。
Go語法補充:Comma-ok斷言
if clique, ok := s.engine.(*clique.Clique); ok {
這段語句很令人迷惑,經過搜查,以上語法被稱作Comma-ok斷言。
value, ok = element.(T)
value是element變量的值,ok是布爾類型用來表達斷言結果,element是接口變量,T是斷言類型。
套入以上代碼段,翻譯過來即:
如果s.engine是Clique類型,則ok為true,同時clique就等於s.engine。
二:Snapshot起到的作用是什么?
Snapshot對象在Seal方法中是通過調用snapshot構造函數來獲取到的。而snapshot構造函數內部有較長的函數體,包括newSnapshot方法以及loadSnapshot方法的處理。從這個分析來看,我們也可以知道Snapshot是快照,也是緩存的一種機制,同時它也不僅僅是緩存,因為它存儲了最近簽名者的map集合。
Snapshot可以從內存(即程序中的變量)或是磁盤上(即通過數據庫leveldb)獲取或者存儲,實際上這就是二級緩存的概念了。
三:認證結點的出塊機會均等
首先將上文Seal方法的源碼遺留代碼段展示如下。
for seen, recent := range snap.Recents {
if recent == signer {
if limit := uint64(len(snap.Signers)/2 + 1); number < limit || seen > number-limit {
log.Info("Signed recently, must wait for others")
<-stop
return nil, nil
}
}
}
其中
if recent == signer {
如果當前結點最近簽名過,則跳過,為保證機會均等,避免某個認證結點可以連續出塊,從而作惡。
if limit := uint64(len(snap.Signers)/2 + 1); number < limit || seen > number-limit {
實際上到了這里就已經在決定出塊權了。我們依次來看,
- snap.Signers是所有的認證結點。
- limit的值是所有認證結點的數量的一半加1,也就是說可以保證limit>50%好的認證結點個數(安全性考慮:掌握大於50%的控制權)。結合上面的機會均等,clique要求認證結點在每輪limit個區塊中只能生成一個區塊。
- number是當前區塊號
- seen是 “for seen, recent := range snap.Recents {” 中Recents的index,從0開始,最大值為Recents的總數-1。
接着,我們來分析控制程序中止的條件表達式:
number < limit || seen > number-limit
- number < limit, 如果區塊高度小於limit
- seen > number - limit,緩存中最近簽發者序號已經超過了區塊高度與limit之差。number-limit是最多的壞節點,索引seen大於壞節點也要中斷(TODO: number區塊高度與認證結點的關系)
在這兩種情況下,會中斷程序,停止簽名以及出塊操作。
四:出塊難度
// inturn函數通過給定的區塊高度和簽發者返回該簽發者是否在輪次內
func (s *Snapshot) inturn(number uint64, signer common.Address) bool {
// 方法體的內容就是區塊高度與認證簽發者集合長度的余數是否等於該簽發者的下標值
signers, offset := s.signers(), 0
for offset < len(signers) && signers[offset] != signer {
offset++
}
return (number % uint64(len(signers))) == uint64(offset)
}
一句話,clique要求簽發者必須按照其在snapshot中的認證簽發者集合按照字典排序的順序出塊。
符合以上條件的話,難度為2,否則為1。
diffInTurn = big.NewInt(2) // 簽名在輪次內的區塊難度為2。
diffNoTurn = big.NewInt(1) // 簽名未在輪次內的區塊難度為1。
clique的出塊難度比較容易理解,這是在POW中大書特書的部分但在clique中卻十分簡單,當inturn的結點離線時,其他結點會來競爭,難度值降為1。然而正常出塊時,limit中的所有認證結點包括一個inturn和其他noturn的結點,clique是采用了給noturn加延遲時間的方式來支持inturn首先出塊,避免noturn的結點無謂生成區塊。這部分代碼在下面再貼一次。
wiggle := time.Duration(len(snap.Signers)/2+1) * wiggleTime // wiggleTime = 500 * time.Millisecond // 隨機推延,從而允許並發簽名(針對每個簽名者)
delay += time.Duration(rand.Int63n(int64(wiggle)))
clique認可難度值最高的鏈為主鏈,所以完全inturn結點出的塊組成的鏈會是最理想的主鏈。
五:區塊校驗
// 同樣位於clique文件中的verifySeal函數,顧名思義是結點用來校驗別的結點廣播過來的區塊信息的。
func (c *Clique) verifySeal(chain consensus.ChainReader, header *types.Header, parents []*types.Header) error {
// 創世塊的話不校驗
number := header.Number.Uint64()
if number == 0 {
return errUnknownBlock
}
// 取到所需snapshot對象,用來校驗區塊頭並且將其緩存。
snap, err := c.snapshot(chain, number-1, header.ParentHash, parents)
if err != nil {
return err
}
// 處理授權秘鑰,檢查是否違背認證簽名者集合
signer, err := ecrecover(header, c.signatures)// 從區塊頭中解密出Extra字段,找到簽名字符串,獲得簽名者地址信息。可以跳轉到下面ecrecover函數的源碼分析。
if err != nil {
return err
}
if _, ok := snap.Signers[signer]; !ok {
return errUnauthorized
}
// 與Seal相同的處理,機會均等
for seen, recent := range snap.Recents {
if recent == signer {
if limit := uint64(len(snap.Signers)/2 + 1); seen > number-limit {
return errUnauthorized
}
}
}
// 區分是否inturn,設置區塊困難度,上面也介紹過了。
inturn := snap.inturn(header.Number.Uint64(), signer)
if inturn && header.Difficulty.Cmp(diffInTurn) != 0 {
return errInvalidDifficulty
}
if !inturn && header.Difficulty.Cmp(diffNoTurn) != 0 {
return errInvalidDifficulty
}
return nil
}
ecrecover函數的源碼分析:
// ecrecover函數從一個簽名的區塊頭中解壓出以太坊賬戶地址
func ecrecover(header *types.Header, sigcache *lru.ARCCache) (common.Address, error) {
// 如果簽名已經被緩存,返回它。
hash := header.Hash()
if address, known := sigcache.Get(hash); known {
return address.(common.Address), nil
}
// 從區塊頭的Extra字段取得簽名內容。
if len(header.Extra) < extraSeal {
return common.Address{}, errMissingSignature
}
signature := header.Extra[len(header.Extra)-extraSeal:]
// 通過密碼學技術從簽名內容中解密出公鑰和以太坊地址。
pubkey, err := crypto.Ecrecover(sigHash(header).Bytes(), signature)// 具體源碼見下方
if err != nil {
return common.Address{}, err
}
var signer common.Address
copy(signer[:], crypto.Keccak256(pubkey[1:])[12:])//將公鑰利用keccak256解密賦值給signer。
sigcache.Add(hash, signer)//加入緩存
return signer, nil
}
crypto包的Ecrecover函數:
func Ecrecover(hash, sig []byte) ([]byte, error) {
return secp256k1.RecoverPubkey(hash, sig)
}
Ecrecover函數是使用secp256k1來解密公鑰。
下面我們從VerifySeal函數反推,找出調用該函數的位置在miner/remote_agent.go,
// SubmitWork函數嘗試注入一個pow解決方案(共識引擎)到遠程代理,返回這個解決方案是否被接受。(不能同時是一個壞的pow也不能有其他任何錯誤,例如沒有工作被pending
func (a *RemoteAgent) SubmitWork(nonce types.BlockNonce, mixDigest, hash common.Hash) bool {
a.mu.Lock()
defer a.mu.Unlock()
// 保證被提交的工作不是空
work := a.work[hash]
if work == nil {
log.Info("Work submitted but none pending", "hash", hash)
return false
}
// 保證引擎是真實有效的。
result := work.Block.Header()
result.Nonce = nonce
result.MixDigest = mixDigest
if err := a.engine.VerifySeal(a.chain, result); err != nil {//在這里,VerifySeal方法被調用。
log.Warn("Invalid proof-of-work submitted", "hash", hash, "err", err)
return false
}
block := work.Block.WithSeal(result)
// 解決方案看上去是有效的,返回到礦工並且通知接受結果。
a.returnCh <- &Result{work, block}
delete(a.work, hash)
return true
}
這個SubmitWork位於挖礦的pkg中,主要工作是對work的校驗,包括work本身是否為空,work中的區塊頭以及區塊頭中包含的字段的有效性,然后是對區塊頭的VerifySeal(該函數的功能在上面已經介紹到了,主要是對區塊簽名者的認證,區塊難度值的確認)
繼續反推找到SubmitWork函數被調用的位置:
// SubmitWork函數能夠被外部礦工用來提交他們的POW。
func (api *PublicMinerAPI) SubmitWork(nonce types.BlockNonce, solution, digest common.Hash) bool {
return api.agent.SubmitWork(nonce, digest, solution)
}
總結
區塊的校驗是外部結點自動執行PublicMinerAPI的SubmitWork方法,從而層層調用,通過檢查區塊頭內的簽名內容,通過secp256k1方法恢復公鑰,然后利用Keccak256將公鑰加密為一個以太地址作為簽名地址,獲得簽名地址以后,去本地認證結點緩存中檢查,看該簽名地址是否符合要求。最終只要通過層層校驗,就不會報出errUnauthorized的錯誤。
注意:簽名者地址common.Address在Seal時被簽名signature存在區塊頭的Extra字段中,然后在VerifySeal中被從區塊頭中取出簽名signature。該簽名的解密方式比較復雜:要先通過secp256k1恢復一個公鑰,然后利用這個公鑰和Keccak256加密出簽名者地址common.Address。
common.Address本身就是結點公鑰的Keccak256加密結果。請參照common/types.go:
// Hex函數返回了一個十六禁止的字符串,代表了以太坊地址。
func (a Address) Hex() string {
unchecksummed := hex.EncodeToString(a[:])
sha := sha3.NewKeccak256()//這里就不展開了,可以看出是通過Keccak256方法將未檢查的明文Address加密為一個標准以太坊地址
sha.Write([]byte(unchecksummed))
hash := sha.Sum(nil)
result := []byte(unchecksummed)
for i := 0; i < len(result); i++ {
hashByte := hash[i/2]
if i%2 == 0 {
hashByte = hashByte >> 4
} else {
hashByte &= 0xf
}
if result[i] > '9' && hashByte > 7 {
result[i] -= 32
}
}
return "0x" + string(result)
}
六: 基於投票的認證結點的運行機制
上面我們分析了clique的認證結點的出塊,校驗等細節,那么這里引出終極問題:如何確認一個普通結點是否是認證結點呢?
答:clique是基於投票機制來確認認證結點的。
先來看投票實體類,存在於snapshot源碼中。
// Vote代表了一個獨立的投票,這個投票可以授權一個簽名者,更改授權列表。
type Vote struct {
Signer common.Address `json:"signer"` // 已授權的簽名者(通過投票)
Block uint64 `json:"block"` // 投票區塊號
Address common.Address `json:"address"` // 被投票的賬戶,修改它的授權
Authorize bool `json:"authorize"` // 對一個被投票賬戶是否授權或解授權
}
這個Vote是存在於Snapshot的屬性字段中,所以投票機制離不開Snapshot,我們在這里再次將Snapshot實體源碼重新分析一遍,上面注釋過的內容我不再復述,而是直接關注在投票機制相關字段內容上。
type Snapshot struct {
config *params.CliqueConfig
sigcache *lru.ARCCache
Number uint64 `json:"number"`
Hash common.Hash `json:"hash"`
Signers map[common.Address]struct{} `json:"signers"` // 認證節點集合
Recents map[uint64]common.Address `json:"recents"`
Votes []*Vote `json:"votes"` // 上面的Vote對象數組
Tally map[common.Address]Tally `json:"tally"` // 也是一個自定義類型,見下方
}
Tally結構體:
// Tally是一個簡單的用來保存當前投票分數的計分器
type Tally struct {
Authorize bool `json:"authorize"` // 授權true或移除false
Votes int `json:"votes"` // 該提案已獲票數
}
另外Clique實體中還有個有爭議的字段proposals,當時並沒有分析清楚,何謂提案?
proposal是可以通過rpc申請加入或移除一個認證節點,結構為待操作地址(節點地址)和狀態(加入或移除)
投票中某些概念的確定
- 投票的范圍是在委員會,委員會的意思就是所有礦工。
- 概念介紹:checkpoint,checkpointInterval = 1024 ,每過1024個區塊,則保存snapshot到數據庫
- 概念介紹:Epoch,與ethash一樣,一個Epoch是三萬個區塊
投票流程
- 首先委員會某個成員(即節點礦工)通過rpc調用consensus/clique/api.go中的propose方法
// Propose注入一個新的授權提案,可以授權一個簽名者或者移除一個。
func (api *API) Propose(address common.Address, auth bool) {
api.clique.lock.Lock()
defer api.clique.lock.Unlock()
api.clique.proposals[address] = auth// true:授權,false:移除
}
-
上面rpc提交過來的propose會寫入Clique.proposals集合中。
-
在挖礦開始以后,會在miner.start()中提交一個commitNewWork,其中涉及到准備區塊頭Prepare的方法,我們進入到clique的實現,其中涉及到對上面的Clique.proposals的處理:
// 如果存在pending的proposals,則投票
if len(addresses) > 0 {
header.Coinbase = addresses[rand.Intn(len(addresses))]//將投票節點的地址賦值給區塊頭的Coinbase字段。
// 下面是通過提案內容來組裝區塊頭的隨機數字段。
if c.proposals[header.Coinbase] {
copy(header.Nonce[:], nonceAuthVote)
} else {
copy(header.Nonce[:], nonceDropVote)
}
}
// nonceAuthVote和nonceDropVote常量的聲明與初始化
nonceAuthVote = hexutil.MustDecode("0xffffffffffffffff") // 授權簽名者的必要隨機數
nonceDropVote = hexutil.MustDecode("0x0000000000000000") // 移除簽名者的必要隨機數
- 整個區塊組裝好以后(其他的內容不再復述),會被廣播到外部結點校驗,如果沒有問題該塊被成功出了,則區塊頭中的這個提案也會被記錄在主鏈上。
- 區塊在生成時,會創建Snapshot,在snapshot構造函數中,會涉及到對proposal的處理apply方法。
// apply通過接受一個給定區塊頭創建了一個新的授權
func (s *Snapshot) apply(headers []*types.Header) (*Snapshot, error) {
if len(headers) == 0 {
return s, nil
}
for i := 0; i < len(headers)-1; i++ {
if headers[i+1].Number.Uint64() != headers[i].Number.Uint64()+1 {
return nil, errInvalidVotingChain
}
}
if headers[0].Number.Uint64() != s.Number+1 {
return nil, errInvalidVotingChain
}
snap := s.copy()
// 投票的處理核心代碼
for _, header := range headers {
// Remove any votes on checkpoint blocks
number := header.Number.Uint64()
// 如果區塊高度正好在Epoch結束,則清空投票和計分器
if number%s.config.Epoch == 0 {
snap.Votes = nil
snap.Tally = make(map[common.Address]Tally)
}
if limit := uint64(len(snap.Signers)/2 + 1); number >= limit {
delete(snap.Recents, number-limit)
}
// 從區塊頭中解密出來簽名者地址
signer, err := ecrecover(header, s.sigcache)
if err != nil {
return nil, err
}
if _, ok := snap.Signers[signer]; !ok {
return nil, errUnauthorized
}
for _, recent := range snap.Recents {
if recent == signer {
return nil, errUnauthorized
}
}
snap.Recents[number] = signer
// 區塊頭認證,不管該簽名者之前的任何投票
for i, vote := range snap.Votes {
if vote.Signer == signer && vote.Address == header.Coinbase {
// 從緩存計數器中移除該投票
snap.uncast(vote.Address, vote.Authorize)
// 從按時間排序的列表中移除投票
snap.Votes = append(snap.Votes[:i], snap.Votes[i+1:]...)
break // only one vote allowed
}
}
// 從簽名者中計數新的投票
var authorize bool
switch {
case bytes.Equal(header.Nonce[:], nonceAuthVote):
authorize = true
case bytes.Equal(header.Nonce[:], nonceDropVote):
authorize = false
default:
return nil, errInvalidVote
}
if snap.cast(header.Coinbase, authorize) {
snap.Votes = append(snap.Votes, &Vote{
Signer: signer,
Block: number,
Address: header.Coinbase,
Authorize: authorize,
})
}
// 判斷票數是否超過一半的投票者,如果投票通過,更新簽名者列表
if tally := snap.Tally[header.Coinbase]; tally.Votes > len(snap.Signers)/2 {
if tally.Authorize {
snap.Signers[header.Coinbase] = struct{}{}
} else {
delete(snap.Signers, header.Coinbase)
if limit := uint64(len(snap.Signers)/2 + 1); number >= limit {
delete(snap.Recents, number-limit)
}
for i := 0; i < len(snap.Votes); i++ {
if snap.Votes[i].Signer == header.Coinbase {
snap.uncast(snap.Votes[i].Address, snap.Votes[i].Authorize)
snap.Votes = append(snap.Votes[:i], snap.Votes[i+1:]...)
i--
}
}
}
// 不管之前的任何投票,直接改變賬戶
for i := 0; i < len(snap.Votes); i++ {
if snap.Votes[i].Address == header.Coinbase {
snap.Votes = append(snap.Votes[:i], snap.Votes[i+1:]...)
i--
}
}
delete(snap.Tally, header.Coinbase)
}
}
snap.Number += uint64(len(headers))
snap.Hash = headers[len(headers)-1].Hash()
return snap, nil
}
關鍵控制的代碼是tally.Votes > len(snap.Signers)/2,意思是計分器中的票數大於一半的簽名者,就表示該投票通過,下面就是要更改snapshot中的認證簽名者列表緩存,同時要同步給其他節點,並刪除該投票相關信息。
總結
本以為clique比較簡單,不必調查這么長,然而POA的共識算法還是比較有難度的,它和POW是基於完全不同的兩種場景的實現方式,出塊方式也完全不同。下面我嘗試用簡短的語言來總結Clique的共識機制。
clique共識是基於委員會選舉認證節點來確認出塊權力的方式實現的。投票方式通過rpc請求propose,snapshot二級緩存機制,唱票,執行投票結果。認證節點出塊機會均等,困難度通過輪次(是否按照緩存認證順序出塊)確定,區塊頭Extra存儲簽名,keccak256加密以太地址,secp256k1解密簽名為公鑰,通過認證結點出塊的邏輯可以反推區塊校驗。
到目前為止,我們對POA共識機制,以及以太坊clique的實現有了深刻的理解與認識,相信如果讓我們去實現一套POA,也是完全有能力的。大家在閱讀本文時有任何疑問均可留言給我,我一定會及時回復。
參考資料
go-ethereum源碼,以太坊官方文檔,網絡名詞解釋文章