1. 程式人生 > >區塊鏈100講:以太坊原始碼研究之PoW及共識演算法深究

區塊鏈100講:以太坊原始碼研究之PoW及共識演算法深究

image

本講將介紹“挖礦“得到新區塊的整個過程,以及不同共識演算法的實現細節。

1

待挖掘區塊需要組裝

在Ethereum 程式碼中,名為miner的包(package)負責向外提供一個“挖礦”得到的新區塊,其主要結構體的UML關係圖如下圖所示:

image

處於入口的類是Miner,它作為公共型別,向外暴露mine功能;它有一個worker型別的成員變數,負責管理mine過程;worker內部有一組Agent介面型別物件,每個Agent都可以完成單個區塊的mine,目測這些Agent之間應該是競爭關係;Work結構體主要用以攜帶資料,被視為挖掘一個區塊時所需的資料環境。

主要的資料傳輸發生在worker和它的Agent(們)之間:在合適的時候,worker把一個Work物件傳送給每個Agent,然後任何一個Agent完成mine時,將一個經過授權確認的Block加上那個更新過的Work,組成一個Result物件傳送回worker。

有意思的是<>介面,儘管呼叫方worker內部聲明瞭一個Agent陣列,但目前只有一個實現類CpuAgent的物件會被加到該陣列,可能這個Agent陣列是為將來的擴充套件作的預留吧。CpuAgent通過全域性的<>物件,藉助共識演算法完成最終的區塊授權。

另外,unconfirmedBlocks 也挺特別,它會以unconfirmedBlock的形式儲存最近一些本地挖掘出的區塊。在一段時間之後,根據區塊的Number和Hash,再確定這些區塊是否已經被收納進主幹鏈(canonical chain)裡,以輸出Log的方式來告知使用者。

對於一個新區塊被挖掘出的過程,程式碼實現上基本分為兩個環節:一是組裝出一個新區塊,這個區塊的資料基本完整,包括成員Header的部分屬性,以及交易列表txs,和叔區塊組uncles[],並且所有交易已經執行完畢,所有收據(Receipt)也已收集完畢,這部分主要由worker完成;二是填補該區塊剩餘的成員屬性,比如Header.Difficulty等,並完成授權,這些工作是由Agent呼叫介面實現體,利用共識演算法來完成的。

新區塊的組裝流程

挖掘新區塊的流程入口在Miner裡,略顯奇葩的是,具體入口在Miner結構體的建立函式(避免稱之為‘建構函式’)裡。

image

Miner的函式

New()

在New()裡,針對新物件miner的各個成員變數初始化完成後,會緊跟著建立worker物件,然後將Agent物件登記給worker,最後用一個單獨執行緒去執行miner.Update()函式;而worker的建立函式裡也如法炮製,分別用單獨執行緒去啟動worker.updater()和wait();最後worker.CommitNewWork()會開始準備一個新區塊所需的基本資料,如Header,Txs, Uncles等。注意此時Agent尚未啟動。

Update()

這個update()會訂閱(監聽)幾種事件,均跟Downloader相關。當收到Downloader的StartEvent時,意味者此時本節點正在從其他節點下載新區塊,這時miner會立即停止進行中的挖掘工作,並繼續監聽;如果收到DoneEvent或FailEvent時,意味本節點的下載任務已結束-無論下載成功或失敗-此時都可以開始挖掘新區塊,並且此時會退出Downloader事件的監聽。

從miner.Update()的邏輯可以看出,對於任何一個Ethereum網路中的節點來說,挖掘一個新區塊和從其他節點下載、同步一個新區塊,根本是相互衝突的。這樣的規定,保證了在某個節點上,一個新區塊只可能有一種來源,這可以大大降低可能出現的區塊衝突,並避免全網中計算資源的浪費。

worker的函式

這裡我們主要關注worker.updater()和wait()

image

update()

