死磕以太坊源码分析之Ethash共识算法
引言
目前以太坊中有两个共识算法的实现: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设计目标
以太坊设计共识算法时,期望达到三个目的:
- 抗
ASIC性:为算法创建专用硬件的优势应尽可能小,让普通计算机用户也能使用CPU进行开采。- 通过内存限制来抵制(
ASIC使用矿机内存昂贵) - 大量随机读取内存数据时计算速度就不仅仅受限于计算单元,更受限于内存的读出速度。
- 通过内存限制来抵制(
- 轻客户端可验证性: 一个区块应能被轻客户端快速有效校验。
- 矿工应该要求存储完整的区块链状态。
哈希数据集
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的过程如下图所示:

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 再重新计算,直到找到最大的质数为止。
1 | csize := cacheSize(d.epoch*epochLength + 1) |
1 | func cacheSize(block uint64) uint64 { |
1 | func calcCacheSize(epoch int) uint64 { |
生成dataset size
dataset Size 主要某个特定块编号的ethash验证缓存的大小 , 类似上面生成cache size
1 | dsize := datasetSize(d.epoch*epochLength + 1) |
1 | func datasetSize(block uint64) uint64 { |
生成 seed 种子
seedHash是用于生成验证缓存和挖掘数据集的种子。长度为 32。
1 | seed := seedHash(d.epoch*epochLength + 1) |
1 | func seedHash(block uint64) []byte { |
生成cache
1 | generateCache(cache, d.epoch, seed) |
接下来分析generateCache的关键代码:
先了解一下hashBytes,在下面的计算中都是以此为单位,它的值为 64 ,相当于一个keccak512哈希的长度,下文以item称呼[hashBytes]byte。
①:初始化cache
此循环用来初始化cache:先将seed的哈希填入cache的第一个item,随后使用前一个item的哈希,填充后一个item。
1 | for offset := uint64(hashBytes); offset < size; offset += hashBytes { |
②:对cache中数据按规则做异或
为对于每一个item(srcOff),“随机”选一个item(xorOff)与其进行异或运算;将运算结果的哈希写入dstOff中。这个运算逻辑将进行cacheRounds次。
两个需要注意的地方:
- 一是
srcOff是从尾部向头部变化的,而dstOff是从头部向尾部变化的。并且它俩是对应的,即当srcOff代表倒数第x个item时,dstOff则代表正数第x个item。 - 二是
xorOff的选取。注意我们刚才的“随机”是打了引号的。xorOff的值看似随机,因为在给出seed之前,你无法知道xorOff的值是多少;但一旦seed的值确定了,那么每一次xorOff的值都是确定的。而seed的值是由区块的高度决定的。这也是同一个epoch内总是能得到相同cache数据的原因。
1 | for i := 0; i < cacheRounds; i++ { |
生成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变量决定的。
1 | mix := make([]byte, hashBytes) |
②:将mix转换成[]uint32类型
1 | intMix := make([]uint32, hashWords) |
③:将cache数据聚合进intmix
1 | for i := uint32(0); i < datasetParents; i++ { |
FNV哈希算法,是一种不需要使用密钥的哈希算法。
这个算法很简单:a乘以FNV质数0x01000193,然后再和b异或。
首先用这个算法算出一个索引值,利用这个索引从cache中选出一个值(data),然后对mix中的每个字节都计算一次FNV,得到最终的哈希值。
1 | func fnv(a, b uint32) uint32 { |
④:将intMix又恢复成mix并计算mix的哈希返回
1 | for i, val := range intMix { |
generateCache和generateDataset是实现Dagger算法的核心函数,到此整个生成哈希数据集的的过程结束。
共识引擎核心函数
代码位于consensus.go

①:Author
1 | // 返回coinbase, coinbase是打包第一笔交易的矿工的地址 |
②:VerifyHeader
主要有两步检查,第一步检查header是否已知或者是未知的祖先,第二步是ethash的检查:
2.1 header.Extra 不能超过32字节
1 | if uint64(len(header.Extra)) > params.MaximumExtraDataSize { // 不超过32字节 |
2.2 时间戳不能超过15秒,15秒以后的就被认定为未来的块
1 | if !uncle { |
2.3 当前header的时间戳小于父块的
1 | if header.Time <= parent.Time { // 当前header的时间小于等于父块的 |
2.4 根据时间戳和父块的难度来验证块的难度
1 | expected := ethash.CalcDifficulty(chain, header.Time, parent) |
2.5验证gas limit小于2^63^ -1
1 | cap := uint64(0x7fffffffffffffff) |
2.6 确认gasUsed为<= gasLimit
1 | if header.GasUsed > header.GasLimit { |
2.7 验证块号是父块加1
1 | if diff := new(big.Int).Sub(header.Number, parent.Number); diff.Cmp(big.NewInt(1)) != 0 { |
2.8检查给定的块是否满足pow难度要求
1 | if seal { |
③:VerifyUncles
3.1叔叔块最多两个
1 | if len(block.Uncles()) > maxUncles { |
3.2收集叔叔块和祖先块
1 | number, parent := block.NumberU64()-1, block.ParentHash() |
3.3 确保叔块只被奖励一次且叔块有个有效的祖先
1 | for _, uncle := range block.Uncles() { |
④:Prepare
初始化
header的Difficulty字段
1 | parent := chain.GetHeader(header.ParentHash, header.Number.Uint64()-1) |
⑤:Finalize会执行交易后的所有状态修改(例如,区块奖励),但不会组装该区块。
5.1累积任何块和叔块的奖励
1 | accumulateRewards(chain.Config(), state, header, uncles) |
5.2计算状态树的根哈希并提交到header
1 | header.Root = state.IntermediateRoot(chain.Config().IsEIP158(header.Number)) |
⑥:FinalizeAndAssemble 运行任何交易后状态修改(例如,块奖励),并组装最终块。
1 | 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) { |
很明显就是比Finalize多了 types.NewBlock
⑦:SealHash返回在seal之前块的哈希(会跟seal之后的块哈希不同)
1 | func (ethash *Ethash) SealHash(header *types.Header) (hash common.Hash) { |
⑧:Seal给定的输入块生成一个新的密封请求(挖矿),并将结果推送到给定的通道中。
注意,该方法将立即返回并将异步发送结果。 根据共识算法,可能还会返回多个结果。这部分会在下面的挖矿中具体分析,这里跳过。
挖矿细节
大家在阅读本文时有任何疑问均可留言给我,我一定会及时回复。如果觉得写得不错可以关注最下方参考的
github项目,可以第一时间关注作者文章动态。
挖矿的核心接口定义:
1 | Seal(chain ChainReader, block *types.Block, results chan<- *types.Block, stop <-chan struct{}) error |
进入到seal函数:
①:如果运行错误的POW,直接返回空的nonce和MixDigest,同时块也是空块。
1 | if ethash.config.PowMode == ModeFake || ethash.config.PowMode == ModeFullFake { |
②:共享pow的话,则转到它的共享对象执行Seal操作
1 | if ethash.shared != nil { |
③:获取种子源,并根据其生成ethash需要的种子
1 | f ethash.rand == nil { |
④:挖矿的核心工作交给mine
1 | for i := 0; i < threads; i++ { |
⑤:处理挖矿的结果
- 外部意外中止,停止所有挖矿线程
- 其中一个线程挖到正确块,中止其他所有线程
- ethash对象发生改变,停止当前所有操作,重启当前方法
1 | go func() { |
由上可以知道seal的核心工作是由mine函数完成的,重点介绍一下。
mine函数其实也比较简单,它是真正的pow矿工,用来搜索一个nonce值,nonce值开始于seed值,seed值是能最终产生正确的可匹配可验证的区块难度
①:从区块头中提取相关数据,放在全局变量域中
1 | var ( |
②:开始产生随机nonce,直到我们中止或找到一个好的nonce
1 | var ( |
③: 聚集完整的dataset数据,为特定的header和nonce产生最终哈希值
1 | func hashimotoFull(dataset []uint32, hash []byte, nonce uint64) ([]byte, []byte) { |
可以发现实际上hashimotoFull函数做的工作就是将原始数据集进行了读取分割,然后传给hashimoto函数。接下来重点分析hashimoto函数:
3.1根据seed获取区块头
1 | rows := uint32(size / mixBytes) ① |
- 计算数据集的行数
- 合并
header+nonce到一个 40 字节的seed - 将区块头的
hash拷贝到seed中 - 将
nonce值填入seed的后(40-32=8)字节中去,(nonce本身就是uint64类型,是 64 位,对应 8 字节大小),正好把hash和nonce完整的填满了 40 字节的 seed Keccak512加密seed- 从
seed中获取区块头
3.2 从复制的种子开始混合
mixBytes常量= 128,mix的长度为 32,元素为uint32,是 32位,对应为 4 字节大小。所以mix总共大小为 4*32=128 字节大小
1 | mix := make([]uint32, mixBytes/4) |
3.3 混合随机数据集节点
1 | temp := make([]uint32, len(mix))//与mix结构相同,长度相同 |
3.4 压缩混合
1 | for i := 0; i < len(mix); i += 4 { |
最终返回的是digest和digest与seed的哈希;而digest其实就是mix的[]byte形式。在前面Ethash.mine的代码中我们已经看到使用第二个返回值与target变量进行比较,以确定这是否是一个有效的哈希值。
验证pow
挖矿信息的验证有两部分:
- 验证
Header.Difficulty是否正确 - 验证
Header.MixDigest和Header.Nonce是否正确
①:验证Header.Difficulty的代码主要在Ethash.verifyHeader中:
1 | func (ethash *Ethash) verifyHeader(chain consensus.ChainReader, header, parent *types.Header, uncle bool, seal bool) error { |
通过区块高度和时间差作为参数来计算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/