死磕以太坊原始碼分析之Ethash共識演算法
阿新 • • 發佈:2020-12-18
> 死磕以太坊原始碼分析之Ethash共識演算法
>
> 程式碼分支:https://github.com/ethereum/go-ethereum/tree/v1.9.9
## 引言
目前以太坊中有兩個共識演算法的實現:`clique`和`ethash`。而`ethash`是目前以太坊主網(`Homestead`版本)的`POW`共識演算法。
## 目錄結構
`ethash`模組位於以太坊專案目錄下的`consensus/ethash`目錄下。
- **algorithm.go**
實現了`Dagger-Hashimoto`演算法的所有功能,比如生成`cache`和`dataset`、根據`Header`和`Nonce`計算挖礦雜湊等。
- **api.go**
實現了供`RPC`使用的`api`方法。
- **consensus.go**
實現了以太坊共識介面的部分方法,包括`Verify`系列方法(`VerifyHeader`、`VerifySeal`等)、`Prepare`和`Finalize`、`CalcDifficulty`、`Author`、`SealHash`。
- **ethash.go**
實現了`cache`結構體和`dataset`結構體及它們各自的方法、`MakeCache`/`MakeDataset`函式、`Ethash`物件的`New`函式,和`Ethash`的內部方法。
- **sealer.go**
實現了共識介面的`Seal`方法,和`Ethash`的內部方法`mine`。這些方法實現了`ethash`的挖礦功能。
## Ethash 設計原理
### Ethash設計目標
以太坊設計共識演算法時,期望達到三個目的:
1. 抗`ASIC`性:為演算法建立專用硬體的優勢應儘可能小,讓普通計算機使用者也能使用CPU進行開採。
- 通過記憶體限制來抵制(`ASIC`使用礦機記憶體昂貴)
- 大量隨機讀取記憶體資料時計算速度就不僅僅受限於計算單元,更受限於記憶體的讀出速度。
2. 輕客戶端可驗證性: 一個區塊應能被輕客戶端快速有效校驗。
3. 礦工應該要求儲存完整的區塊鏈狀態。
### 雜湊資料集
`ethash`要計算雜湊,需要先有一塊資料集。這塊資料集較大,初始大小大約有`1G`,每隔 3 萬個區塊就會更新一次,且每次更新都會比之前變大`8M`左右。計算雜湊的資料來源就是從這塊資料集中來的;而決定使用資料集中的哪些資料進行雜湊計算的,才是`header`的資料和`Nonce`欄位。這部分是由`Dagger`演算法實現的。
#### Dagger
`Dagger`演算法是用來生成資料集`Dataset`的,核心的部分就是`Dataset`的生成方式和組織結構。
可以把`Dataset`想成多個`item`(**dataItem**)組成的陣列,每個`item`是`64`位元組的byte陣列(一條雜湊)。`dataset`的初始大小約為`1G`,每隔3萬個區塊(一個`epoch`區間)就會更新一次,且每次更新都會比之前變大`8M`左右。
`Dataset`的每個`item`是由一個快取塊(`cache`)生成的,快取塊也可以看做多個`item`(**cacheItem**)組成,快取塊佔用的記憶體要比`dataset`小得多,它的初始大小約為`16M`。同`dataset`類似,每隔 3 萬個區塊就會更新一次,且每次更新都會比之前變大`128K`左右。
生成一條`dataItem`的程是:從快取塊中“隨機”(這裡的“隨機”不是真的隨機數,而是指事前不能確定,但每次計算得到的都是一樣的值)選擇一個`cacheItem`進行計算,得的結果參與下次計算,這個過程會迴圈 256 次。
快取塊是由`seed`生成的,而`seed`的值與塊的高度有關。所以生成`dataset`的過程如下圖所示:
![image-20201213144908721](https://tva1.sinaimg.cn/large/0081Kckwgy1glm8arq9t7j30x80eytji.jpg)
`Dagger`還有一個關鍵的地方,就是確定性。即同一個`epoch`內,每次計算出來的`seed`、快取、`dataset`都是相同的。否則對於同一個區塊,挖礦的人和驗證的人使用不同的`dataset`,就沒法進行驗證了。
-----
### Hashimoto演算法
是`Thaddeus Dryja`創造的。旨在通過`IO`限制來抵制礦機。在挖礦過程中,使記憶體讀取限制條件,由於記憶體裝置本身會比計算裝置更加便宜以及普遍,在記憶體升級優化方面,全世界的大公司也都投入巨大,以使記憶體能夠適應各種使用者場景,所以有了隨機訪問記憶體的概念`RAM`,因此,現有的記憶體可能會比較接近最優的評估演算法。`Hashimoto`演算法使用區塊鏈作為源資料,滿足了上面的 1 和 3 的要求。
它的作用就是使用區塊Header的雜湊和Nonce欄位、利用dataset資料,生成一個最終的雜湊值。
-------
## 原始碼解析
### 生成雜湊資料集
`generate`函式位於`ethash.go`檔案中,主要是為了生成`dataset`,其中包擴以下內容。
#### 生成cache size
`cache size` 主要*某個特定塊編號的ethash驗證快取的大小* *, `epochLength` 為 30000,如果`epoch` 小於 2048,則從已知的`epoch`返回相應的`cache size`,否則重新計算`epoch`
`cache`的大小是線性增長的,`size`的值等於(2^24^ + 2^17^ * epoch - 64),用這個值除以 64 看結果是否是一個質數,如果不是,減去128 再重新計算,直到找到最大的質數為止。
```go
csize := cacheSize(d.epoch*epochLength + 1)
```
```go
func cacheSize(block uint64) uint64 {
epoch := int(block / epochLength)
if epoch < maxEpoch {
return cacheSizes[epoch]
}
return calcCacheSize(epoch)
}
```
```go
func calcCacheSize(epoch int) uint64 {
size := cacheInitBytes + cacheGrowthBytes*uint64(epoch) - hashBytes
for !new(big.Int).SetUint64(size / hashBytes).ProbablyPrime(1) { // Always accurate for n < 2^64
size -= 2 * hashBytes
}
return size
}
```
#### 生成dataset size
`dataset Size` 主要*某個特定塊編號的ethash驗證快取的大小* , 類似上面生成`cache size`
```go
dsize := datasetSize(d.epoch*epochLength + 1)
```
```go
func datasetSize(block uint64) uint64 {
epoch := int(block / epochLength)
if epoch < maxEpoch {
return datasetSizes[epoch]
}
return calcDatasetSize(epoch)
}
```
#### 生成 seed 種子
*seedHash是用於生成驗證快取和挖掘資料集的種子。*長度為 32。
```go
seed := seedHash(d.epoch*epochLength + 1)
```
```go
func seedHash(block uint64) []byte {
seed := make([]byte, 32)
if block < epochLength {
return seed
}
keccak256 := makeHasher(sha3.NewLegacyKeccak256())
for i := 0; i < int(block/epochLength); i++ {
keccak256(seed, seed)
}
return seed
}
```
#### 生成cache
```go
generateCache(cache, d.epoch, seed)
```
接下來分析`generateCache`的關鍵程式碼:
先了解一下**hashBytes**,在下面的計算中都是以此為單位,它的值為 64 ,相當於一個`keccak512`雜湊的長度,下文以**item**稱呼`[hashBytes]byte`。
①:初始化`cache`
此迴圈用來初始化`cache`:先將`seed`的雜湊填入`cache`的第一個`item`,隨後使用前一個`item`的雜湊,填充後一個`item`。
```go
for offset := uint64(hashBytes); offset < size; offset += hashBytes {
keccak512(cache[offset:], cache[offset-hashBytes:offset])
atomic.AddUint32(&progress, 1)
}
```
②:對cache中資料按規則做異或
為對於每一個`item`(`srcOff`),“隨機”選一個`item`(`xorOff`)與其進行異或運算;將運算結果的雜湊寫入`dstOff`中。這個運算邏輯將進行`cacheRounds`次。
兩個需要注意的地方:
- 一是`srcOff`是從尾部向頭部變化的,而`dstOff`是從頭部向尾部變化的。並且它倆是對應的,即當`srcOff`代表倒數第x個item時,`dstOff`則代表正數第x個item。
- 二是`xorOff`的選取。注意我們剛才的“隨機”是打了引號的。`xorOff`的值看似隨機,因為在給出`seed`之前,你無法知道xorOff的值是多少;但一旦`seed`的值確定了,那麼每一次`xorOff`的值都是確定的。而seed的值是由區塊的高度決定的。這也是同一個`epoch`內總是能得到相同`cache`資料的原因。
```GO
for i := 0; i < cacheRounds; i++ {
for j := 0; j < rows; j++ {
var (
srcOff = ((j - 1 + rows) % rows) * hashBytes
dstOff = j * hashBytes
xorOff = (binary.LittleEndian.Uint32(cache[dstOff:]) % uint32(rows)) * hashBytes
)
bitutil.XORBytes(temp, cache[srcOff:srcOff+hashBytes], cache[xorOff:xorOff+hashBytes])
keccak512(cache[dstOff:], temp)
atomic.AddUint32(&progress, 1)
}
}
```
------
#### 生成dataset
`dataset`大小的計算和`cache`類似,量級不同:2^30^ + 2^23^ * epoch - 128,然後每次減256尋找最大質數。
生成資料是一個迴圈,每次生成64個位元組,主要的函式是`generateDatasetItem`:
`generateDatasetItem`的資料來源就是`cache`資料,而最終的dataset值會儲存在mix變數中。整個過程也是由多個迴圈構成。
①:初始化`mix`變數
根據cache值對`mix`變數進行初始化。其中`hashWords`代表的是一個`hash`裡有多少個`word`值:一個`hash`的長度為`hashBytes`即64位元組,一個`word`(uint32型別)的長度為 4 位元組,因此`hashWords`值為 16。選取`cache`中的哪一項資料是由引數`index`和`i`變數決定的。
```go
mix := make([]byte, hashBytes)
binary.LittleEndian.PutUint32(mix, cache[(index%rows)*hashWords]^index)
for i := 1; i < hashWords; i++ {
binary.LittleEndian.PutUint32(mix[i*4:], cache[(index%rows)*hashWords+uint32(i)])
}
keccak512(mix, mix)
```
②:將`mix`轉換成`[]uint32`型別
```go
intMix := make([]uint32, hashWords)
for i := 0; i < len(intMix); i++ {
intMix[i] = binary.LittleEndian.Uint32(mix[i*4:])
}
```
③:將`cache`資料聚合進`intmix`
```go
for i := uint32(0); i < datasetParents; i++ {
parent := fnv(index^i, intMix[i%16]) % rows
fnvHash(intMix, cache[parent*hashWords:])
}
```
`FNV`雜湊演算法,是一種不需要使用金鑰的雜湊演算法。
這個演算法很簡單:a乘以FNV質數0x01000193,然後再和b異或。
首先用這個演算法算出一個索引值,利用這個索引從`cache`中選出一個值(`data`),然後對`mix`中的每個位元組都計算一次`FNV`,得到最終的雜湊值。
```go
func fnv(a, b uint32) uint32 {
return a*0x01000193 ^ b
}
func fnvHash(mix []uint32, data []uint32) {
for i := 0; i < len(mix); i++ {
mix[i] = mix[i]*0x01000193 ^ data[i]
}
}
```
④:將`intMix`又恢復成`mix`並計算`mix`的雜湊返回
```go
for i, val := range intMix {
binary.LittleEndian.PutUint32(mix[i*4:], val)
}
keccak512(mix, mix)
return mix
```
`generateCache`和`generateDataset`是實現`Dagger`演算法的核心函式,到此整個生成雜湊資料集的的過程結束。
--------
### 共識引擎核心函式
程式碼位於`consensus.go`
![image-20201214150532321](https://tva1.sinaimg.cn/large/0081Kckwgy1glnee6vhalj31py0lkgq4.jpg)
①:`Author`
```go
// 返回coinbase, coinbase是打包第一筆交易的礦工的地址
func (ethash *Ethash) Author(header *types.Header) (common.Address, error) {
return header.Coinbase, nil
}
```
②:`VerifyHeader`
主要有兩步檢查,第一步檢查**header是否已知**或者**是未知的祖先**,第二步是`ethash`的檢查:
2.1 header.Extra 不能超過32位元組
```go
if uint64(len(header.Extra)) > params.MaximumExtraDataSize { // 不超過32位元組
return fmt.Errorf("extra-data too long: %d > %d", len(header.Extra), params.MaximumExtraDataSize)
}
```
2.2 時間戳不能超過15秒,15秒以後的就被認定為未來的塊
```go
if !uncle {
if header.Time > uint64(time.Now().Add(allowedFutureBlockTime).Unix()) {
return consensus.ErrFutureBlock
}
}
```
2.3 當前header的時間戳小於父塊的
```go
if header.Time <= parent.Time { // 當前header的時間小於等於父塊的
return errZeroBlockTime
}
```
2.4 根據時間戳和父塊的難度來驗證塊的難度
```go
expected := ethash.CalcDifficulty(chain, header.Time, parent)
if expected.Cmp(header.Difficulty) != 0 {
return fmt.Errorf("invalid difficulty: have %v, want %v", header.Difficulty, expected)
}
```
2.5驗證`gas limit`小於2^63^ -1
```go
cap := uint64(0x7fffffffffffffff)
if header.GasLimit > cap {
return fmt.Errorf("invalid gasLimit: have %v, max %v", header.GasLimit, cap)
}
```
2.6 確認`gasUsed`為<= `gasLimit`
```go
if header.GasUsed > header.GasLimit {
return fmt.Errorf("invalid gasUsed: have %d, gasLimit %d", header.GasUsed, header.GasLimit)
}
```
2.7 驗證塊號是父塊加1
```go
if diff := new(big.Int).Sub(header.Number, parent.Number); diff.Cmp(big.NewInt(1)) != 0 {
return consensus.ErrInvalidNumber
}
```
2.8檢查給定的塊是否滿足pow難度要求
```go
if seal {
if err := ethash.VerifySeal(chain, header); err != nil {
return err
}
}
```
③:`VerifyUncles`
3.1叔叔塊最多兩個
```go
if len(block.Uncles()) > maxUncles {
return errTooManyUncles
}
```
3.2收集叔叔塊和祖先塊
```GO
number, parent := block.NumberU64()-1, block.ParentHash()
for i := 0; i < 7; i++ {
ancestor := chain.GetBlock(parent, number)
if ancestor == nil {
break
}
ancestors[ancestor.Hash()] = ancestor.Header()
for _, uncle := range ancestor.Uncles() {
uncles.Add(uncle.Hash())
}
parent, number = ancestor.ParentHash(), number-1
}
ancestors[block.Hash()] = block.Header()
uncles.Add(block.Hash())
```
3.3 確保叔塊只被獎勵一次且叔塊有個有效的祖先
```GO
for _, uncle := range block.Uncles() {
// Make sure every uncle is rewarded only once
hash := uncle.Hash()
if uncles.Contains(hash) {
return errDuplicateUncle
}
uncles.Add(hash)
// Make sure the uncle has a valid ancestry
if ancestors[hash] != nil {
return errUncleIsAncestor
}
if ancestors[uncle.ParentHash] == nil || uncle.ParentHash == block.ParentHash() {
return errDanglingUncle
}
if err := ethash.verifyHeader(chain, uncle, ancestors[uncle.ParentHash], true, true); err != nil {
return err
}
```
④:`Prepare`
> 初始化`header`的`Difficulty`欄位
```go
parent := chain.GetHeader(header.ParentHash, header.Number.Uint64()-1)
if parent == nil {
return consensus.ErrUnknownAncestor
}
header.Difficulty = ethash.CalcDifficulty(chain, header.Time, parent)
return nil
```
⑤:`Finalize`會執行交易後的所有狀態修改(例如,區塊獎勵),但**不會組裝**該區塊。
5.1累積任何塊和叔塊的獎勵
```go
accumulateRewards(chain.Config(), state, header, uncles)
```
5.2計算狀態樹的根雜湊並提交到`header`
```go
header.Root = state.IntermediateRoot(chain.Config().IsEIP158(header.Number))
```
⑥:`FinalizeAndAssemble` 執行任何交易後狀態修改(例如,塊獎勵),並組裝最終塊。
```GO
func (ethash *Ethash) FinalizeAndAssemble(chain consensus.ChainReader, header *types.Header, state *state.StateDB, txs []*types.Transaction, uncles []*types.Header, receipts []*types.Receipt) (*types.Block, error) {
accumulateRewards(chain.Config(), state, header, uncles)
header.Root = state.IntermediateRoot(chain.Config().IsEIP158(header.Number))
return types.NewBlock(header, txs, uncles, receipts), nil
}
```
很明顯就是比`Finalize`多了 `types.NewBlock`
⑦:`SealHash`返回在`seal`之前塊的雜湊(會跟`seal`之後的塊雜湊不同)
```go
func (ethash *Ethash) SealHash(header *types.Header) (hash common.Hash) {
hasher := sha3.NewLegacyKeccak256()
rlp.Encode(hasher, []interface{}{
header.ParentHash,
header.UncleHash,
header.Coinbase,
header.Root,
header.TxHash,
header.ReceiptHash,
header.Bloom,
header.Difficulty,
header.Number,
header.GasLimit,
header.GasUsed,
header.Time,
header.Extra,
})
hasher.Sum(hash[:0])
return hash
}
```
⑧:`Seal`給定的輸入塊生成一個新的密封請求(**挖礦**),並將結果推送到給定的通道中。
注意,該方法將立即返回並將非同步傳送結果。 根據共識演算法,可能還會返回多個結果。這部分會在下面的挖礦中具體分析,這裡跳過。
-------
### 挖礦細節
> 大家在閱讀本文時有任何疑問均可留言給我,我一定會及時回覆。如果覺得寫得不錯可以關注最下方**參考**的 `github專案`,可以第一時間關注作者文章動態。
挖礦的核心介面定義:
```go
Seal(chain ChainReader, block *types.Block, results chan<- *types.Block, stop <-chan struct{}) error
```
進入到`seal`函式:
①:如果執行錯誤的`POW`,直接返回空的`nonce`和`MixDigest`,同時塊也是空塊。
```go
if ethash.config.PowMode == ModeFake || ethash.config.PowMode == ModeFullFake {
header := block.Header()
header.Nonce, header.MixDigest = types.BlockNonce{}, common.Hash{}
select {
case results <- block.WithSeal(header):
default:
ethash.config.Log.Warn("Sealing result is not read by miner", "mode", "fake", "sealhash", ethash.SealHash(block.Header()))
}
return nil
}
```
②:共享`pow`的話,則轉到它的共享物件執行`Seal`操作
```go
if ethash.shared != nil {
return ethash.shared.Seal(chain, block, results, stop)
}
```
③:獲取種子源,並根據其生成`ethash`需要的種子
```go
f ethash.rand == nil {
// 獲得種子
seed, err := crand.Int(crand.Reader, big.NewInt(math.MaxInt64))
if err != nil {
ethash.lock.Unlock()
return err
}
ethash.rand = rand.New(rand.NewSource(seed.Int64())) // 給rand賦值
}
```
④:挖礦的核心工作交給`mine`
```go
for i := 0; i < threads; i++ {
pend.Add(1)
go func(id int, nonce uint64) {
defer pend.Done()
ethash.mine(block, id, nonce, abort, locals) // 真正執行挖礦的動作
}(i, uint64(ethash.rand.Int63()))
}
```
⑤:處理挖礦的結果
- 外部意外中止,停止所有挖礦執行緒
- 其中一個執行緒挖到正確塊,中止其他所有執行緒
- ethash物件發生改變,停止當前所有操作,重啟當前方法
```go
go func() {
var result *types.Block
select {
case <-stop:
close(abort)
case result = <-locals:
select {
case results <- result: //其中一個執行緒挖到正確塊,中止其他所有執行緒
default:
ethash.config.Log.Warn("Sealing result is not read by miner", "mode", "local", "sealhash", ethash.SealHash(block.Header()))
}
close(abort)
case <-ethash.update:
close(abort)
if err := ethash.Seal(chain, block, results, stop); err != nil {
ethash.config.Log.Error("Failed to restart sealing after update", "err", err)
}
}
```
由上可以知道`seal`的核心工作是由`mine`函式完成的,重點介紹一下。
`mine`函式其實也比較簡單,它是*真正的`pow`礦工,用來搜尋一個`nonce`值,`nonce`值開始於`seed`值,`seed`值是能最終產生正確的可匹配可驗證的區塊難度*
①:從區塊頭中提取相關資料,放在全域性變數域中
```go
var (
header = block.Header()
hash = ethash.SealHash(header).Bytes()
target = new(big.Int).Div(two256, header.Difficulty) // 這是用來驗證的target
number = header.Number.Uint64()
dataset = ethash.dataset(number, false)
)
```
②:開始產生隨機`nonce`,直到我們中止或找到一個好的`nonce`
```GO
var (
attempts = int64(0)
nonce = seed
)
```
③: 聚集完整的`dataset`資料,為特定的header和nonce產生最終雜湊值
```go
func hashimotoFull(dataset []uint32, hash []byte, nonce uint64) ([]byte, []byte) {
//定義一個lookup函式,用於在資料集中查詢資料
lookup := func(index uint32) []uint32 {
offset := index * hashWords //hashWords是上面定義的常量值= 16
return dataset[offset : offset+hashWords]
}
return hashimoto(hash, nonce, uint64(len(dataset))*4, lookup)
}
```
可以發現實際上`hashimotoFull`函式做的工作就是將原始資料集進行了讀取分割,然後傳給`hashimoto`函式。接下來重點分析`hashimoto`函式:
3.1根據seed獲取區塊頭
```go
rows := uint32(size / mixBytes) ①
seed := make([]byte, 40) ②
copy(seed, hash) ③
binary.LittleEndian.PutUint64(seed[32:], nonce)④
seed = crypto.Keccak512(seed)⑤
seedHead := binary.LittleEndian.Uint32(seed)⑥
```
1. 計算資料集的行數
2. 合併`header+nonce`到一個 40 位元組的`seed`
3. 將區塊頭的`hash`拷貝到`seed`中
4. 將`nonce`值填入`seed`的後(40-32=8)位元組中去,(nonce本身就是`uint64`型別,是 64 位,對應 8 位元組大小),正好把`hash`和`nonce`完整的填滿了 40 位元組的 seed
5. `Keccak512`加密`seed`
6. 從`seed`中獲取區塊頭
3.2 從複製的種子開始混合
- `mixBytes`常量= 128,`mix`的長度為 32,元素為`uint32`,是 32位,對應為 4 位元組大小。所以`mix`總共大小為 4*32=128 位元組大小
```go
mix := make([]uint32, mixBytes/4)
for i := 0; i < len(mix); i++ {
mix[i] = binary.LittleEndian.Uint32(seed[i%16*4:])
}
```
3.3 混合隨機資料集節點
```go
temp := make([]uint32, len(mix))//與mix結構相同,長度相同
for i := 0; i < loopAccesses; i++ {
parent := fnv(uint32(i)^seedHead, mix[i%len(mix)]) % rows
for j := uint32(0); j < mixBytes/hashBytes; j++ {
copy(temp[j*hashWords:], lookup(2*parent+j))
}
fnvHash(mix, temp)
}
```
3.4 壓縮混合
```go
for i := 0; i < len(mix); i += 4 {
mix[i/4] = fnv(fnv(fnv(mix[i], mix[i+1]), mix[i+2]), mix[i+3])
}
mix = mix[:len(mix)/4]
digest := make([]byte, common.HashLength)
for i, val := range mix {
binary.LittleEndian.PutUint32(digest[i*4:], val)
}
return digest, crypto.Keccak256(append(seed, digest...))
```
最終返回的是`digest`和`digest`與`seed`的雜湊;而`digest`其實就是`mix`的`[]byte`形式。在前面`Ethash.mine`的程式碼中我們已經看到使用第二個返回值與`target`變數進行比較,以確定這是否是一個有效的雜湊值。
-----
### 驗證pow
挖礦資訊的驗證有兩部分:
1. 驗證`Header.Difficulty`是否正確
2. 驗證`Header.MixDigest`和`Header.Nonce`是否正確
①:驗證`Header.Difficulty`的程式碼主要在`Ethash.verifyHeader`中:
```go
func (ethash *Ethash) verifyHeader(chain consensus.ChainReader, header, parent *types.Header, uncle bool, seal bool) error {
......
expected := ethash.CalcDifficulty(chain, header.Time.Uint64(), parent)
if expected.Cmp(header.Difficulty) != 0 {
return fmt.Errorf("invalid difficulty: have %v, want %v", header.Difficulty, expected)
}
}
```
通過區塊高度和時間差作為引數來計算`Difficulty`值,然後與待驗證的區塊的`Header.Difficulty`欄位進行比較,如果相等則認為是正確的。
②:`MixDigest`和`Nonce`的驗證主要是在`Header.verifySeal`中:
驗證的方式:使用`Header.Nonce`和頭部雜湊通過`hashimoto`重新計算一遍`MixDigest`和`result`雜湊值,並且驗證的節點是不需要dataset資料的。
----
## 總結&參考
> https://mindcarver.cn ☆☆☆
>
> https://github.com/blockchainGuide ☆☆☆
>
> https://eth.wiki/concepts/ethash/design-rationale
>
> https://eth.wiki/concepts/ethash/dag
>
> https://www.vijaypradeep.com/blog/2017-04-28-ethereums-memory-hardness-ex