go-ethereum源码解读(一):共识-工作量证明(PoW, Proof of Work)

406 阅读6分钟

前言

本系列分享共有4个分部工作量证明、权益证明、共识过程以及网络协议与通信。本次主要讲解工作量证明的的原理以及源码实现。

基础介绍

区块链最早的共识机制,目的是为了保证区块链上数据的一致性,防止恶意攻击(比如double spending攻击、服务滥用)。

数据一致性包括账户余额、交易参数、交易顺序等。

以太坊中,由于最新版本已经废除工作量证明,并采用权益证明,本文中工作量证明展示的代码为go-ethereum的1.11。

仓库链接:github.com/ethereum/go…

原理

工作量证明的主要作用是使用密码学哈希函数(以太坊中常用SHA-256算法计算哈希)生成区块哈希,矿工(以太坊中一个参与打包交易的以太坊节点)需要找到一个小于或等于目标值的哈希,这个目标值取决于难度值的大小。

矿工通过不断修改区块头中的Nonce值,来改变计算得到的结果值大小。找到符合条件的哈希值后,矿工将新区块广播到网络,其他节点验证其有效性,并将其添加到区块链上。当新的区块被添加到区块链上,创建这个区块的矿工将会得到这个区块中所有交易消耗的gas,所以工作量证明的计算过程又被称为“挖矿”,参与计算哈希的以太坊节点被称为矿工。

工作量证明通过这种高难度的计算防止恶意节点攻击和服务的滥用。而挖矿获得激励的机制,天然的鼓励更多的人参与构建去中心化的以太坊网络共识。

工作量证明代码实现

以太坊会根据CPU核心数启动对应数量的goroutine(Go语言中,需要并行执行的计算,需要创建一个goroutine)并发计算哈希,并且会使用随机数作为nonce的初始值,增加计算出哈希的效率:

代码源文件go-ethereum/consensus/ethash/sealer.go:

func (ethash *Ethash) Seal(chain consensus.ChainHeaderReader, block *types.Block, results chan<- *types.Block, stop <-chan struct{}) error {

        // 省略一些不重要的代码...
    
        // If we're running a shared PoW, delegate sealing to it
        if ethash.shared != nil {
                return ethash.shared.Seal(chain, block, results, stop)
        }
        
    // 省略一些不重要的代码...
    
        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()))
        }
        
    // 省略一些不重要的代码...

        return nil
}

每个goroutine都会循环查找匹配要求的nonce:

func (ethash *Ethash) mine(block *types.Block, id int, seed uint64, abort chan struct{}, found chan *types.Block) {
        // Extract some data from the header
        var (
                header  = block.Header()
                hash    = ethash.SealHash(header).Bytes()
                // 目标值 = 2 ^ 256 / 难度值,即难度值越大, 目标值越小
                target  = new(big.Int).Div(two256, header.Difficulty)
                number  = header.Number.Uint64()
                dataset = ethash.dataset(number, false)
        )
        // Start generating random nonces until we abort or find a good one
        var (
                attempts  = int64(0)
                // seed是个随机数,会作为nonce的初始值来计算结果值
                nonce     = seed
                powBuffer = new(big.Int)
        )
        logger := ethash.config.Log.New("miner", id)
        logger.Trace("Started ethash search for new nonces", "seed", seed)
search:
        for {
                select {
                case <-abort:
                        // 省略一些不重要的代码...

                default:
                        // 省略一些不重要的代码...

                        // 计算结果值
                        digest, result := hashimotoFull(dataset.dataset, hash, nonce)
                        // 由于这个结果值也是一个哈希,可以理解为是一个符合条件的结果值均匀散列在 0 ~ 目标值之间
                        // 目标值越小,符合条件的结果值的范围就越小,计算出结果的概率就越小
                        if powBuffer.SetBytes(result).Cmp(target) <= 0 {
                                // 结果值 <= 目标值
                                // 找到了正确的nonce,赋值给区块头中对应的属性
                                header = types.CopyHeader(header)
                                header.Nonce = types.EncodeNonce(nonce)
                                header.MixDigest = common.BytesToHash(digest)

                                break search
                        }
                        nonce++
                }
        }
        // 省略一些不重要的代码...
}

计算当前区块的难度值:

代码源文件:go-ethereum/consensus/ethash/consensus.go

// 以太坊不同版本的难度计算,唯一不同的地方在于声明不同的区块高度
// 这个区块高度会影响难度变化的周期,影响出块的速度
calcDifficultyEip5133 = makeDifficultyCalculator(big.NewInt(11_400_000))
calcDifficultyEip4345 = makeDifficultyCalculator(big.NewInt(10_700_000))
calcDifficultyEip3554 = makeDifficultyCalculator(big.NewInt(9700000))
calcDifficultyEip2384 = makeDifficultyCalculator(big.NewInt(9000000))
calcDifficultyConstantinople = makeDifficultyCalculator(big.NewInt(5000000))
calcDifficultyByzantium = makeDifficultyCalculator(big.NewInt(3000000))