worker.update()分別監聽ChainHeadEvent,ChainSideEvent,TxPreEvent幾個事件,每個事件會觸發worker不同的反應。ChainHeadEvent是指區塊鏈中已經加入了一個新的區塊作為整個鏈的鏈頭,這時worker的迴應是立即開始準備挖掘下一個新區塊(也是夠忙的);ChainSideEvent指區塊鏈中加入了一個新區塊作為當前鏈頭的旁支,worker會把這個區塊收納進possibleUncles[]陣列,作為下一個挖掘新區塊可能的Uncle之一;TxPreEvent是TxPool物件發出的,指的是一個新的交易tx被加入了TxPool,這時如果worker沒有處於挖掘中,那麼就去執行這個tx,並把它收納進Work.txs陣列,為下次挖掘新區塊備用。

需要稍稍注意的是,ChainHeadEvent並不一定是外部源發出。由於worker物件有個成員變數chain(eth.BlockChain),所以當worker自己完成挖掘一個新區塊,並把它寫入資料庫,加進區塊鏈裡成為新的鏈頭時,worker自己也可以呼叫chain發出一個ChainHeadEvent,從而被worker.update()函式監聽到,進入下一次區塊挖掘。

wait()

worker.wait()會在一個channel處一直等待Agent完成挖掘傳送回來的新Block和Work物件。這個Block會被寫入資料庫,加入本地的區塊鏈試圖成為最新的鏈頭。注意,此時區塊中的所有交易,假設都已經被執行過了,所以這裡的操作,不會再去執行這些交易物件。

當這一切都完成,worker就會發送一條事件(NewMinedBlockEvent{}),等於通告天下:我挖出了一個新區塊!這樣監聽到該事件的其他節點,就會根據自身的狀況,來決定是否接受這個新區塊成為全網中公認的區塊鏈新的鏈頭。至於這個公認過程如何實現,就屬於共識演算法的範疇了。

commitNewWork()

commitNewWork()會在worker內部多處被呼叫,注意它每次都是被直接呼叫,並沒有以goroutine的方式啟動。commitNewWork()內部使用sync.Mutex對全部操作做了隔離。這個函式的基本邏輯如下:

  • 準備新區塊的時間屬性Header.Time,一般均等於系統當前時間,不過要確保父區塊的時間(parentBlock.Time())要早於新區塊的時間,父區塊當然來自當前區塊鏈的鏈頭了。

  • 建立新區塊的Header物件,其各屬性中:Num可確定(父區塊Num +1);Time可確定;ParentHash可確定;其餘諸如Difficulty,GasLimit等,均留待之後共識演算法中確定。

  • 呼叫Engine.Prepare()函式,完成Header物件的準備。

  • 根據新區塊的位置(Number),檢視它是否處於DAO硬分叉的影響範圍內,如果是,則賦值予header.Extra。

  • 根據已有的Header物件,建立一個新的Work物件,並用其更新worker.current成員變數。

  • 如果配置資訊中支援硬分叉,在Work物件的StateDB裡應用硬分叉。

  • 準備新區塊的交易列表,來源是TxPool中那些最近加入的tx,並執行這些交易。

  • 準備新區塊的叔區塊uncles[],來源是worker.possibleUncles[],而possibleUncles[]中的每個區塊都從事件ChainSideEvent中搜集得到。注意叔區塊最多有兩個。

  • 呼叫Engine.Finalize()函式,對新區塊“定型”,填充上Header.Root, TxHash, ReceiptHash, UncleHash等幾個屬性。

  • 如果上一個區塊(即舊的鏈頭區塊)處於unconfirmedBlocks中,意味著它也是由本節點挖掘出來的,嘗試去驗證它已經被吸納進主幹鏈中。

  • 把建立的Work物件,通過channel傳送給每一個登記過的Agent,進行後續的挖掘。

以上步驟中,4和6都是僅僅在該區塊配置中支援DAO硬分叉,並且該區塊的位置正好處於DAO硬分叉影響範圍內時才會發生;其他步驟是普遍性的。commitNewWork()完成了待挖掘區塊的組裝,block.Header建立完畢,交易陣列txs,叔區塊Uncles[]都已取得,並且由於所有交易被執行完畢,相應的Receipt[]也已獲得。萬事俱備,可以交給Agent進行‘挖掘’了。

CpuAgent的函式

CpuAgent中與mine相關的函式,主要是update()和mine():

image

CpuAgent.update()就是worker.commitNewWork()結束後發出Work物件的會一直監聽相關channel,如果收到Work物件(顯然由worker.commitNewWork()結束後發出),就啟動mine()函式;如果收到停止(mine)的訊息,就退出一切相關操作。

