NEO從源碼分析看共識協議

0x00 概論

不一樣於比特幣使用的工做量證實(PoW)來實現共識,NEO提出了DBFT共識算法。DBFT改良自股權證實算法(PoS),我沒有具體分析過PoS的源碼,因此暫時還不是很懂具體哪裏作了改動,有興趣的同窗能夠看下NEO的官方文檔。本文主要內容集中在對共識協議源碼的分析,此外還會有對於一些理論的講解。關於NEO網絡通訊部分源碼分析我還另外寫了一篇博客,因此本文中全部涉及到通訊的內容我就再也不贅述,有興趣的同窗能夠去看個人另外一篇博客。html

0x01 獲取議員名單

NEO的共識協議相似於西方國家的議會,每次區塊的生成都在議長主持下由議會成員共同協商生成新的區塊。NEO網絡節點分爲兩種,一種爲共識節點,另外一種爲普通節點。普通節點是不參與NEO新區快生成的,對應於普通人,共識節點參與共識的過程而且都有機會成爲議長主持新區塊的生成,對應於議員。 看官方文檔彷佛全部的共識節點均可以到NEO的服務器註冊爲議員,可是貌似成爲議員仍是有條件的,據社區大佬說,你帳戶裏至少也要由個把億才能成爲議員,因此像我這樣的窮逼是沒但願了。可是在分析源碼的時候我發現彷佛並非這樣。源碼中在每輪共識開始的時候調用ConsensusContext.cs中的Reset方法,在 重置共識的時候會調用Blockchain.Default.GetValidators()來獲取議員列表,跟進去這個GetValidators()源碼:node

源碼位置:neo/Core/BlockChain.csgit

/// <summary>
        /// 獲取下一個區塊的記帳人列表
        /// </summary>
        /// <returns>返回一組公鑰,表示下一個區塊的記帳人列表</returns>
        public ECPoint[] GetValidators()
        {
            lock (_validators)
            {
                if (_validators.Count == 0)
                {
                    _validators.AddRange(GetValidators(Enumerable.Empty<Transaction>()));
                }
                return _validators.ToArray();
            }
        }
複製代碼

發現這裏是調用了內部的GetValidators(IEnumerable<Transaction> others)方法,可是這裏有點意思,這裏傳過去的參數,竟然是個空的。再看這個內部的GetValidators方法:github

源碼位置:neo/Core/BlockChain.cs算法

public virtual IEnumerable<ECPoint> GetValidators(IEnumerable<Transaction> others)
        {
            DataCache<UInt160, AccountState> accounts = GetStates<UInt160, AccountState>();
            DataCache<ECPoint, ValidatorState> validators = GetStates<ECPoint, ValidatorState>();
            MetaDataCache<ValidatorsCountState> validators_count = GetMetaData<ValidatorsCountState>();
            foreach (Transaction tx in others)
            {
                ////////////
            }
            int count = (int)validators_count.Get().Votes.Select((p, i) => new
            {
                Count = i,
                Votes = p
            }).Where(p => p.Votes > Fixed8.Zero).ToArray().WeightedFilter(0.25, 0.75, p => p.Votes.GetData(), (p, w) => new
            {
                p.Count,
                Weight = w
            }).WeightedAverage(p => p.Count, p => p.Weight);
            count = Math.Max(count, StandbyValidators.Length);
            HashSet<ECPoint> sv = new HashSet<ECPoint>(StandbyValidators);
            ECPoint[] pubkeys = validators.Find().Select(p => p.Value).Where(p => (p.Registered && p.Votes > Fixed8.Zero) || sv.Contains(p.PublicKey)).OrderByDescending(p => p.Votes).ThenBy(p => p.PublicKey).Select(p => p.PublicKey).Take(count).ToArray();
            IEnumerable<ECPoint> result;
            if (pubkeys.Length == count)
            {
                result = pubkeys;
            }
            else
            {
                HashSet<ECPoint> hashSet = new HashSet<ECPoint>(pubkeys);
                for (int i = 0; i < StandbyValidators.Length && hashSet.Count < count; i++)
                    hashSet.Add(StandbyValidators[i]);
                result = hashSet;
            }
            return result.OrderBy(p => p);
        }
