死磕以太坊源码分析之Ethash共识算法git
代码分支:https://github.com/ethereum/go-ethereum/tree/v1.9.9github
目前以太坊中有两个共识算法的实现:clique
和ethash
。而ethash
是目前以太坊主网(Homestead
版本)的POW
共识算法。算法
ethash
模块位于以太坊项目目录下的consensus/ethash
目录下。api
Dagger-Hashimoto
算法的全部功能,好比生成cache
和dataset
、根据Header
和Nonce
计算挖矿哈希等。RPC
使用的api
方法。Verify
系列方法(VerifyHeader
、VerifySeal
等)、Prepare
和Finalize
、CalcDifficulty
、Author
、SealHash
。cache
结构体和dataset
结构体及它们各自的方法、MakeCache
/MakeDataset
函数、Ethash
对象的New
函数,和Ethash
的内部方法。Seal
方法,和Ethash
的内部方法mine
。这些方法实现了ethash
的挖矿功能。以太坊设计共识算法时,指望达到三个目的:数组
ASIC
性:为算法建立专用硬件的优点应尽量小,让普通计算机用户也能使用CPU进行开采。
ASIC
使用矿机内存昂贵)ethash
要计算哈希,须要先有一块数据集。这块数据集较大,初始大小大约有1G
,每隔 3 万个区块就会更新一次,且每次更新都会比以前变大8M
左右。计算哈希的数据源就是从这块数据集中来的;而决定使用数据集中的哪些数据进行哈希计算的,才是header
的数据和Nonce
字段。这部分是由Dagger
算法实现的。缓存
Dagger
算法是用来生成数据集Dataset
的,核心的部分就是Dataset
的生成方式和组织结构。app
能够把Dataset
想成多个item
(dataItem)组成的数组,每一个item
是64
字节的byte数组(一条哈希)。dataset
的初始大小约为1G
,每隔3万个区块(一个epoch
区间)就会更新一次,且每次更新都会比以前变大8M
左右。异步
Dataset
的每一个item
是由一个缓存块(cache
)生成的,缓存块也能够看作多个item
(cacheItem)组成,缓存块占用的内存要比dataset
小得多,它的初始大小约为16M
。同dataset
相似,每隔 3 万个区块就会更新一次,且每次更新都会比以前变大128K
左右。ide
生成一条dataItem
的程是:从缓存块中“随机”(这里的“随机”不是真的随机数,而是指事前不能肯定,但每次计算获得的都是同样的值)选择一个cacheItem
进行计算,得的结果参与下次计算,这个过程会循环 256 次。函数
缓存块是由seed
生成的,而seed
的值与块的高度有关。因此生成dataset
的过程以下图所示:
Dagger
还有一个关键的地方,就是肯定性。即同一个epoch
内,每次计算出来的seed
、缓存、dataset
都是相同的。不然对于同一个区块,挖矿的人和验证的人使用不一样的dataset
,就无法进行验证了。
是Thaddeus Dryja
创造的。旨在经过IO
限制来抵制矿机。在挖矿过程当中,使内存读取限制条件,因为内存设备自己会比计算设备更加便宜以及广泛,在内存升级优化方面,全世界的大公司也都投入巨大,以使内存可以适应各类用户场景,因此有了随机访问内存的概念RAM
,所以,现有的内存可能会比较接近最优的评估算法。Hashimoto
算法使用区块链做为源数据,知足了上面的 1 和 3 的要求。
它的做用就是使用区块Header的哈希和Nonce字段、利用dataset数据,生成一个最终的哈希值。
generate
函数位于ethash.go
文件中,主要是为了生成dataset
,其中包扩如下内容。
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
主要某个特定块编号的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) }
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 }
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中数据按规则作异或
为对于每个item
(srcOff
),“随机”选一个item
(xorOff
)与其进行异或运算;将运算结果的哈希写入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
大小的计算和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
中的哪一项数据是由参数index
和i
变量决定的。
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
generateCache
和generateDataset
是实现Dagger
算法的核心函数,到此整个生成哈希数据集的的过程结束。
代码位于consensus.go
①: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
初始化
header
的Difficulty
字段
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
,直接返回空的nonce
和MixDigest
,同时块也是空块。
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())) }
⑤:处理挖矿的结果
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)⑥
header+nonce
到一个 40 字节的seed
hash
拷贝到seed
中nonce
值填入seed
的后(40-32=8)字节中去,(nonce自己就是uint64
类型,是 64 位,对应 8 字节大小),正好把hash
和nonce
完整的填满了 40 字节的 seedKeccak512
加密seed
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...))
最终返回的是digest
和digest
与seed
的哈希;而digest
其实就是mix
的[]byte
形式。在前面Ethash.mine
的代码中咱们已经看到使用第二个返回值与target
变量进行比较,以肯定这是不是一个有效的哈希值。
挖矿信息的验证有两部分:
Header.Difficulty
是否正确Header.MixDigest
和Header.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
字段进行比较,若是相等则认为是正确的。
②:MixDigest
和Nonce
的验证主要是在Header.verifySeal
中:
验证的方式:使用Header.Nonce
和头部哈希经过hashimoto
从新计算一遍MixDigest
和result
哈希值,而且验证的节点是不须要dataset数据的。
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/