死磕以太坊源碼分析之Ethash共識算法

死磕以太坊源碼分析之Ethash共識算法git

代碼分支:https://github.com/ethereum/go-ethereum/tree/v1.9.9github

引言

目前以太坊中有兩個共識算法的實現:cliqueethash。而ethash是目前以太坊主網(Homestead版本)的POW共識算法。算法

目錄結構

ethash模塊位於以太坊項目目錄下的consensus/ethash目錄下。api

  • algorithm.go
    實現了Dagger-Hashimoto算法的全部功能,好比生成cachedataset、根據HeaderNonce計算挖礦哈希等。
  • api.go
    實現了供RPC使用的api方法。
  • consensus.go
    實現了以太坊共識接口的部分方法,包括Verify系列方法(VerifyHeaderVerifySeal等)、PrepareFinalizeCalcDifficultyAuthorSealHash
  • 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的生成方式和組織結構。app

能夠把Dataset想成多個itemdataItem)組成的數組,每一個item64字節的byte數組(一條哈希)。dataset的初始大小約爲1G,每隔3萬個區塊(一個epoch區間)就會更新一次,且每次更新都會比以前變大8M左右。異步

Dataset的每一個item是由一個緩存塊(cache)生成的,緩存塊也能夠看作多個itemcacheItem)組成,緩存塊佔用的內存要比dataset小得多,它的初始大小約爲16M。同dataset相似,每隔 3 萬個區塊就會更新一次,且每次更新都會比以前變大128K左右。ide

生成一條dataItem的程是:從緩存塊中「隨機」(這裏的「隨機」不是真的隨機數,而是指事前不能肯定,但每次計算獲得的都是同樣的值)選擇一個cacheItem進行計算,得的結果參與下次計算,這個過程會循環 256 次。函數

緩存塊是由seed生成的,而seed的值與塊的高度有關。因此生成dataset的過程以下圖所示:

image-20201213144908721

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的值等於(224 + 217 * epoch - 64),用這個值除以 64 看結果是不是一個質數,若是不是,減去128 再從新計算,直到找到最大的質數爲止。

csize := cacheSize(d.epoch*epochLength + 1)
func cacheSize(block uint64) uint64 {
	epoch := int(block / epochLength)
	if epoch < maxEpoch {
		return cacheSizes[epoch]
	}
	return calcCacheSize(epoch)
}
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

dsize := datasetSize(d.epoch*epochLength + 1)
func datasetSize(block uint64) uint64 {
	epoch := int(block / epochLength)
	if epoch < maxEpoch {
		return datasetSizes[epoch]
	}
	return calcDatasetSize(epoch)
}

生成 seed 種子

seedHash是用於生成驗證緩存和挖掘數據集的種子。長度爲 32。

seed := seedHash(d.epoch*epochLength + 1)
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

generateCache(cache, d.epoch, seed)

接下來分析generateCache的關鍵代碼:

先了解一下hashBytes,在下面的計算中都是以此爲單位,它的值爲 64 ,至關於一個keccak512哈希的長度,下文以item稱呼[hashBytes]byte

①:初始化cache

此循環用來初始化cache:先將seed的哈希填入cache的第一個item,隨後使用前一個item的哈希,填充後一個item

for offset := uint64(hashBytes); offset < size; offset += hashBytes {
		keccak512(cache[offset:], cache[offset-hashBytes:offset])
		atomic.AddUint32(&progress, 1)
	}

②:對cache中數據按規則作異或

爲對於每個itemsrcOff),「隨機」選一個itemxorOff)與其進行異或運算;將運算結果的哈希寫入dstOff中。這個運算邏輯將進行cacheRounds次。

兩個須要注意的地方:

  • 一是srcOff是從尾部向頭部變化的,而dstOff是從頭部向尾部變化的。而且它倆是對應的,即當srcOff表明倒數第x個item時,dstOff則表明正數第x個item。
  • 二是xorOff的選取。注意咱們剛纔的「隨機」是打了引號的。xorOff的值看似隨機,由於在給出seed以前,你沒法知道xorOff的值是多少;但一旦seed的值肯定了,那麼每一次xorOff的值都是肯定的。而seed的值是由區塊的高度決定的。這也是同一個epoch內老是能獲得相同cache數據的緣由。
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相似,量級不一樣:230 + 223 * epoch - 128,而後每次減256尋找最大質數。

生成數據是一個循環,每次生成64個字節,主要的函數是generateDatasetItem

generateDatasetItem的數據來源就是cache數據,而最終的dataset值會存儲在mix變量中。整個過程也是由多個循環構成。

①:初始化mix變量

根據cache值對mix變量進行初始化。其中hashWords表明的是一個hash裏有多少個word值:一個hash的長度爲hashBytes即64字節,一個word(uint32類型)的長度爲 4 字節,所以hashWords值爲 16。選取cache中的哪一項數據是由參數indexi變量決定的。

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類型

intMix := make([]uint32, hashWords)
	for i := 0; i < len(intMix); i++ {
		intMix[i] = binary.LittleEndian.Uint32(mix[i*4:])
	}