CpuAgent.mine()會直接呼叫Engine.Seal()函式,利用Engine實現體的共識演算法對傳入的Block進行最終的授權,如果成功,就將Block同Work一起通過channel發還給worker,那邊worker.wait()會接收並處理。

顯然,這兩個函式都沒做什麼實質性工作,它們只是負責呼叫介面實現體,待授權完成後將區塊資料傳送回worker。挖掘出一個區塊的真正奧妙全在Engine實現體所代表的共識演算法裡。

2

共識演算法完成挖掘

共識演算法族對外暴露的是Engine介面,其有兩種實現體,分別是基於運算能力的Ethash演算法和基於“同行”認證的的Clique演算法。

image

在Engine介面的宣告函式中,VerifyHeader(),VerifyHeaders(),VerifyUncles()用來驗證區塊相應資料成員是否合理合規,可否放入區塊;Prepare()函式往往在Header建立時呼叫,用來對Header.Difficulty等屬性賦值;Finalize()函式在區塊區塊的資料成員都已具備時被呼叫,比如叔區塊(uncles)已經具備,全部交易Transactions已經執行完畢,全部收據(Receipt[])也已收集完畢,此時Finalize()會最終生成Root,TxHash,UncleHash,ReceiptHash等成員。

而Seal()和VerifySeal()是Engine介面所有函式中最重要的。Seal()函式可對一個呼叫過Finalize()的區塊進行授權或封印,並將封印過程產生的一些值賦予區塊中剩餘尚未賦值的成員(Header.Nonce, Header.MixDigest)。Seal()成功時返回的區塊全部成員齊整,可視為一個正常區塊,可被廣播到整個網路中,也可以被插入區塊鏈等。所以,對於挖掘一個新區塊來說,所有相關程式碼裡Engine.Seal()是其中最重要,也是最複雜的一步。VerifySeal()函式基於跟Seal()完全一樣的演算法原理,通過驗證區塊的某些屬性(Header.Nonce,Header.MixDigest等)是否正確,來確定該區塊是否已經經過Seal操作。

在兩種共識演算法的實現中,Ethash是產品環境下以太坊真正使用的共識演算法,Clique主要針對以太坊的測試網路運作,兩種共識演算法的差異,主要體現在Seal()的實現上。

Ethash共識演算法

Ethash演算法又被稱為Proof-of-Work(PoW),是基於運算能力的授權/封印過程。Ethash實現的Seal()函式,其基本原理可簡單表示成以下公式:

RAND(h, n) <= M / d

這裡M表示一個極大的數,比如2^256-1;d表示Header成員Difficulty。RAND()是一個概念函式,它代表了一系列複雜的運算,並最終產生一個類似隨機的數。這個函式包括兩個基本入參:h是Header的雜湊值(Header.HashNoNonce()),n表示Header成員Nonce。整個關係式可以大致理解為,在最大不超過M的範圍內,以某個方式試圖找到一個數,如果這個數符合條件(<=M/d),那麼就認為Seal()成功。

我們可以先定性的驗證一個推論:d的大小對整個關係式的影響。假設h,n均不變,如果d變大,則M/d變小,那麼對於RAND()生成一個滿足該條件的數值,顯然其概率是下降的,即意味著難度將加大。考慮到以上變數的含義,當Header.Difficulty逐漸變大時,這個對應區塊被挖掘出的難度(恰為Difficulty本義)也在緩慢增大,挖掘所需時間也在增長,所以上述推論是合理的。

mine()函式

Ethash.Seal()函式實現中,會以多執行緒(goroutine)的方式並行呼叫mine()函式,執行緒個數等於Ethash.threads;如果Ethash.threads被設為0,則Ethash選擇以本地CPU中的總核數作為開啟執行緒的個數。

image

以上程式碼就是mine()函式的主要業務邏輯。入參@id是執行緒編號,用來發送log告知上層;函式內部首先定義一組區域性變數,包括之後呼叫hashimotoFull()時傳入的hash、nonce、巨大的輔助陣列dataset,以及結果比較的target;然後是一個無限迴圈,每次呼叫hashimotoFull()進行一系列複雜運算,一旦它的返回值符合條件,就複製Header物件(深度拷貝),並賦值Nonce、MixDigest屬性,返回經過授權的區塊。注意到在每次迴圈運算時,nonce還會自增+1,使得每次迴圈中的計算都各不相同。