複製代碼

我把第一個foreach循環中的代碼都刪掉了,由於明顯傳進來的others參數爲0,因此循環體裏的代碼根本不會有執行的機會。這個方法的返回值是result,它值的數據有兩個來源。第一個是pubkeys,pubkeys來自於本地緩存中的議員信息,這個信息是在區塊鏈同步的時候保存的,也就是說只要共識節點開始接入區塊鏈網絡進行區塊同步,就會獲取到議員信息。而若是沒有緩存議員信息或者緩存的議員信息丟失,就會使用內置的默認議員列表進行共識,以後再在共識的過程當中緩存議員信息。 上面說到獲取議員信息有兩種途徑,第二種的使用內置默認議員列表是直接將配置文件protocol.json中的數據讀取到StandbyValidators字段中。接下來主要介紹第一種途徑。 GetValidators方法的第二行調用了GetStates,而且傳入類的類型是ValidatorState,這個方法位於LevelDBBlockChain.cs文件中,完整代碼以下:數據庫

源碼位置:neo/Implementations/BlockChains/LevelDB/LevelDBBlockChain.csjson

public override DataCache<TKey, TValue> GetStates<TKey, TValue>()
        {
            Type t = typeof(TValue);
            if (t == typeof(AccountState)) return new DbCache<TKey, TValue>(db, DataEntryPrefix.ST_Account);
            if (t == typeof(UnspentCoinState)) return new DbCache<TKey, TValue>(db, DataEntryPrefix.ST_Coin);
            if (t == typeof(SpentCoinState)) return new DbCache<TKey, TValue>(db, DataEntryPrefix.ST_SpentCoin);
            if (t == typeof(ValidatorState)) return new DbCache<TKey, TValue>(db, DataEntryPrefix.ST_Validator);
            if (t == typeof(AssetState)) return new DbCache<TKey, TValue>(db, DataEntryPrefix.ST_Asset);
            if (t == typeof(ContractState)) return new DbCache<TKey, TValue>(db, DataEntryPrefix.ST_Contract);
            if (t == typeof(StorageItem)) return new DbCache<TKey, TValue>(db, DataEntryPrefix.ST_Storage);
            throw new NotSupportedException();
        }
複製代碼

能夠看到這裏是直接從leveldb的數據庫中讀取的議員數據。也就是說在讀取數據以前,應該要建立/打開數據庫才行,這部分的操做能夠參考neo-cli項目,這個項目就在MainService類的OnStart方法中傳入了數據庫地址。 固然這只是從數據庫中獲取議員信息,向數據庫中存入議員信息的工做主要由LevelDBBlockChain.cs文件中的Persist(Block block) 方法負責,這個方法接收一個區塊類型做爲參數,主要工做是將同步到的區塊信息解析保存。涉及到議員信息的關鍵代碼以下:緩存

源碼位置:neo/Implementations/BlockChains/LevelDB/LevelDBBlockChain.cs/Persistbash

foreach (ECPoint pubkey in account.Votes)
            {
                      ValidatorState validator = validators.GetAndChange(pubkey);
                      validator.Votes -= out_prev.Value;
                       if (!validator.Registered && validator.Votes.Equals(Fixed8.Zero))
                              validators.Delete(pubkey);
             }
複製代碼

經過調用GetAndChange方法將獲取到的議員帳戶添加到數據庫緩存中。服務器

0x02 肯定議長

共識節點經過調用ConsensusService類中的Start方法來開始參與共識。在Start方法中首先是註冊了消息接收、數據保存等的事件通知,以後調用InitializeConsensus開啓共識,InitializeConsensus方法接收一個整形參數,這個參數被稱爲爲視圖編號,具體視圖的定義能夠去查看官方文檔,這裏不作解釋。當傳入的視圖編號爲0時,就意味是着一輪新的共識,須要重置共識狀態。重置共識狀態的代碼以下:

源碼位置:neo/Consenus/ConsensusContext.cs

/// <summary>
        /// 共識狀態重置,準備發起新一輪共識
        /// </summary>
        /// <param name="wallet">錢包</param>
        public void Reset(Wallet wallet)
        {
            State = ConsensusState.Initial;  //設置共識狀態爲 Initial
            PrevHash = Blockchain.Default.CurrentBlockHash;   //獲取上一個區塊的哈希
            BlockIndex = Blockchain.Default.Height + 1;  //新區塊下標
            ViewNumber = 0;     //初始狀態 視圖編號爲0
            Validators = Blockchain.Default.GetValidators();   //獲取議員信息
            MyIndex = -1;   //當前議員下標初始化
            PrimaryIndex = BlockIndex % (uint)Validators.Length; //肯定議長 p = (h-v)mod n 此處v = 0 
            TransactionHashes = null;
            Signatures = new byte[Validators.Length][];
            ExpectedView = new byte[Validators.Length];   //用於保存衆議員當前視圖編號
            KeyPair = null;
            for (int i = 0; i < Validators.Length; i++)
            {
                //獲取本身的議員編號以及密鑰
                WalletAccount account = wallet.GetAccount(Validators[i]);
                if (account?.HasKey == true)
                {
                    MyIndex = i;
                    KeyPair = account.GetKey();
                    break;
                }
            }
            _header = null;
        }
    }
複製代碼

在代碼中我添加了詳盡的註釋,肯定議長的算法是當前區塊高度+1 再減去當前的視圖編號,結果mod上當前的議員人數,結果就是議長的下標。議員本身的編號則是本身在議員列表中的位置,由於這個位置的排序是根據每一個議員的權重,因此理論上只要節點的議員成員是一致的,那麼最終得到的序列也是一致,也就是說每一個議員的編號在全部的共識節點都是一致的。 在共識節點中,除了在共識重置的時候會肯定議長以外,在每次更新本地視圖的時候也會從新肯定議長:

源碼位置:neo/Consensus/ConsensusContex.cs

/// <summary>
        /// 更新共識視圖
        /// </summary>
        /// <param name="view_number">新的視圖編號</param>
        public void ChangeView(byte view_number)
        {
            int p = ((int)BlockIndex - view_number) % Validators.Length;

            //設置共識狀態爲已發送簽名
            State &= ConsensusState.SignatureSent;
            ViewNumber = view_number;
            //議長編號
            PrimaryIndex = p >= 0 ? (uint)p : (uint)(p + Validators.Length);

            if (State == ConsensusState.Initial)
            {
                TransactionHashes = null;
                Signatures = new byte[Validators.Length][];
            }
            _header = null;
        }
複製代碼

0x03 議長髮起共識

議長在更新完視圖編號後,若是當前時間距離上次寫入新區塊的時間超過了預約的每輪共識的間隔時間(15s)則當即開始新一輪的共識,不然等到間隔時間後再發起共識,時間控制代碼以下: 源碼位置:neo/Consensus/ConsencusService.cs/InitializeConsensus

//議長髮起共識時間控制
         TimeSpan span = DateTime.Now - block_received_time;
          if (span >= Blockchain.TimePerBlock)
          timer.Change(0, Timeout.Infinite); //間隔時間大於預約時間則當即發起共識
          else
                timer.Change(Blockchain.TimePerBlock - span, Timeout.InfiniteTimeSpan); //定時執行
複製代碼

議長進行共識的函數是OnTimeout,由定時器定時執行。下面是議長髮起共識的核心代碼:

源碼位置:neo/Consencus/ConsensusService.cs/OnTimeOut