func CalcDifficulty(config *params.ChainConfig, time uint64, parent *types.Header) *big.Int {
        next := new(big.Int).Add(parent.Number, big1)
        switch {
        case config.IsGrayGlacier(next):
                return calcDifficultyEip5133(time, parent)
        case config.IsArrowGlacier(next):
                return calcDifficultyEip4345(time, parent)
        case config.IsLondon(next):
                return calcDifficultyEip3554(time, parent)
        case config.IsMuirGlacier(next):
                return calcDifficultyEip2384(time, parent)
        case config.IsConstantinople(next):
                return calcDifficultyConstantinople(time, parent)
        case config.IsByzantium(next):
                return calcDifficultyByzantium(time, parent)
        case config.IsHomestead(next):
                return calcDifficultyHomestead(time, parent)
        default:
                return calcDifficultyFrontier(time, parent)
        }
}

矿工如何获得收益

在以太坊启动的参数中有一个专门设置收益地址的参数,叫做miner.etherbase

当本地以太坊执行层节点创建一个新区块时,会把这个参数设置为coinbase地址,在执行交易时把交易中剩余的gas作为奖励,直接添加到coinbase地址余额。

打包区块前,worker会把当前的coinbase赋值给generateParams,而generateParams中的coinbase属性会被新的区块头读取到:

代码源文件:go-ethereum/miner/worker.go

// 设置Header中coinbase地址的值
func (w *worker) commitWork(interrupt *int32, noempty bool, timestamp int64) {
       // 省略不重要的代码...
       if w.isRunning() {
               if w.coinbase == (common.Address{}) {
                       log.Error("Refusing to mine without etherbase")
                       return
               }
               // 获取当前worker中预设的coinbase地址
               coinbase = w.coinbase
       }
       // 在这个prepareWork中,新创建的Header会使用generateParams中的coinbase地址
       work, err := w.prepareWork(&generateParams{
               timestamp: uint64(timestamp),
               // coinbase属性赋值
               coinbase:  coinbase,
       })
       
       if !noempty && atomic.LoadUint32(&w.noempty) == 0 {
               w.commit(work.copy(), nil, false, start)
       }

       // 在fillTransactions中会真正的执行交易,而在交易执行完之后
       // 会将交易使用的gas,添加到coinbase地址余额
       err = w.fillTransactions(interrupt, work)
       if errors.Is(err, errBlockInterruptedByNewHead) {
               work.discard()
               return
       }
       w.commit(work.copy(), w.fullTaskHook, true, start)

   // 省略不重要的代码...
}

在真正执行交易之前,会先取worker中预设的coinbase地址。并赋值给新创建的generateParams,执行prepareWork方法时,新创建的Header会使用这个generateParams中的coinbase地址,作为Header的coinbase地址。

执行交易时,会在引用Header中的coinbase地址。

交易执行时,给coinbase发放奖励:

代码源文件:go-ethereum/core/state_transition.go

func (st *StateTransition) TransitionDb() (*ExecutionResult, error) {
        
    // 省略不重要的代码...
        if st.evm.Config.NoBaseFee && msg.GasFeeCap.Sign() == 0 && msg.GasTipCap.Sign() == 0 {
                // 省略部分注释
        } else {
                // 每笔交易消耗的gas都会作为奖励发给coinbase地址
                // 并且不会因为交易执行过程中的错误而回滚
                // st.gasUsed() 就是在获取当前交易执行所消耗的gas
                fee := new(big.Int).SetUint64(st.gasUsed())
                // 这个effectiveTip一开始是gasPrice,
                // 在london硬分叉升级之后,会在GasTipCap和 (GasFeeCap - header.BaseFee)之间选最小的
                fee.Mul(fee, effectiveTip)
                // st.evm.Context.Coinbase就是Header的coinbase地址
                st.state.AddBalance(st.evm.Context.Coinbase, fee)
        }

        return &ExecutionResult{
                UsedGas:    st.gasUsed(),
                Err:        vmerr,
                ReturnData: ret,
        }, nil
}

执行交易之后,交易所消耗的Gas都会先乘以一个GasPrice系数,然后添加到Header的Coinbase地址的余额。

注:交易在执行st.evm.Call方法之后,即使交易执行失败,gas也必定会被消耗。

交易执行与工作量证明时序图

Work主循环中打包区块链时序图

区块执行完之后,会为需要计算Hash的区块创建一个task并添加到Worker的taskCh中等待被处理。

Worker的taskLoop中处理taskCh中的task时序图

taskLoop中,使用SealHash方法对task任务中的区块信息生成一个Hash,并缓存到内存中,防止需要生成哈希的区块被重复提交。

之后再调用Seal方法,计算区块头中的nonce,真正的开始挖矿计算。