這裡hashimotoFull()函式通過呼叫hashimoto()函式完成運算,而同時還有另外一個類似的函式hashimoLight()函式。

image

以上兩個函式,最終都呼叫了hashimoto()。它們的差別,在於各自呼叫hashimoto()函式的入參@size uint 和 @lookup func()不同。相比於Light(),Full()函式呼叫的size更大,以及一個從更大陣列中獲取資料的查詢函式lookup()。hashimotoFull()函式是被Seal()呼叫的,而hashimotoLight()是為VerifySeal()準備的。

這裡的lookup()函式其實很重要,它其實是一個以非線性表查詢方式進行的雜湊函式! 這種雜湊函式的效能不僅取決於查詢的邏輯,更多的依賴於所查詢的表格的資料規模大小。lookup()會以函式型引數的形式傳遞給hashimoto()

核心的運算函式hashimoto()

最終為Ethash共識演算法的Seal過程執行運算任務的是hashimoto()函式,它的函式型別如下:

image

hashimoto()函式的入參包括:區塊雜湊值@hash, 區塊nonce成員@nonce,和非線性表查詢的雜湊函式lookup(),及其所查詢的非線性表格的容量@size。返回值digest和result,都是32 bytes長的位元組串。

hashimoto()的邏輯比較複雜,包含了多次、多種雜湊運算。下面嘗試從其中資料結構變化的角度來簡單描述之:

image

簡單介紹一下上圖所代表的程式碼流程:

  • 首先,hashimoto()函式將入參@hash和@nonce合併成一個40 bytes長的陣列,取它的SHA-512雜湊值取名seed,長度為64 bytes。

  • 然後,將seed[]轉化成以uint32為元素的陣列mix[],注意一個uint32數等於4 bytes,故而seed[]只能轉化成16個uint32數,而mix[]陣列長度32,所以此時mix[]陣列前後各半是等值的。

  • 接著,lookup()函式登場。用一個迴圈,不斷呼叫lookup()從外部資料集中取出uint32元素型別陣列,向mix[]陣列中混入未知的資料。迴圈的次數可用引數調節,目前設為64次。每次迴圈中,變化生成引數index,從而使得每次呼叫lookup()函式取出的陣列都各不相同。這裡混入資料的方式是一種類似向量“異或”的操作,來自於FNV演算法。

  • 待混淆資料完成後,得到一個基本上面目全非的mix[],長度為32的uint32陣列。這時,將其摺疊(壓縮)成一個長度縮小成原長1/4的uint32陣列,摺疊的操作方法還是來自FNV演算法。

  • 最後,將摺疊後的mix[]由長度為8的uint32型陣列直接轉化成一個長度32的byte陣列,這就是返回值@digest;同時將之前的seed[]陣列與digest合併再取一次SHA-256雜湊值,得到的長度32的byte陣列,即返回值@result。

最終經過一系列多次、多種的雜湊運算,hashimoto()返回兩個長度均為32的byte陣列 - digest[]和result[]。回憶一下ethash.mine()函式中,對於hashimotoFull()的兩個返回值,會直接以big.int整型數形式比較result和target;如果符合要求,則將digest取SHA3-256的雜湊值(256 bits),並存於Header.MixDigest中,待以後Ethash.VerifySeal()可以加以驗證。

以非線性表查詢方式進行的雜湊運算

上述hashimoto()函式中,函式型入參lookup()其實表示的是一次以非線性表查詢方式進行的雜湊運算,lookup()以入參為key,從所關聯的資料集中按照定義好的一段邏輯取出64 bytes長的資料作為hash value並返回,注意返回值以uint32的形式則相應變成16個uint32長。返回的資料會在hashimoto()函式被其他的雜湊運算所使用。

以非線性表的查詢方式進行的雜湊運算(hashing by nonlinear table lookup),屬於眾多雜湊函式中的一種實現,在Ethash演算法的核心環節有大量使用,所使用到的非線性表被定義成兩種結構體,分別叫cache{}和dataset{}。二者的差異主要是表格的規模和呼叫場景:dataset{}中的資料規模更加巨大,從而會被hashimotoFull()呼叫從而服務於Ethash.Seal();cache{}內含資料規模相對較小,會被hashimotoLight()呼叫並服務於Ethash.VerifySeal()。