context.Timestamp = Math.Max(DateTime.Now.ToTimestamp(),  Blockchain.Default.GetHeader(context.PrevHash).Timestamp + 1);
          context.Nonce = GetNonce();//生成區塊隨機數

           //獲取本地內存中的交易列表
           List<Transaction> transactions = LocalNode.GetMemoryPool().Where(p => CheckPolicy(p)).ToList();
           //若是內存中緩存的交易信息數量大於區塊最大交易數,則對內存中的交易信息進行排序 每字節手續費 越高越先確認交易
           if (transactions.Count >= Settings.Default.MaxTransactionsPerBlock)
                            transactions = transactions.OrderByDescending(p => p.NetworkFee / p.Size).Take(Settings.Default.MaxTransactionsPerBlock - 1).ToList();
                        
            //添加手續費交易
            transactions.Insert(0, CreateMinerTransaction(transactions, context.BlockIndex, context.Nonce));
            context.TransactionHashes = transactions.Select(p => p.Hash).ToArray();
            context.Transactions = transactions.ToDictionary(p => p.Hash);

            //獲取新區塊記帳人合約地址
            context.NextConsensus = Blockchain.GetConsensusAddress(Blockchain.Default.GetValidators(transactions).ToArray());
            //生成新區塊並簽名
            context.Signatures[context.MyIndex] = context.MakeHeader().Sign(context.KeyPair);
複製代碼

議長將本地的交易生成新的Header並簽名,而後將這個Header發送PrepareRequest廣播給網絡中的議員。

0x04 議員參與共識

議員在收到PrepareRequest廣播以後會觸發OnPrepareReceived方法:

源碼位置:neo/Consensus/ConsensusService.cs

/// <summary>
        /// 收到議長共識請求
        /// </summary>
        /// <param name="payload">議長的共識參數</param>
        /// <param name="message"></param>
        private void OnPrepareRequestReceived(ConsensusPayload payload, PrepareRequest message)
        {
            Log($"{nameof(OnPrepareRequestReceived)}: height={payload.BlockIndex} view={message.ViewNumber} index={payload.ValidatorIndex} tx={message.TransactionHashes.Length}");
            if (!context.State.HasFlag(ConsensusState.Backup) || context.State.HasFlag(ConsensusState.RequestReceived))//當前不處於回退狀態或者已經收到了重置請求
                return;

            if (payload.ValidatorIndex != context.PrimaryIndex) return;//只接受議長髮起的共識請求

            if (payload.Timestamp <= Blockchain.Default.GetHeader(context.PrevHash).Timestamp || payload.Timestamp > DateTime.Now.AddMinutes(10).ToTimestamp())
            {
                Log($"Timestamp incorrect: {payload.Timestamp}");
                return;
            }
            context.State |= ConsensusState.RequestReceived;//設置狀態爲收到議長共識請求
            context.Timestamp = payload.Timestamp;          //時間戳同步
            context.Nonce = message.Nonce;                  //區塊隨機數同步
            context.NextConsensus = message.NextConsensus;  
            context.TransactionHashes = message.TransactionHashes;  //交易哈希
            context.Transactions = new Dictionary<UInt256, Transaction>();
            
            //議長公鑰驗證
            if (!Crypto.Default.VerifySignature(context.MakeHeader().GetHashData(), message.Signature, context.Validators[payload.ValidatorIndex].EncodePoint(false))) return;
            
            //添加議長簽名到議員簽名列表
            context.Signatures = new byte[context.Validators.Length][];
            context.Signatures[payload.ValidatorIndex] = message.Signature;

            //將內存中緩存的交易添加到共識的context中
            Dictionary<UInt256, Transaction> mempool = LocalNode.GetMemoryPool().ToDictionary(p => p.Hash);
            foreach (UInt256 hash in context.TransactionHashes.Skip(1))
            {
                if (mempool.TryGetValue(hash, out Transaction tx))
                    if (!AddTransaction(tx, false))//從緩存隊列中讀取添加到contex中
                        return;
            }

            if (!AddTransaction(message.MinerTransaction, true)) return; //添加分配字節費的交易 礦工手續費交易

            LocalNode.AllowHashes(context.TransactionHashes.Except(context.Transactions.Keys));
            if (context.Transactions.Count < context.TransactionHashes.Length)
                localNode.SynchronizeMemoryPool();
        }
