对于区块链系统来说,挖矿是保证区块链安全的重要手段(Block chain is secured by mining)。比特币的挖矿算法是比较成功的,经受了时间的考验(一个天然的bug bounty)。
但是比特币的挖矿算法有一些饱受争议的问题,比如只能用ASIC芯片才能挖到矿,这与去中心化背道而驰。理想的挖矿的方式是普通的PC也能参与挖矿,这样算力就能分散开来,发动51%攻击就比较困难了。所以很多加密货币设计mining puzzle的时候,希望能够做到ASIC resistance。
一个比较好的方式是设计出对内存高要求的puzzle,即memory-hard mining puzzle,对于ASIC芯片来说,计算力较强,但在内存访问上的性能没什么优势,所以memory-hard mining puzzle能够遏制ASIC芯片的作用。
Scrypt是对内存要求很高(memory-hard)的哈希函数,设计思想是增加对内存的访问,创建一个很大的数组,然后按照顺序填充伪随机数,第一个位置填充从seed运算来的数据,后面每个位置的值都是前一个位置取哈希得到的。这个数组的特点是后面位置的数据依赖前一个数据。
求解puzzle时,按照伪随机的顺序,读取某一个数,根据这个数进行一系列运算,计算出下一个数的位置,读取该位置数据,再根据这个数经过一系列运算得到下一个数的位置,最终可以查看数据是否符合要求。如下图所示:
假设这个数组足够大,就必须保存整个数组(或者保存奇数位置数据),才能高效地挖矿;如果不保存数组,那么每次循环都要进行大量地哈希运算,挖矿复杂度将大幅提升。
但是Scrypt的局限性是对于轻节点同样是内存高要求,另外也不能够对谜底进行快速的验证。莱特币在实际运行中,为了照顾轻节点,设置的数组仅有128KB,所以实际并没有达到ASIC resistance的目标。
挖矿算法要求难于求解,但是要易于验证(difficult to solve, but easy to verify)。
因此以太坊设计了一个16MB小数据集(cache)和一个1GB的大数据集(dataset),dataset由cache计算而来,矿工保存dataset用于挖矿,轻节点只要保存cache就可以进行验证。
cache的计算方式与与Scrypt类似,通过seed计算出第一个元素,然后依次取哈希,后面位置的数据为前一个位置数据的哈希,这样整个数组就全部填充为伪随机数了。如下图所示:
dataset每个元素都是从cache计算而来,每个元素都是从cached按照伪随机顺序读取一些元素,循环读取256次,即可得到dataset中的一个元素值。如下图所示:
求解puzzle时使用dataset即可,根据区块块头和nonce值算出一个哈希,映射到dataset中一个位置,读取该位置的值,继续按照伪随机的顺序从dataset中循环64次读取128个数(每次读取相邻的2个数),最后算出一个哈希,与target阈值比较 ,看是否符合要求;如果不符合要求则替换nonce值,重复上述过程,直到算出的哈希满足target阈值。如下图所示:
def mkcache(cache_size, seed):
cache = [hash(seed)]
for i in range(1,cache_size):
cache.append(hash[cache[-1]])
return cache
这个函数是通过seed计算出cache的伪代码。
伪代码略去了原来代码中对cache元素进一步的处理,只展示原理,即cache中元素按序生成,每个元素产生时与上一个元素相关。
每隔30000个块会重新生成seed(对原来的seed求哈希值),并且利用新的seed生成新的cache。
cache的初始大小为16M,每隔30000个块重新生成时增大初始大小的1/128 ——128K。
def cal_dataset_i(cahce, i)
cache_size = cache.size
mix = hash(cache[i%cache_size]^i)
for j in range(256):
cache_index = get_int(mix);
mix = make_item(mix,cache[cache_index%cache_size])
return hash(mix)
这是通过cache来生成dataset中第i个元素的伪代码。省略了大部分细节,展示原理。
这个dataset叫作DAG,初始大小是1G,也是每隔30000个块更新,同时增大初始大小的1/128——8M。
先通过cache中的第i%cache_size个元素生成初始的mix,因为两个不同的dataset元素可能对应同一个cache中的元素,为了保证每个初始的mix都不同,注意到i也参与了哈希计算。
随后循环256次,每次通过get_int_from_item来根据当前的mix值求得下一个要访问的cache元素的下标,用这个cache元素和mix通过make_item求得新的mix值。注意到由于初始的mix值都不同,所以访问cache的序列也都是不同的。
最终返回mix的哈希值,得到第i个dataset中的元素。
多次调用这个函数,就可以得到完整的dataset。
def calc_dataset(full_size, cache):
return [calc_dataset_item(cache,i) for i in range(full_size)]
这个函数通过不断调用前边介绍的calc_dataset_item函数来依次生成dataset中全部full_size个元素。
def hashimoto_full(header, nonce, full_size, dataset):
mix = hash(header, nonce);
for i in range(64):
dataset_index = get_int_from_item(mix) % full_size
mix = make_item(mix , dataset[dataset_index)
mix = make_item(mix , dataset[dataset_index + 1])
return hash(mix)
def hashimoto_light(header, nonce, full_size, cache):
mix = hash(header, nonce)
for i in range(64):
dataset_index = get_int(mix)%full_size
mix = hash(mix, cal_dataset_i(cache,dataset_index))
mix = hash(mix, cal_dataset_i(cache,dataset_index+1))
return hash(mix)
这个函数展示了ethash算法的puzzle:通过区块头、nonce以及DAG求出一个与target比较的值,矿工和轻节点使用的实现方法是不一样的。伪代码略去了大部分细节,展示原理。
先通过header和nonce求出一个初始的mix,然后进入64次循环,根据当前的mix值求出要访问的dataset的元素的下标,然后根据这个下标访问dataset中两个连续的的值。
最后返回mix的哈希值,用来和target比较。
注意到轻节点是临时计算出用到的dataset的元素,而矿工是直接访问内存,也就是必须在内存里存着这个1G的dataset,后边会分析这个的原因。
def mine(full_size, dataset, header, target):
nonce = random.randint(0, 2**64)
while hashimoto_full(header, nonce, full_size, dataset) > target:
nonce = (nonce + 1) % 2**64
return nonce
这是矿工挖矿的函数的伪代码,同样省略了一些细节,展示原理。
full_size指的是dataset的元素个数,dataset就是从cache生成的DAG,header是区块头,target就是挖矿的目标,我们需要调整nonce来使hashimoto_full的返回值小于等于target。
这里先随机初始化nonce,再一个个尝试nonce,直到得到的值小于target。
矿工需要保存整个dataset的原因:
“cache_index = get_int_from_item(mix)”代码表明通过cache生成dataset的元素时,下一个用到的cache中的元素的位置是通过当前用到的cache的元素的值计算得到的,这样具体的访问顺序事先不可预知,满足伪随机性。
由于矿工需要验证非常多的nonce,如果每次都要从16M的cache中重新生成的话,那挖矿的效率就太低了,而且这里面有大量的重复计算:随机选取的dataset的元素中有很多是重复的,可能是之前尝试别的nonce时用过的。所以,矿工采取以空间换时间的策略,把整个dataset保存下来。轻节点由于只验证一个nonce,验证的时候就直接生成要用到的dataset中的元素就行了。
以太坊的挖矿算法使得矿工主要依靠GPU来挖矿,起到了ASIC resistance的作用。
另外以太坊很早就计划从工作量证明(PoW)转向权益证明(PoS),对于ASIC厂商来说,因为ASIC研发周期最少1年,且成本较高,所以一直没有生产出效率非常高的以太坊ASIC矿机。
一般来说挖矿算法要尽可能让通用计算机参加,挖矿过程越民主,区块链越安全。但是也有一种观点:用通用设备挖矿反而不安全,因为用ASIC芯片发动攻击的成本较高,攻击成功后,反而造成比特币价格下跌。通用设备发动攻击则比较容易,不用专门购买设备,使用原本就在使用的服务器,或者租用云服务器就可以发起攻击。
比特币是每隔2016个区块调整一次挖矿难度,目标是维持出块时间在10min左右,以太坊中每个区块都会调整出块难度。
调整公式如下所示:
D ( H ) ≡ { D 0 , i f H i = 0 m a x ( D 0 , P ( H ) H d + x × ς 2 ) + ϵ , o t h e r w i s e D(H)≡\begin{cases} D_0,&\quad if\ H_i=0 \\\\ max(D_0,\ P(H)_{Hd}+x×\varsigma_2)\ +\ \epsilon,&\quad otherwise \end{cases} D(H)≡⎩⎪⎨⎪⎧D0,max(D0, P(H)Hd+x×ς2) + ϵ,if Hi=0otherwise
D 0 ≡ 131072 D_0≡131072 D0≡131072
自适应难度调整与2部分如下所示:
x ≡ [ P ( H ) H d 2048 ] x≡\left[\frac{P(H)_{Hd}}{2048}\right] x≡[2048P(H)Hd]
ς 2 ≡ m a x ( y − [ H s − P ( H ) H s 9 ] , − 99 ) \varsigma_2≡max\left(y-\left[\frac{H_s-P(H)_{Hs}}{9}\right], -99\right) ς2≡max(y−[9Hs−P(H)Hs],−99)
难度炸弹部分如下所示:
ϵ ≡ [ 2 [ H i ′ ÷ 100000 ] − 2 ] \epsilon≡\left[2^{\left[H_i'\div100000\right]-2}\right] ϵ≡[2[Hi′÷100000]−2]
H i ′ ≡ m a x ( H i − 3000000 , 0 ) H_i'≡max(H_i-3000000,\ 0) Hi′≡max(Hi−3000000, 0)
难度炸弹的威力如下图所示:
降低难度后,出块奖励从5ETH降低为3ETH(君士坦丁堡阶段又降低到了2ETH),否则难度的突然降低会对调整之前挖矿的矿工不公平。
拜占庭阶段挖矿难度调整的代码实现如下:
// calcDifficultyByzantium is the difficulty adjustment algorithm. It returns
// the difficulty that a new block should have when created at time given the
// parent block's time and difficulty. The calculation uses the Byzantium rules.
func calcDifficultyByzantium(time uint64, parent *types.Header) *big.Int {
// https://github.com/ethereum/EIPs/issues/100.
// algorithm:
// diff = (parent_diff +
// (parent_diff / 2048 * max((2 if len(parent.uncles) else 1) - ((timestamp - parent.timestamp) // 9), -99))
// ) + 2^(periodCount - 2)
bigTime := new(big.Int).SetUint64(time)
bigParentTime := new(big.Int).Set(parent.Time)
// holds intermediate values to make the algo easier to read & audit
x := new(big.Int)
y := new(big.Int)
// (2 if len(parent_uncles) else 1) - (block_timestamp - parent_timestamp) // 9
x.Sub(bigTime, bigParentTime)
x.Div(x, big9)
if parent.UncleHash == types.EmptyUncleHash {
x.Sub(big1, x)
} else {
x.Sub(big2, x)
}
// max((2 if len(parent_uncles) else 1) - (block_timestamp - parent_timestamp) // 9, -99)
if x.Cmp(bigMinus99) < 0 {
x.Set(bigMinus99)
}
// (parent_diff + parent_diff // 2048 * max(1 - (block_timestamp - parent_timestamp) // 10, -99))
y.Div(parent.Difficulty, params.DifficultyBoundDivisor)
x.Mul(y, x)
x.Add(parent.Difficulty, x)
// minimum difficulty can ever be (before exponential factor)
if x.Cmp(params.MinimumDifficulty) < 0 {
x.Set(params.MinimumDifficulty)
}
// calculate a fake block numer for the ice-age delay:
// https://github.com/ethereum/EIPs/pull/669
// fake_block_number = min(0, block.number - 3_000_000
fakeBlockNumber := new(big.Int)
if parent.Number.Cmp(big2999999) >= 0 {
fakeBlockNumber = fakeBlockNumber.Sub(parent.Number, big2999999) // Note, parent is 1 less than the actual block number
}
// for the exponential factor
periodCount := fakeBlockNumber
periodCount.Div(periodCount, expDiffPeriod)
// the exponential factor, commonly referred to as "the bomb"
// diff = diff + 2^(periodCount - 2)
if periodCount.Cmp(big1) > 0 {
y.Sub(periodCount, big2)
y.Exp(big2, y, nil)
x.Add(x, y)
}
return x
}
函数输入是父区块的时间戳和难度,进行一系列计算后得出当前正在挖的区块难度。
注释中给出的难度公式为:
d i f f = ( p a r e n t _ d i f f + ( p a r e n t _ d i f f / 2048 ∗ m a x ( ( 2 i f l e n ( p a r e n t . u n c l e s ) e l s e 1 ) − ( ( t i m e s t a m p − p a r e n t . t i m e s t a m p ) / / 9 ) , − 99 ) ) ) + 2 ( p e r i o d C o u n t − 2 ) diff = (parent\_diff +\\ (parent\_diff / 2048 * max((2\ if\ len(parent.uncles)\ else\ 1) - ((timestamp - parent.timestamp) // 9), -99))) + \\ 2^(periodCount - 2) diff=(parent_diff+(parent_diff/2048∗max((2 if len(parent.uncles) else 1)−((timestamp−parent.timestamp)//9),−99)))+2(periodCount−2),与上面章节中所述相同。