image

以cache{}的結構體宣告為例,成員cache就是實際使用的一塊記憶體Buffer,mmap是記憶體對映物件,dump是該記憶體buffer儲存於磁碟空間的檔案物件,epoch是共享這個cache{}物件的一組區塊的序號。從上述UML圖來看,cache和dataset的結構體宣告基本一樣,這也暗示了它們無論是原理還是行為都極為相似。

cache{}物件的生成

dataset{}和cache{}的生成過程很類似,都是通過記憶體對映的方式讀/寫磁碟檔案。

image

以cache{}為例,Ethash.cache(uint64)會確保該區塊所用到的cache{}物件已經建立,它的入參是區塊的Number,用Number/epochLength可以得到該區塊所對應的epoch號。epochLength被定義成常量30000,意味著每連續30000個區塊共享一個cache{}物件。

有意思的是記憶體對映相關的函式,memoryMapAndGenerate()會首先呼叫memoryMapFile()生成一個檔案並對映到記憶體中的一個數組,並呼叫傳入的函式型引數generator() 進行資料的填入,於是這個記憶體陣列以及所對映的磁碟檔案就同時變得十分巨大,注意此時傳入memoryMapFile()的檔案操作許可權是可寫的。然後再關閉所有檔案操作符,呼叫memoryMapFile()重新開啟這個磁碟檔案並對映到記憶體的一個數組,注意此時的檔案操作許可權是隻讀的。可見這組函式的coding很精細。

Ethash中分別用一個map結構來存放不同epoch對應的cache物件和dataset物件,快取成員fcache和fdataset,用以提前建立cache{}和dataset{}物件以避免下次使用時再花費時間。

我們以cache{}為例,看看cache.generate()方法的具體邏輯:

image

上圖是cache.generate()方法的基本流程:如果是測試用途,則不必考慮磁碟檔案,直接呼叫generateCache()建立buffer;如果資料夾為空,那也沒法拼接出檔案路徑,同樣直接呼叫generateCache()建立buffer;然後根據拼接出的檔案路徑,先嚐試讀取磁碟上已有檔案,如果成功,說明檔案已存在並可使用;如果檔案不存在,那隻好建立一個新檔案,定義檔案容量,然後利用記憶體對映將其匯入記憶體。很明顯,直接為cache{]建立buffer的generateCache()函式是這裡的核心操作,包括memoryMapAndGenerate()方法,都將generateCache()作為一個函式型引數引入操作的。

引數size的生成

引數size是生成buffer的容量,它在上述cache.generate()中生成。

image

上述就是生成size的程式碼,cacheSize()的入參雖然是跟區塊Number相關,但實際上對於處於同一epoch組的區塊來說效果是一樣的,每組個數epochLength。Ethash在程式碼裡預先定義了一個數組cacheSizes[],存放了前2048個epoch組所用到的cache size。如果當前區塊的epoch處於這個範圍內,則取用之;若沒有,則以下列公式賦初始值。

size = cacheInitBytes + cacheGrowthBytes * epoch - hashBytes

這裡cacheInitBytes = 2^24,cacheGrowthBytes = 2^17,hashBytes = 64,可見size的取值有多麼巨大了。注意到cacheSize()中在對size賦值後還要不斷調整,保證最終size是個質數,這是出於密碼學的需要。

粗略計算一下size的取值範圍,size = 2^24 + 2^17 * epoch,由於epoch > 2048 = 2^11,所以size > 2^28,生成的buffer至少有256MB,而這還僅僅是VerifySeal()使用的cache{},Seal()使用的dataset{}還要大的多,可見這些資料集有多麼龐大了。

引數seed[]的生成

引數seed是generateCache()中對buffer進行雜湊運算的種子資料,它也在cache.generate()函式中生成。

image

