本文内容来源:https://github.com/lbc-team/deep_ethereum/commit/4e70378105a4e7947ca3ada2f06965814bcbca00
矿工的收益来自于挖矿奖励。这样才能激励矿工积极参与挖矿,维护网络安全。那么,以太坊是如何奖励矿工的呢?
本文通过两个问题来协助你理解机制:
- 奖励是如何计算的;
- 何时何地奖励;
1. 奖励是如何计算的
奖励分成三部分:
- 新块奖励
- 叔块奖励
- 矿工费
{总奖励} = 新块奖励 + 叔块奖励 + 矿工费
1.1 新块奖励
它是奖励矿工消耗电能,完成工作量证明所给予的奖励。该奖励已进行两次调整:
- 起初每个区块有 5 个以太币的奖励;
- 在2017年10月16日(区块高度 4370000) 执行拜占庭硬分叉,将奖励下降到 3 个以太币;
- 在2019年2月28日(区块高度 7280000)执行君士坦丁堡硬分叉,将奖励再次下降到 2 个以太币。
以太坊在2015年7月正式发布以太坊主网后,其团队便规划发展阶段,分为“前沿”、“家园”、“大都会”和“宁静”四个阶段。拜占庭(Byzantium)和君士坦丁堡(Constantinople)是大都会的两个阶段。
新块奖励是矿工的主要收入来源,下降到 2 个 以太币的新块奖励。对矿机厂商和矿工,甚至以太坊挖矿生态都会产生比较大的影响和调整。因为挖矿收益减少,机会成本增加,在以太坊上挖矿将会变得性价比低于其他币种,因此可能会降低矿工的积极性。这也是迫使以太坊向以太坊2.0升级的一种助燃剂,倒逼以太坊更新换代。
1.2 叔块奖励
以太坊出块间隔平均为12秒,区块链软分叉是一种普遍现象,如果采取和比特币一样处理方式,只有最长链上的区块才有出块奖励,对于那些挖到区块而最终不在最长链上的矿工来说,就很不公平,而且这种“不公平”将是一个普遍情况。这会影响矿工们挖矿的积极性,甚至可能削弱以太坊网络的系统安全,也是对算力的一种浪费。因此,以太坊系统对不在最长链上的叔块,设置了“叔块奖励”。
叔块奖励也分成两部分:
- 奖励叔块的创建者;
- 奖励收集叔块的矿工;
叔块创建者的奖励根据“近远”关系而不同,和当前区块隔得越远,奖励越少。
{挖叔块奖励} = {8-(当前区块高度-叔块高度)} {8} * {当前区块挖矿奖励}
叔块 | 奖励 | 按挖矿奖励 2 ETH计算 |
第一代 | 7/8 | 1.75 ETH |
第二代 | 6/8 | 1.5 ETH |
第三代 | 5/8 | 1.25 ETH |
第四代 | 4/8 | 1 ETH |
第五代 | 3/8 | 0.75 ETH |
第六代 | 2/8 | 0.5 ETH |
第七代 | 1/8 | 0.25 ETH |
注意:叔块中所产生的交易费是不返给创建者的,毕竟叔块中的交易是不能作数的。
收录叔块的矿工每收录一个叔块将到多得 1/32 的区块挖矿奖励。
收集叔块奖励 = 数量数量 \times \frac{新块奖励}{32}
1.3 矿工费
矿工处理交易,并校验和打包到区块中去。此时交易签名者需要支付矿工费给矿工。每笔交易收多少矿工费,取决于交易消耗了多少燃料,它等于用户所自主设置的燃料单价GasPrice 乘以交易所消耗的燃料。
Fee = \text{tx.gasPrice} \times \text{tx.gasUsed}
2. 何时何地奖励
奖励是在挖矿打包好一个区块时,便已在其中完成了奖励的发放,相当于是实时结算。
矿工费的发放是在处理完一笔交易时,便根据交易所消耗的 Gas 直接存入到矿工账户中;区块奖励和叔块奖励,则是在处理完所有交易后,进行奖励实时计算。
3. 代码展示
3.1 实时结算交易矿工费
//core/state_transition.go
func (st *StateTransition) TransitionDb() (*ExecutionResult, error) {
//...
var (
ret []byte
vmerr error // vm errors do not effect consensus and are therefore not assigned to err
)
if contractCreation {
ret, _, st.gas, vmerr = st.evm.Create(sender, st.data, st.gas, st.value)
} else {
// Increment the nonce for the next transaction
st.state.SetNonce(msg.From(), st.state.GetNonce(sender.Address())+1)
ret, st.gas, vmerr = st.evm.Call(sender, st.to(), st.data, st.gas, st.value)
}
st.refundGas()
st.state.AddBalance(st.evm.Coinbase, new(big.Int).Mul(new(big.Int).SetUint64(st.gasUsed()), st.gasPrice))
return &ExecutionResult{
UsedGas: st.gasUsed(),
Err: vmerr,
ReturnData: ret,
}, nil
}
3.2 实时结算挖矿奖励和叔块奖励
//consensus/ethash/consensus.go:572
func (ethash *Ethash) Finalize(chain consensus.ChainReader, header *types.Header, state *state.StateDB, txs []*types.Transaction, uncles []*types.Header) {
// Accumulate any block and uncle rewards and commit the final state root
accumulateRewards(chain.Config(), state, header, uncles)
header.Root = state.IntermediateRoot(chain.Config().IsEIP158(header.Number))
}
func accumulateRewards(config *params.ChainConfig, state *state.StateDB, header *types.Header, uncles []*types.Header) {
// Select the correct block reward based on chain progression
blockReward := FrontierBlockReward
if config.IsByzantium(header.Number) {
blockReward = ByzantiumBlockReward
}
if config.IsConstantinople(header.Number) {
blockReward = ConstantinopleBlockReward
}
// Accumulate the rewards for the miner and any included uncles
reward := new(big.Int).Set(blockReward)
r := new(big.Int)
for _, uncle := range uncles {
r.Add(uncle.Number, big8)
r.Sub(r, header.Number)
r.Mul(r, blockReward)
r.Div(r, big8)
state.AddBalance(uncle.Coinbase, r)
r.Div(blockReward, big32)
reward.Add(reward, r)
}
state.AddBalance(header.Coinbase, reward)
}
4. 叔块
4.1 什么是叔块
是指**没能**成为区块链最长链的一部分的区块(陈旧的区块),但被后续区块收录时,这些区块称之为“叔块”。
它是针对区块而言的,是指被区块收录的陈旧的祖先孤块,它没能成为区块链最长链的一部分而被收录。
是针对当前区块所的而已的叔辈区块,一个区块最多可以记录 7 个叔块。叔块也是数据合法的区块,只是它所在的区块链分支没有成功成为主链的一部分。
如上图所示,新区块 E,它可以收录两个绿色的孤块B和C,但是灰色的区块不能被收录,因为他们的父区块并不在新区块所在的区块链上。而黄色区块和红色新区块是同辈区块,不能被新区块作为叔块收录。
4.2 为什么要设计叔块
在比特币中,因临时分叉(软分叉),没能成为最长合法链上的区块的区块,称之为孤块,孤块是没有区块奖励的。研究发现, 比特币的全网需要12.6秒一个新的区块才能传播到全网95%的节点。比特币系统,是平均10分钟才出一个区块,有足够的时间将新区块广播到全网其他节点,这种临时性的分叉几率就相当小。根据历史数据,大概平均3000多个区块,才会出现一次临时性分叉,相当于20多天出现一次这种临时性分叉,属于比较“罕见”的情况。
但是以太坊的出块时间已经缩短到12 到14 秒一个区块。更短的时间意味着,临时分叉的几率大幅提升。这是因为当矿工A挖出一个新区块后,需要向全网广播,广播的过程需要时间的。 由于以太坊出块时间短,其他节点可能还没有收到矿工A发布的区块,就已经挖出了同一高度的区块,这就造成了临时分叉。在以太坊网络中,临时性分叉发生的几率在 6.6% 左右。
上图数据来源于 https://etherchain.org/ (2020年06月03日),当前以太坊叔块率为 6.6%。意味着在以太坊网络中,每 100 个区块,大约有 7 个叔块产生。如果按照平均 13.5 秒的出块时间计算,一个小时内有约 17.6 次临时分叉。
以太坊系统出现临时性分叉是一种普遍现象,如果采取和比特币一样处理方式,只有最长链上的区块才有出块奖励,对于那些挖到区块而最终不在最长链上的矿工来说,就很不公平,而且这种“不公平”将是一个普遍情况。这会影响矿工们挖矿的积极性,甚至可能削弱以太坊网络的系统安全,也是对算力的一种浪费。因此,以太坊系统对不在最长链上的叔块,设置了叔块奖励。
4.3 区块如何收录叔块
当节点不断接受到区块时,特别是同一高度的多个区块,会让以太坊陷入短期的软分叉中,或者在多个软分叉分支中来回切换。一旦出现软分叉,那么意味着有一个区块没能成为最长链的一部分。
比如上图中,挖矿依次接收到 A、B 、C ,会在本地校验和存储这些区块,但根据最长链规则最终会切换到分支B上。那么在此刻,A和C 暂时成为了孤块。矿工会基于 B 挖取下一个新区块D。
此时,D 就可以将本地还暂存的孤块 A 和 C 作为它的叔块而收录到区块 D 中。当然 D 不仅可以收录第一代祖先,还可以收录七代内的孤块。但有一些限制条件,以下图新区块 N 为例:
- N 不能收录 A :因 A 不在七代祖先内(区间要求);
- N 不能收录 M:因 M 不是 N 的祖先,是兄弟而已;
- N 不能收录 E、G、K、L :因它们的父区块并不在 N 所在的区块分支上,但 B 可以;
- N 不能同时收录 D、C 和 B:因为一个区块最多能收录两个叔块,做多是三选二;
- 当 D 被 F 收录后,N 是不能重复收录 D 的;
- N 不收录 F 或 H: 因为 F 和 H 不是孤块;
矿工在开挖新区块,准备区块头信息时,矿工将从本地节点存储中获取七代内的所有家族区块,根据上述规则选择最多两个叔块。另外,在选择时本地叔块优先选择。
5. 叔块奖励分配
叔块的奖励分为两部分:奖励收录叔块的矿工和奖励叔块创建者。
5.1 奖励叔块的创建者
叔块创建者的奖励根据“近远”关系而不同,和当前区块隔得越远,奖励越少。
{叔块奖励} = {8-(当前区块高度-叔块高度)}{8} * {当前区块挖矿奖励}
5.2 收录叔块的矿工
该矿工即为当前新区块的矿工,他处理获得原本的区块挖矿奖励(2 ETH)和交易手续费外,还能获得收录叔块奖励,每收录一个区块将得到多得 1/32 的区块挖矿奖励。
以区块 [10192970](https://etherscan.io/block/10192970) 为例:
该区块矿工 2Miners:SOLO 总共获得了 2**.**385338652682918613 ETH奖励,其中:
- 2 ETH 是挖矿奖励;
- 0.322838652682918613 ETH 是交易手续费;
- 0**.**0625 ETH 是收录了一个叔块的奖励,是 2 ETH的挖矿奖励的 1/32。
而收录的一个[叔块](https://etherscan.io/uncle/0xb3f6c988ba064ac1cad2058c52ab280f05dcc558687c8734e07aabf4cd00e855)是第 1代叔块,奖励 2 ETH 的 7 /8。
5.3 叔块是如何收录在区块中的
6. 区块存储
这篇文章所说的挖矿环节中的存储环节,当矿工通过穷举计算找到了符合难道要去的区块 Nonce 后,标志着新区块已经成功被挖掘。
此时,矿工将在本地将这个合法的区块直接在本地存储,下面具体讲讲,在 geth 中矿工是如何存储自己挖掘的新区块的。
在上一环节“PoW 寻找 Nonce” 后,已经拥有了完整的区块信息。
而在“处理本地交易”和“处理远程交易”后,便拥有了完整的区块交易回执清单:
区块中的每一笔交易在处理后,都会存在一份交易回执。在交易回执中记录着这边交易的执行结果信息,对于交易回执,我们已经在前面的课程有讲解,这里不再复述。
同时在“发放区块奖励”后,区块的状态不会再发生变化,此时,我们就已经拿到了一个可以代表该区块的状态数据。状态`state`,在内存中将记录着本次区块中交易执行后状态所发送的变化信息,包括新增、变更和删除的数据。
前面所说的区块(Block)、交易回执(Receipt)、状态(State)就是本次挖矿的产物,在本地需要存储的也只有这三部分数据。
这些数据,在挖矿中处理存储的代码如下:
//miner/worker.go:595
var (
receipts = make([]*types.Receipt, len(task.receipts))
logs []*types.Log
)
for i, receipt := range task.receipts {//❶
// add block location fields
receipt.BlockHash = hash
receipt.BlockNumber = block.Number()
receipt.TransactionIndex = uint(i)
receipts[i] = new(types.Receipt)
*receipts[i] = *receipt
for _, log := range receipt.Logs {
log.BlockHash = hash
}
logs = append(logs, receipt.Logs...)//❷
}
// Commit block and state to database. //❸
_, err := w.chain.WriteBlockWithState(block, receipts, logs, task.state, true)
if err != nil {
log.Error("Failed writing block to chain", "err", err)
continue
}
log.Info("Successfully sealed new block", "number", block.Number(), "sealhash", sealhash, "hash", hash,
"elapsed", common.PrettyDuration(time.Since(task.createdAt)))
- 遍历交易回执,给每一个交易回执添加本次区块信息(blockHash,BlockNumber、TransactionIndex),这样就可以在本地记录交易回执和区块间的查找关系。
- 同时将交易回执中生成的日志信息提取到一个大集合中,以便作为一个区块日志整体存储。
- 开始提交区块(Block)、交易回执(Receipt)、状态(State)和日志(log)到本地数据库中。
在`writeBlockWithState`中,是将所有数据以一个批处理事务写入到数据库中:
blockBatch := bc.db.NewBatch()
rawdb.WriteTd(blockBatch, block.Hash(), block.NumberU64(), externTd)
rawdb.WriteBlock(blockBatch, block)
rawdb.WriteReceipts(blockBatch, block.Hash(), block.NumberU64(), receipts)
rawdb.WritePreimages(blockBatch, state.Preimages())
if err := blockBatch.Write(); err != nil {
log.Crit("Failed to write block into disk", "err", err)
}
// Commit all cached state changes into underlying memory database.
root, err := state.Commit(bc.chainConfig.IsEIP158(block.Number()))
//...
// Set new head.
if status == CanonStatTy {
bc.writeHeadBlock(block)
}
在一个事务中,分别想数据库中写入了区块难度、区块、交易回执、Preimages(key映射),最后将 state 提交。
那么,geth 是如何在本地将这些数据存放到键值数据库 levelDB 中的呢?这里,给大家整理一份键值信息表。
Key | Value | 说明 |
“b”.blockNumber.blockHash | blockBody: uncles + transactions | 通过区块哈希和高度存储对应的区块叔块和交易信息 |
"H".blockHash | blockNumber | 通过区块哈希记录对于的区块高度 |
“h”.blockNumber.blockHash | blockHeader | 通过区块哈希和高度存储对于的区块头 |
”r“.blockNumber | receipts | 通过区块高度记录区块的交易回执记录 |
"h".blockNumber | blockHash | 区块高度对应的区块哈希 |
”l“.txHash | blockNumber | 记录交易哈希所在的区块高度 |
”LastBlock“ | blockHash | 更新最后一个区块哈希值 |
”LastHeader“ | blockHash | 更新最后一个区块头所在位置 |
注意,上面的 value 信息,是需要序列化为 bytes 才能存储到 leveldb 中,序列化是以太坊自定义的 RLP 编码技术。你有没有想过它为何要添加一个前缀呢?比如”b“、”H“等等,第一个好处是将不同数据分类,另一个重要的原因是在leveldb中数据是以 key 值排序存储的,这样在按顺序遍历区块头、查询同类型数据时,读的性能会更好。
正是因为在我们在本地了区块数据的一些映射关系,我们才能快速的从本地数据库中只需要提供少量的信息就就能组合一个或者多个键值关系查询到目标数据。下面我列举了一些常见的以太坊API,你觉得该如何从DB中查找出数据呢?
- 通过交易哈希获取交易信息:eth_getTransactionByHash("0xb903239f8543d04b5dc1ba6579132b143087c68db1b2168786408fcbce568238")
- 查询最后一个区块信息: eth_getBlockByNumber("latest")
- 通过交易哈希获取交易回执eth_getTransactionReceipt("0x444172bef57ad978655171a8af2cfd89baa02a97fcb773067aef7794d6913374")