複製代碼

議員在收到議長共識請求以後,首先使用議長的公鑰對收到的共識信息進行驗證,在驗證經過後將議長的簽名添加到簽名列表中。而後將內存中緩存並在議長Header的交易哈希列表中的交易添加到context裏。 這裏須要講一下這個從內存中添加交易信息到context中的方法 AddTransaction。這個方法在每次添加交易以後都會比較當前context中的交易筆數是否和從議長那裏獲取的交易哈希數相同,若是相同並且記帳人合約地址驗證經過,則廣播本身的簽名到網絡中,這部分核心代碼以下:

源碼位置:neo/Consensus/ConsensusService.cs/AddTransaction

//設置共識狀態爲已發送簽名
                    context.State |= ConsensusState.SignatureSent;
                    //添加本地簽名到簽名列表
                    context.Signatures[context.MyIndex] = context.MakeHeader().Sign(context.KeyPair);
                    //廣播共識響應
                    SignAndRelay(context.MakePrepareResponse(context.Signatures[context.MyIndex]));
                    //檢查簽名狀態是否符合共識要求
                    CheckSignatures();
複製代碼

由於全部的議員都須要同步各個共識節點的簽名,因此議員節點也須要監聽網絡中別的節點對議長共識信息的響應並記錄簽名信息。在每次監聽到共識響應並記錄了收到的簽名信息以後,節點須要調用CheckSignatures方法對當前收到的簽名信息是否合法進行判斷,CheckSignatures代碼以下:

源碼位置:neo/Consensus/ConsensusService.cs

/// <summary>
        /// 驗證共識協商結果
        /// </summary>
        private void CheckSignatures()
        {
            //驗證當前已進行的協商的共識節點數是否合法
            if (context.Signatures.Count(p => p != null) >= context.M && context.TransactionHashes.All(p => context.Transactions.ContainsKey(p)))
            {
                //創建合約
                Contract contract = Contract.CreateMultiSigContract(context.M, context.Validators);
                //建立新區塊
                Block block = context.MakeHeader();

                //設置區塊參數
                ContractParametersContext sc = new ContractParametersContext(block);
                for (int i = 0, j = 0; i < context.Validators.Length && j < context.M; i++)
                    if (context.Signatures[i] != null)
                    {
                        sc.AddSignature(contract, context.Validators[i], context.Signatures[i]);
                        j++;
                    }
                //獲取用於驗證區塊的腳本
                sc.Verifiable.Scripts = sc.GetScripts();
                block.Transactions = context.TransactionHashes.Select(p => context.Transactions[p]).ToArray();
                Log($"relay block: {block.Hash}");

                //廣播新區塊
                if (!localNode.Relay(block))
                    Log($"reject block: {block.Hash}");
                //設置當前共識狀態爲新區塊已廣播
                context.State |= ConsensusState.BlockSent;
            }
        }
複製代碼

CheckSignatures方法裏首先是對當前簽名數的合法性判斷。也就是以獲取的合法簽名數量須要不小於M。M這個值的獲取在ConsensusContext類中:

public int M => Validators.Length - (Validators.Length - 1) / 3;
複製代碼

這個值的獲取涉及到NEO共識算法的容錯能力,公式是𝑓 = ⌊ (𝑛−1) / 3 ⌋,理解的話就是隻要有超過網絡2/3的共識節點是一致的,那麼這個結果就是可信的。這個理解起來不是很難,想看分析的話能夠參考官方白皮書。也就是說,只要獲取到的簽名數量合法了,當前節點就能夠根據已有的信息生成新的區塊並向網絡中進行廣播。

0x05 視圖更新