上述seedHash()函式用來生成所需的seed[]陣列,它的長度32bytes,與common.Address型別長度相同。makeHasher()函式利用入參的雜湊函式介面生成一個雜湊函式,這裡用了SHA3-256雜湊函式。注意seedHash()中利用生成的雜湊函式keccak256()對seed[]做的原地雜湊,而原地雜湊運算的次數跟當前區塊所處的epoch序號有關,所以每個不同的cache{}所用到的seed[]也是完全不同的,這個不同通過更多次的雜湊運算來實現。

generateCache()函式

generateCache()函式在給定種子陣列seed[]的情況下,對固定容量的一塊buffer進行一系列操作,使得buffer的數值分佈變得隨機、無規律可循,最終buffer作為cache{}中的陣列(非線性表)返回,還可作為資料來源幫助生成dataset{}。generateCache()函式主體分兩部分,首先用SHA3-512雜湊函式作為雜湊生成器(hasher),對buffer陣列分段(每次64bytes)進行雜湊化,然後利用StrictMemoryHardFunction中的RandMemoHash演算法對buffer再進行處理。這個RandMemoHash演算法來自2014年密碼學方向的一篇學術論文,有興趣的朋友可以搜搜看。

記憶體對映

由於Ethash(PoW)演算法中用到的隨機資料集cache{}和dataset{}過於龐大,將其以檔案形式儲存在磁碟上就顯得很有必要。同樣由於這些檔案過於龐大,使用時又需要一次性整體讀入記憶體(因為對其的使用是隨意擷取其中的一段資料),使用記憶體對映可以大大減輕I/O負擔。cache{}和dataset{}結構體中,均有一個mmap物件用以操作記憶體對映,以及一個系統檔案物件dump,對應於開啟的磁碟檔案。

Ethash演算法總結

回看一下Ethash共識演算法最基本的形態,如果把整個result[]的生成過程視作那個概念上的函式RAND(),則如何能更加隨機,分佈更加均勻的生成陣列,關係到整個Ethash演算法的安全性。畢竟如果result[]生成過程存在被破譯的途徑,那麼必然有方法可以更快地找到符合條件的陣列,通過更快的挖掘出區塊,在整個以太坊系統中逐漸佔據主導。所以Ethash共識演算法應用了非常複雜的一系列運算,包含了多次、多種不同的雜湊函式運算:

  • 大量使用SHA3雜湊函式,包括256-bit和512-bit形式的,用它們來對資料(組)作雜湊運算,或者充當其他更復雜雜湊計算的某個原型 – 比如呼叫makeHasher()。而SHA3雜湊函式,是一種典型的可應對長度變化的輸入資料的雜湊函式,輸出結果長度統一(可指定256bits或512bits)。

  • lookup()函式提供了非線性表格查詢方式的雜湊函式,相關聯的dataset{}和cache{}規模巨大,其中資料的生成/填充過程中也大量使用雜湊函式。

  • 在一些計算過程中,有意將[]byte陣列轉化為uint32或uint64整型數進行操作(比如XOR,以及類XOR的FNV()函式)。因為理論證實,在32位或64位CPU機器上,以32位/64位整型數進行操作時,速度更快。

Clique共識演算法

Clique演算法又稱Proof-of-Authortiy(PoA),它實現的Seal()其實是一個標準的數字簽名加密過程,可由下列公式表示:

n = F(pr, h)

其中F()是數字簽名函式,n是生成的數字簽名,pr是公鑰,h是被加密的內容。具體到Clique應用中,n是一個65 bytes長的字串,pr是一個common.Address型別的(長度20 bytes)地址,h是一個common.Hash型別(32 bytes)的雜湊值,而簽名演算法F(),目前採用的正是橢圓曲線數字簽名演算法(ECDSA)。

沒錯,就是這個被用來生成交易(Transaction)物件的數字簽名的ECDSA。在Clique的實現中,這裡用作公鑰的Address型別地址有一個限制,它必須是已認證的(authorized)。所以Clique.Seal()函式的基本邏輯就是:有一個Address型別地址打算用作數字簽名的公鑰(不是區塊的作者地址Coinbase);如果它是已認證的,則執行指定的數字簽名演算法。而其中涉及到的動態管理所有認證地址的機制,才是Clique演算法(PoA)的精髓。

基於投票的地址認證機制