③:將cache數據聚合進intmix

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,獲得最終的哈希值。

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的哈希返回

for i, val := range intMix {
		binary.LittleEndian.PutUint32(mix[i*4:], val)
	}
	keccak512(mix, mix)
	return mix

generateCachegenerateDataset是實現Dagger算法的核心函數,到此整個生成哈希數據集的的過程結束。


共識引擎核心函數

代碼位於consensus.go

image-20201214150532321

①:Author

// 返回coinbase, coinbase是打包第一筆交易的礦工的地址
func (ethash *Ethash) Author(header *types.Header) (common.Address, error) {
	return header.Coinbase, nil
}

②:VerifyHeader

主要有兩步檢查,第一步檢查header是否已知或者是未知的祖先,第二步是ethash的檢查:

2.1 header.Extra 不能超過32字節

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秒之後的就被認定爲將來的塊

if !uncle {
		if header.Time > uint64(time.Now().Add(allowedFutureBlockTime).Unix()) {
			return consensus.ErrFutureBlock
		}
	}

2.3 當前header的時間戳小於父塊的

if header.Time <= parent.Time { // 當前header的時間小於等於父塊的
		return errZeroBlockTime
	}

2.4 根據時間戳和父塊的難度來驗證塊的難度

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小於263 -1

cap := uint64(0x7fffffffffffffff)
	if header.GasLimit > cap {
		return fmt.Errorf("invalid gasLimit: have %v, max %v", header.GasLimit, cap)
	}

2.6 確認gasUsed爲<= gasLimit

if header.GasUsed > header.GasLimit {
		return fmt.Errorf("invalid gasUsed: have %d, gasLimit %d", header.GasUsed, header.GasLimit)
	}

2.7 驗證塊號是父塊加1

if diff := new(big.Int).Sub(header.Number, parent.Number); diff.Cmp(big.NewInt(1)) != 0 {
		return consensus.ErrInvalidNumber
	}

2.8檢查給定的塊是否知足pow難度要求

if seal {
		if err := ethash.VerifySeal(chain, header); err != nil {
			return err
		}
	}

③:VerifyUncles

3.1叔叔塊最多兩個

if len(block.Uncles()) > maxUncles {
		return errTooManyUncles
	}

3.2收集叔叔塊和祖先塊

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 確保叔塊只被獎勵一次且叔塊有個有效的祖先

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

初始化headerDifficulty字段

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累積任何塊和叔塊的獎勵

accumulateRewards(chain.Config(), state, header, uncles)

5.2計算狀態樹的根哈希並提交到header

header.Root = state.IntermediateRoot(chain.Config().IsEIP158(header.Number))

⑥:FinalizeAndAssemble 運行任何交易後狀態修改(例如,塊獎勵),並組裝最終塊。

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以後的塊哈希不一樣)

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項目,能夠第一時間關注做者文章動態。

挖礦的核心接口定義:

Seal(chain ChainReader, block *types.Block, results chan<- *types.Block, stop <-chan struct{}) error

進入到seal函數:

①:若是運行錯誤的POW,直接返回空的nonceMixDigest,同時塊也是空塊。

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操做

if ethash.shared != nil {
		return ethash.shared.Seal(chain, block, results, stop)
	}

③:獲取種子源,並根據其生成ethash須要的種子

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

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 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值是能最終產生正確的可匹配可驗證的區塊難度

①:從區塊頭中提取相關數據,放在全局變量域中

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

var (
		attempts = int64(0)
		nonce    = seed
	)

③: 彙集完整的dataset數據,爲特定的header和nonce產生最終哈希值

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獲取區塊頭

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 字節大小),正好把hashnonce完整的填滿了 40 字節的 seed
  5. Keccak512加密seed
  6. seed中獲取區塊頭

3.2 從複製的種子開始混合

  • mixBytes常量= 128,mix的長度爲 32,元素爲uint32,是 32位,對應爲 4 字節大小。因此mix總共大小爲 4*32=128 字節大小
mix := make([]uint32, mixBytes/4)
	for i := 0; i < len(mix); i++ {
		mix[i] = binary.LittleEndian.Uint32(seed[i%16*4:])
	}

3.3 混合隨機數據集節點

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 壓縮混合

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...))

最終返回的是digestdigestseed的哈希;而digest其實就是mix[]byte形式。在前面Ethash.mine的代碼中咱們已經看到使用第二個返回值與target變量進行比較,以肯定這是不是一個有效的哈希值。


驗證pow

挖礦信息的驗證有兩部分:

  1. 驗證Header.Difficulty是否正確
  2. 驗證Header.MixDigestHeader.Nonce是否正確

①:驗證Header.Difficulty的代碼主要在Ethash.verifyHeader中:

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字段進行比較,若是相等則認爲是正確的。

②:MixDigestNonce的驗證主要是在Header.verifySeal中:

驗證的方式:使用Header.Nonce和頭部哈希經過hashimoto從新計算一遍MixDigestresult哈希值,而且驗證的節點是不須要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-explained/

相關文章
相關標籤/搜索