我我的感受NEO的共識協議裏最雞賊的就是這個視圖的概念了。由於NEO網絡的共識間隔是用定時任務來作的,而不是根據全網算力在數學意義上保證每一個區塊生成的大概時間。每輪的共識都是由當前選定的議長來發起,這就有個很大的問題,若是當前選定的議長恰好是個大壞蛋怎麼辦,若是這個議長一直不發起共識或者故意發起錯誤的共識信息致使本輪共識沒法最終完成怎麼辦?爲了解決這個問題,視圖概念被引入,在一個視圖生存週期完成的時候,若是共識尚未被達成,則議員會發送廣播請求進入下一個視圖週期並從新選擇議長,當請求更新視圖的請求大於議員數量的2/3的時候,全網達成共識進入下一個視圖週期從新開始共識過程。議長的選定算法和視圖的編號有關係,這保證了每輪視圖選定的議長不會是同一個。 視圖的生存時間是t*2^(view_number+1),其中t是默認的區塊生成時間間隔,view_number是當前視圖編號。議員在每次共識開始的時候進入編號爲0的視圖週期,若是當前週期完成的時候共識沒有達成,則視圖編號+1,並進入下一個視圖週期。定義視圖生存時間的代碼在ConsensusServer類的InitializeConsensus方法中:

源碼位置:neo/Consensus/ConsensusService.cs/InitializeConsensus

context.State = ConsensusState.Backup;
                    timer_height = context.BlockIndex;
                    timer_view = view_number;

                    //議員超時控制 t*2^(view_number+1)
                    timer.Change(TimeSpan.FromSeconds(Blockchain.SecondsPerBlock << (view_number + 1)), Timeout.InfiniteTimeSpan);
複製代碼

當一輪視圖週期完成的時候,若是共識沒有達成則發出更新視圖請求:

源碼位置:neo/Consensus/ConsensusService.cs

/// <summary>
        /// 發送更新視圖請求
        /// </summary>
        private void RequestChangeView()
        {
            context.State |= ConsensusState.ViewChanging;
            context.ExpectedView[context.MyIndex]++;
            Log($"request change view: height={context.BlockIndex} view={context.ViewNumber} nv={context.ExpectedView[context.MyIndex]} state={context.State}");
            //重置視圖週期
            timer.Change(TimeSpan.FromSeconds(Blockchain.SecondsPerBlock << (context.ExpectedView[context.MyIndex] + 1)), Timeout.InfiniteTimeSpan);
            //簽名並廣播更新視圖消息
            SignAndRelay(context.MakeChangeView());
            //檢查是否能夠更新視圖
            CheckExpectedView(context.ExpectedView[context.MyIndex]);
        }
複製代碼

更新視圖會把當前指望視圖+1而且廣播更新視圖的請求給全部的議員。這裏須要注意的是,在當前節點發送了更新視圖的請求以後,節點的當前視圖編號並無改變,而只是改變了指望視圖編號。 其餘議員在收到更新視圖的廣播後會觸發OnChangeViewReceived方法來更新本身的議員指望視圖列表。

源碼位置:neo/Consensus/ConsensusService.cs

/// <summary>
        /// 議員收到更新視圖的請求
        /// </summary>
        /// <param name="payload"></param>
        /// <param name="message"></param>
        private void OnChangeViewReceived(ConsensusPayload payload, ChangeView message)
        {
            Log($"{nameof(OnChangeViewReceived)}: height={payload.BlockIndex} view={message.ViewNumber} index={payload.ValidatorIndex} nv={message.NewViewNumber}");
            //消息中新視圖編號比當前所記錄的視圖編號還小則爲過期消息
            if (message.NewViewNumber <= context.ExpectedView[payload.ValidatorIndex])
                return;
            //更新目標議員指望視圖編號
            context.ExpectedView[payload.ValidatorIndex] = message.NewViewNumber;
            //檢查是否符合更新視圖要求
            CheckExpectedView(message.NewViewNumber);
        }
複製代碼

在每次收到更新視圖請求以後都須要檢查一下當前收到的請求數量是否是大於2/3的全體議員數,若是知足條件,則在新視圖週期裏從新開始共識過程。

捐贈地址(NEO)
:ASCjW4xpfr8kyVHY1J2PgvcgFbPYa1qX7F


轉自:https://my.oschina.net/u/2276921/blog/1621870

羣交流:795681763

相關文章
相關標籤/搜索