首先了解一下Clique的認證機制authorization所包括的一些設定:

  • 所有的地址(Address型別)分為兩類,分別是經過認證的,和未經過認證的。

  • 已認證地址(authorized)可以變成未認證的,反之亦然。不過這些變化都必須通過投票機制完成。

  • 一張投票包括:投票方地址,被投票地址,和被投票地址的新認證狀態。有效投票必須滿足:被投票地址的新認證地址與其現狀相反。

  • 任意地值A只能給地址B投一張票

這些設定理解起來並不困難,把這裡的地址替換成平常生活中的普通個體,這就是個很普通的投票制度。Clique演算法中的投票系統的巧妙之處在於,每張投票並不是某個投票方主動“投”出來的,而是隨機組合出來的。

想了解更多細節免不了要深入一些程式碼,下圖是Clique演算法中用到的一些結構體:

image

Clique結構體實現了共識演算法介面Engine的所有方法,它可對區塊作Seal操作。它的成員signFn正是數字簽名生成函式,signer用作數字簽名的公鑰,這兩成員均由Authorize()函式進行賦值。它還有一個map型別成員proposals,用來存放所有的不記名投票,即每張投票只帶有被投票地址和投票內容(新認證狀態),由於是map型別,顯然這裡的proposals存放的是內容不同的不記名投票。API結構體對外提供方法,可以向Clique的成員變數proposals插入或刪除投票。

Snapshot結構體用來動態管理認證地址列表,在這裡認證地址是分批次儲存和更新的,一個Snapshot物件,存放的是以區塊為時序的所有認證地址的”快照”。Snapshot的成員Number和Hash,正是區塊Block的標誌屬性;成員Signers儲存所有已認證地址。

一個Vote物件表示一張記名投票。Tally結構體用來記錄投票資料,即某個(被投票)地址總共被投了多少票,新認證狀態是什麼。Snapshot中用map型變數Tally來管理所有Tally物件資料,map的key是被投票地址,所以Snapshot.Tally記錄了被投票地址的投票次數。另外Snapshot還有一個Vote物件陣列,記錄所有投票資料。

新區塊的Coinbase是一個隨機的被投票地址

Engine介面的Prepare()方法,約定在Header建立後呼叫,用以對Header的一些成員變數賦值,比如作者地址Coinbase。在Clique演算法中,新區塊的Coinbase來自於proposals中的某個被投票地址。

image

上圖解釋了Clique.Prepare()方法中的部分邏輯。首先從proposals中篩選出有效的不記名投票,要麼是已認證地址變為未認證,要麼反過來;然後獲取有效的被投票地址列表,從中隨機選取一個地址作為該區塊的Coinbase,與之相應的投票內容則被區塊的Nonce屬性攜帶。而新區塊的Coinbase會在之後的更新認證地址環節,被當作一次投票的被投票地址。

ps,Ethash演算法中,新區塊的Coinbase地址可是異常重要的,因為它會作為新區塊的作者地址,被系統獎勵或補償以太幣。但Clique演算法中就完全不同了,由於工作在測試網路中,每個帳號地址獲得多少以太幣沒有實際意義,所以這裡的Coinbase任意賦值倒也無妨。

新增記名投票並更新認證地址列表

管理所有認證地址的結構體是Snapshot,具體到更新認證地址列表的方法是apply()。它的基本流程如下圖:

image

重溫一下Snapshot結構體內宣告的一組快取成員變數:

Signers是全部已認證地址集合,注意這裡用map型別來提供Set的行為。

Recents用來記錄最近擔當過數字簽名演算法的signer的地址,通過它Snapshot可以控制某個地址不會頻繁的擔當signer。更重要的是,由於signer地址會充當記名投票的投票方,所以Recents可以避免某些地址頻繁的充當投票方!Recents中map型別的key是區塊Number值。

Votes記錄了所有尚未失效的記名投票;Tallies記錄了所有被投票地址(voted)目前的(被)投票次數。

