NEO從原始碼分析看共識協議

NEOGO發表於2019-01-23

0x00 概論

不同於比特幣使用的工作量證明(PoW)來實現共識,NEO提出了DBFT共識演算法。DBFT改良自股權證明演算法(PoS),我沒有具體分析過PoS的原始碼,所以暫時還不是很懂具體哪裡做了改動,有興趣的同學可以看下NEO的官方文件。本文主要內容集中在對共識協議原始碼的分析,此外還會有對於一些理論的講解。關於NEO網路通訊部分原始碼分析我還另外寫了一篇部落格,所以本文中所有涉及到通訊的內容我就不再贅述,有興趣的同學可以去看我的另一篇部落格。

0x01 獲取議員名單

NEO的共識協議類似於西方國家的議會,每次區塊的生成都在議長主持下由議會成員共同協商生成新的區塊。NEO網路節點分為兩種,一種為共識節點,另一種為普通節點。普通節點是不參與NEO新區快生成的,對應於普通人,共識節點參與共識的過程並且都有機會成為議長主持新區塊的生成,對應於議員。 看官方文件似乎所有的共識節點都可以到NEO的伺服器註冊為議員,但是貌似成為議員還是有條件的,據社群大佬說,你賬戶裡至少也要由個把億才能成為議員,所以像我這樣的窮逼是沒希望了。但是在分析原始碼的時候我發現似乎並不是這樣。原始碼中在每輪共識開始的時候呼叫ConsensusContext.cs中的Reset方法,在 重置共識的時候會呼叫Blockchain.Default.GetValidators()來獲取議員列表,跟進去這個GetValidators()原始碼:

原始碼位置:neo/Core/BlockChain.cs

         /// <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方法:

原始碼位置: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.cs

    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/Persist

            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



相關文章