Snapshot.apply()函式的入參是一組Header物件,它們來自區塊主鏈上按從舊到新順序排列的一組區塊,並且嚴格銜接在Snapshot當前狀態(成員Number,Hash)之後。注意,這些區塊都是當前“待挖掘”新區塊的祖先,所以它們的成員屬性都是已經確定的。apply()方法的主要部分是迭代處理每個Header物件,處理單個Header的流程如下:

  • 首先從數字簽名中恢復出簽名所用公鑰,轉化為common.Address型別,作為signer地址。數字簽名(signagure)長度65 bytes,存放在Header.Extra[]的末尾。

  • 如果signer地址是尚未認證的,則直接退出本次迭代;如果是已認證的,則記名投票+1。所以一個父區塊可新增一張記名投票,signer作為投票方地址,Header.Coinbase作為被投票地址,投票內容authorized可由Header.Nonce取值確定。

  • 更新投票統計資訊。如果被投票地址的總投票次數達到已認證地址個數的一半,則通過之。

  • 該被投票地址的認證狀態立即被更改,根據是何種更改,相應的更新快取資料,並刪除過時的投票資訊。

在所有Header物件都被處理完後,Snapshot內部的Number,Hash值會被更新,表明當前Snapshot快照結構已經更新到哪個區塊了。

Snapshot.apply()方法在Clique.Seal()中被呼叫,具體位於執行數字簽名演算法之前,以保證即將充當公鑰的地址可以用最新的認證地址列表加以驗證。

綜上所述,Clique演算法在投票制度的安全性設計上完善了諸多細節:

  • 外部參與不記名投票的方式是通過API.Propose(),Discard()來操作Clique.proposals。由於proposals是map型別,所以每個投票地址(map的key)在proposals中只能存在一份,這樣就杜絕了外部通過惡意操作Clique.proposals來影響不記名投票資料的企圖。

  • 所有認證地址的動態更新,由一張張記名投票累計作用影響。而每張記名投票的投票方地址和投票內容(不記名投票),是以毫不相關、近乎隨機的方式組合起來的。所以,理論上幾乎不存在外部惡意呼叫程式碼從而操縱記名投票資料的可能。同時,通過一些內部快取(Snapshot.Recents),避免了某些signer地址過於頻繁的充當投票方地址。

雖然Clique共識演算法並非作用在產品環境,但它依然很精巧的設計了完整的基於投票的選拔制度,很好的踐行了”去中心化”宗旨。這對於其他型別共識演算法的設計,提供了一個不錯的樣本。

3

總結

本篇介紹了挖掘一個新區塊在程式碼上的完整過程,從呼叫函式入口開始,沿呼叫過程一路向深,直至最終完成新區塊授權/封印的共識演算法,並對兩種共識演算法的設計思路和實現細節都進行了詳細講解。

  • 一般所說的“挖掘一個新區塊”其實包括兩部分,第一階段組裝出新區塊的所有資料成員,包括交易列表txs、叔區塊uncles等,並且所有交易都已經執行完畢,各帳號狀態更新完畢;第二階段對該區塊進行授勳/封印(Seal),沒有成功Seal的區塊不能被廣播給其他節點。第二階段所消耗的運算資源,遠超第一階段。

  • Seal過程由共識演算法(consensus algorithm)族完成,包括Ethash演算法和Clique演算法兩種實現。前者是產品環境下真實採用的,後者是針對測試網路(testnet)使用的。Seal()函式並不會增加或修改區塊中任何跟有效資料有關的部分,它的目的是通過一系列複雜的步驟,或計算或公認,選拔出能夠出產新區塊的個體。

  • Ethash演算法(PoW)基於運算能力來篩選出挖掘區塊的獲勝者,運算過程中使用了大量、多次、多種的雜湊函式,通過極高的計算資源消耗,來限制某些節點通過超常規的計算能力輕易形成“中心化”傾向。

  • Clique演算法(PoA)利用數字簽名演算法完成Seal操作,不過簽名所用公鑰,同時也是common.Address型別的地址必須是已認證的。所有認證地址基於特殊的投票地址進行動態管理,記名投票由不記名投票和投票方地址隨機組合而成,杜絕重複的不記名投票,嚴格限制外部程式碼惡意操縱投票資料

  • 在實踐“去中心化”方面,以太坊還在區塊結構中增加了叔區塊(uncles)成員以加大計算資源的消耗,並通過在交易執行環節對叔區塊作者(挖掘者)的獎勵,以收益機制來調動網路中各節點運算資源分佈更加均勻。

內容來源:部落格園 作者:yong374767047

以下是我們的社群介紹,歡迎各種合作、交流、學習:)

image