乐趣区

AEDPoS合约实现之GetConsensusCommand

正如文章 AElf 共识合约标准中所述,GetConsensusCommand 接口用于获取某个公钥下一次生产区块的时间等信息。

在 AEDPoS 的实现中,其输入仅为一个公钥(public key),该接口实现方法的调用时间另外作为参考(其实也是一个重要的输入)。AElf 区块链中,当系统内部调用只读交易时,合约执行的上下文是自行构造出来的,调用时间也就是通过 C# 自带函数库的 DateTime.UtcNow 生成了一个时间,然后把这个时间转化为 protobuf 提供的时间戳数据类型 Timestamp,传入合约执行的上下文中。

事实上,无论要执行的交易是否为只读交易,合约代码中都可以通过 Context.CurrentBlockTime 来获取当前合约执行上下文传进来的时间戳。

本文主要解释 AEDPoS 共识如何实现 GetConsensusCommand。在此之前,对不了解 AElf 共识的聚聚简单介绍一下 AEDPoS 的流程。

AEDPoS Process

DPoS 的基本概念我们不再赘述,假设现在 AElf 主链通过投票选举出 17 个节点,我们(暂时地)称之为 AElf Core Data Center,简称 CDC。(对应 eos 中的 BP 即 Block Producer 这个概念。)

这些 CDC 是通过全民投票在某个区块高度(或者说时间点)的结果,直接取前 17 名得到。每次重新统计前 17 名候选人并重新任命 CDC,称为换届(Term)。

在每一届中,所有的 CDC 按轮(Round)次生产区块。每一轮有 17+ 1 个时间槽,每位 CDC 随机地占据前 17 个时间槽之一,最后一个时间槽由本轮额外区块生产者负责生产区块。额外区块生产者会根据本轮每个 CDC 公布的随机数初始化下一轮的信息。18 个时间槽后,下一轮开始。如此循环。

Round 的数据结构如下:

// The information of a round.
message Round {

sint64 round_number = 1;
map<string, MinerInRound> real_time_miners_information = 2;
sint64 main_chain_miners_round_number = 3;
sint64 blockchain_age = 4;
string extra_block_producer_of_previous_round = 7;
sint64 term_number = 8;

}

// The information of a miner in a specific round.
message MinerInRound {

sint32 order = 1;
bool is_extra_block_producer = 2;
aelf.Hash in_value = 3;
aelf.Hash out_value = 4;
aelf.Hash signature = 5;
google.protobuf.Timestamp expected_mining_time = 6;
sint64 produced_blocks = 7;
sint64 missed_time_slots = 8;
string public_key = 9;
aelf.Hash previous_in_value = 12;
sint32 supposed_order_of_next_round = 13;
sint32 final_order_of_next_round = 14;
repeated google.protobuf.Timestamp actual_mining_times = 15;// Miners must fill actual mining time when they do the mining.
map<string, bytes> encrypted_in_values = 16;
map<string, bytes> decrypted_previous_inValues = 17;
sint32 produced_tiny_blocks = 18;

}
在 AEDPoS 合约中有一个 map 结构,key 是 long 类型的 RoundNumber,从 1 自增,value 就是上述的 Round 结构,CDC 产生的每个区块都会更新当前轮或者下一轮的信息,以此推进共识和区块生产,并为共识验证提供基本依据。

AEDPoS 的流程大致如此。如果对其中技术细节感兴趣,可见 AElf 白皮书中的 4.2.4 节。对实现细节感兴趣,可见 Github 上 AEDPoS 共识合约项目。

ConsensusCommand

在 AElf 共识合约标准中提到过 ConsensusCommand 的结构:

message ConsensusCommand {

int32 NextBlockMiningLeftMilliseconds = 1;// How many milliseconds left to trigger the mining of next block.
int32 LimitMillisecondsOfMiningBlock = 2;// Time limit of mining next block.
bytes Hint = 3;// Context of Hint is diverse according to the consensus protocol we choose, so we use bytes.
google.protobuf.Timestamp ExpectedMiningTime = 4;

}
对于 AEDPoS 共识,Hint 为 CDC 下一次生产什么类型的区块指了一条明路。我们为 Hint 提供了专门的数据结构 AElfConsensusHint:

message AElfConsensusHint {

AElfConsensusBehaviour behaviour = 1;

}
而区块类型正包含在如下的 Behaviour 中:

enum AElfConsensusBehaviour {

UpdateValue = 0;
NextRound = 1;
NextTerm = 2;
UpdateValueWithoutPreviousInValue = 3;
Nothing = 4;
TinyBlock = 5;

}

逐一解释:

UpdateValue 和 UpdateValueWithoutPreviousInValue 代表该 CDC 要生产某一轮中的一个普普通通的区块。CDC 重点要更新的共识信息包括他前一轮的 in_value(previous_in_value),本轮产生的 out_value,以及本轮用来产生 out_value 的 in_value 的密码片段。(该 CDC 会用 in_value 和其他 CDC 的公钥加密得到 16 个密码片段,其他 CDC 只能各自用自己的私钥解密,当解密的片段达到一定数量后,原始的 in_value 就会被揭露;这是 shamir’s secret sharing 的一个应用,细节可谷歌,AElf 主链用了 ECDH 实现,如果以后有机会可以写文章讨论一下。)除此之外,还要在 actual_mining_times 中增加一条本次实际触发区块生产行为的时间戳。UpdateValueWithoutPreviousInValue 和 UpdateValue 区别仅在于本次不需要公布上一轮的 in_value(previous_in_value),因为当前轮是第一轮,或者刚刚换过届(而该 CDC 是一个萌新 CDC)。

NextRound 代表该 CDC 是本轮的额外区块生产者(或者补救者——当指定的额外区块生产者缺席时),要初始化下一轮信息。下一轮信息包括每个 CDC 的时间槽排列及根据规则指定的下一轮的额外区块生产者。

NextTerm 类似于 NextRound,只不过会重新统计选举的前 17 名,根据新一届的 CDC 初始化下一轮信息。

Nothing 是发现输入的公钥并不是一个 CDC。

TinyBlock 代表该 CDC 刚刚已经更新过共识信息,但是他的时间槽还没有过去,他还有时间去出几个额外的块。目前每个时间槽最多可以出 8 个小块。这样的好处是提高区块验证的效率(eos 也是这么做的)。

有一个时间槽的问题需要特别注意,由于 AEDPoS 选择在创世区块中生成第一轮共识信息(即所有最初的 CDC 的时间槽等信息),而创世区块对于每一个节点都应该是完全一致的,因此第一轮的共识信息不得不指定给一个统一的时间(否则创世区块的哈希值会不一致):如今这个时间是 0001 年 0 点。这样会导致第一轮的时间槽极其不准确(所有的 CDC 直接错过自己的时间槽两千多年),因此在获取第一轮的 ConsensusCommand 时会做特殊处理。

GetConsensusBehaviour

AEDPoS 合约中,要让 GetConsensusCommand 方法返回 ConsensusCommand,首先会根据输入的公钥和调用时间得到 AElfConsensusBehaviour。然后再使用 AElfConsensusBehaviour 判断下一次出块时间等信息。

这里的逻辑相对比较清晰,也许可以用一张图来解释清楚:

v2-08f91a4bf9c62c00eaf1c66da5397d4f_hd.jpg

GetConsensusBehaviour

相关完整代码见:aelf 的 GitHub 主页:

https://github.com/AElfProjec…

接下来我们逐个讨论每个 Behaviour 对应的 ConsensusCommand。

GetConsensusCommand – UpdateValueWithoutPreviousInValue

AElfConsensusBehaviour.UpdateValueWithoutPreviousInValue 的主要作用是实现 Commitment Scheme(WiKi 词条),仅包含一次 commit phase,不包含 reveal phase。对应共识 Mining Process 的阶段,就是每一届(当然包括第一届,也就是链刚刚启动的时候)的第一轮,CDC 要试图产生本轮第一个区块。

如果当前处于第一届的第一轮,则需要从 AEDPoS 共识的 Round.real_time_miners_information 信息中读取提供公钥的 CDC 在本轮中的次序 order,预期出块时间即 order * mining_interval 毫秒之后。mining_interval 默认为 4000ms。

否则,直接从 Round 信息中读取 expected_mining_time,依据此来返回 ConsensusCommand。

if (currentRound.RoundNumber == 1)
{
    // To avoid initial miners fork so fast at the very beginning of current chain.
    nextBlockMiningLeftMilliseconds =
        currentRound.GetMiningOrder(publicKey).Mul(currentRound.GetMiningInterval());
    expectedMiningTime = Context.CurrentBlockTime.AddMilliseconds(nextBlockMiningLeftMilliseconds);
}
else
{
    // As normal as case AElfConsensusBehaviour.UpdateValue.
    expectedMiningTime = currentRound.GetExpectedMiningTime(publicKey);
    nextBlockMiningLeftMilliseconds = (int) (expectedMiningTime - Context.CurrentBlockTime).Milliseconds();}

GetConsensusCommand – UpdateValue

AElfConsensusBehaviour.UpdateValue 包含一次 Commitment Scheme 中的 reveal phase,一次新的 commit phase。对应共识 Mining Process 的阶段为每一届的第二轮及以后,CDC 试图产生本轮的第一个区块。

直接读取当前轮的 Round 信息中该 CDC 的公钥对应的 expected_mining_time 字段即可。

expectedMiningTime = currentRound.GetExpectedMiningTime(publicKey);
nextBlockMiningLeftMilliseconds = (int) (expectedMiningTime - currentBlockTime).Milliseconds();

GetConsensusCommand – NextRound

AElfConsensusBehaviour.NextRound 会根据本轮各个 CDC 公布的信息,按照顺序计算规则,生成下一轮各个 CDC 的顺序和对应时间槽,将 RoundNumber 往后推进一个数字。

对于本轮指定为额外区块生产者的 CDC,直接读取本轮的额外区块生成时间槽即可。

否则,为了防止指定的额外区块生产者掉线或者在另外一个分叉上出块(在网络不稳定的情况下会出现分叉),其他所有的 CDC 也会得到一个互不相同额外区块生产的时间槽,这些 CDC 在同步到任何一个 CDC 生产的额外区块后,会立刻重置自己的调度器,所以不必担心产生冲突。

对于第一届第一轮的特殊处理同 AElfConsensusBehaviour.UpdateValueWithoutPreviousInValue。

...
var minerInRound = currentRound.RealTimeMinersInformation[publicKey];
if (currentRound.RoundNumber == 1)
{nextBlockMiningLeftMilliseconds = minerInRound.Order.Add(currentRound.RealTimeMinersInformation.Count).Sub(1)
            .Mul(currentRound.GetMiningInterval());
    expectedMiningTime = Context.CurrentBlockTime.AddMilliseconds(nextBlockMiningLeftMilliseconds);
}
else
{
    expectedMiningTime =
        currentRound.ArrangeAbnormalMiningTime(minerInRound.PublicKey, Context.CurrentBlockTime);
    nextBlockMiningLeftMilliseconds = (int) (expectedMiningTime - Context.CurrentBlockTime).Milliseconds();}
...

/// <summary>
/// If one node produced block this round or missed his time slot,
/// whatever how long he missed, we can give him a consensus command with new time slot
/// to produce a block (for terminating current round and start new round).
/// The schedule generated by this command will be cancelled
/// if this node executed blocks from other nodes.
/// </summary>
/// <returns></returns>
public Timestamp ArrangeAbnormalMiningTime(string publicKey, Timestamp currentBlockTime)
{if (!RealTimeMinersInformation.ContainsKey(publicKey))
    {return new Timestamp {Seconds = long.MaxValue};
    }

    miningInterval = GetMiningInterval();

    if (miningInterval <= 0)
    {
        // Due to incorrect round information.
        return new Timestamp {Seconds = long.MaxValue};
    }

    var minerInRound = RealTimeMinersInformation[publicKey];

    if (GetExtraBlockProducerInformation().PublicKey == publicKey)
    {var distance = (GetExtraBlockMiningTime().AddMilliseconds(miningInterval) - currentBlockTime).Milliseconds();
        if (distance > 0)
        {return GetExtraBlockMiningTime();
        }
    }

    var distanceToRoundStartTime = (currentBlockTime - GetStartTime()).Milliseconds();
    var missedRoundsCount = distanceToRoundStartTime.Div(TotalMilliseconds(miningInterval));
    var expectedEndTime = GetExpectedEndTime(missedRoundsCount, miningInterval);
    return expectedEndTime.AddMilliseconds(minerInRound.Order.Mul(miningInterval));
}

GetConsensusCommand – NextTerm

AElfConsensusBehaviour.NextTerm 会根据当前的选举结果重新选定 17 位 CDC,生成新一届第一轮的信息。方法同 AElfConsensusBehaviour.NextRound 不对第一届第一轮做特殊处理的情况。

expectedMiningTime =
    currentRound.ArrangeAbnormalMiningTime(minerInRound.PublicKey, Context.CurrentBlockTime);
nextBlockMiningLeftMilliseconds = (int) (expectedMiningTime - Context.CurrentBlockTime).Milliseconds();

GetConsensusCommand – TinyBlock

AElfConsensusBehaviour.TinyBlock 发生在两种情况下:

当前 CDC 为上一轮的额外区块生产者,在生产完包含 NextRound 交易的区块以后,需要在同一个时间槽里继续生产最多 7 个区块;
当前 CDC 刚刚生产过包含 UpdateValue 交易的区块,需要在同一个时间槽继续生产最多 7 个区块。
基本判断逻辑是,如果当前 CDC 为本轮出过包含 UpdateValue 交易的块,即情况 2,就结合当前 CDC 是上一轮额外区块生产者的情况,把一个长度为 4000ms 的时间槽切分成 8 个 500ms 的小块时间槽,进行分配;否则为上述的情况 1,直接根据已经出过的小块的数量分配一个合理的小块时间槽。

/// <summary>
/// We have 2 cases of producing tiny blocks:
/// 1. After generating information of next round (producing extra block)
/// 2. After publishing out value (producing normal block)
/// </summary>
/// <param name="currentRound"></param>
/// <param name="publicKey"></param>
/// <param name="nextBlockMiningLeftMilliseconds"></param>
/// <param name="expectedMiningTime"></param>
private void GetScheduleForTinyBlock(Round currentRound, string publicKey,
    out int nextBlockMiningLeftMilliseconds, out Timestamp expectedMiningTime)
{var minerInRound = currentRound.RealTimeMinersInformation[publicKey];
    var producedTinyBlocks = minerInRound.ProducedTinyBlocks;
    var currentRoundStartTime = currentRound.GetStartTime();
    var producedTinyBlocksForPreviousRound =
        minerInRound.ActualMiningTimes.Count(t => t < currentRoundStartTime);
    var miningInterval = currentRound.GetMiningInterval();
    var timeForEachBlock = miningInterval.Div(AEDPoSContractConstants.TotalTinySlots);//8 for now
    expectedMiningTime = currentRound.GetExpectedMiningTime(publicKey);

    if (minerInRound.IsMinedBlockForCurrentRound())
    {// After publishing out value (producing normal block)
        expectedMiningTime = expectedMiningTime.AddMilliseconds(
            currentRound.ExtraBlockProducerOfPreviousRound != publicKey
                ? producedTinyBlocks.Mul(timeForEachBlock)
                // Previous extra block producer can produce double tiny blocks at most.
                : producedTinyBlocks.Sub(producedTinyBlocksForPreviousRound).Mul(timeForEachBlock));
    }
    else if (TryToGetPreviousRoundInformation(out _))
    {// After generating information of next round (producing extra block)
        expectedMiningTime = currentRound.GetStartTime().AddMilliseconds(-miningInterval)
            .AddMilliseconds(producedTinyBlocks.Mul(timeForEachBlock));
    }

    if (currentRound.RoundNumber == 1 ||
        currentRound.RoundNumber == 2 && !minerInRound.IsMinedBlockForCurrentRound())
    {nextBlockMiningLeftMilliseconds = GetNextBlockMiningLeftMillisecondsForFirstRound(minerInRound, miningInterval);
    }
    else
    {
        TuneExpectedMiningTimeForTinyBlock(miningInterval,
            currentRound.GetExpectedMiningTime(publicKey),
            ref expectedMiningTime);

        nextBlockMiningLeftMilliseconds = (int) (expectedMiningTime - Context.CurrentBlockTime).Milliseconds();

        var toPrint = expectedMiningTime;
        Context.LogDebug(() =>
            $"expected mining time: {toPrint}, current block time: {Context.CurrentBlockTime}." +
            $"next: {(int) (toPrint - Context.CurrentBlockTime).Milliseconds()}");
    }
}

/// <summary>
/// Finally make current block time in the range of (expected_mining_time, expected_mining_time + time_for_each_block)
/// </summary>
/// <param name="miningInterval"></param>
/// <param name="originExpectedMiningTime"></param>
/// <param name="expectedMiningTime"></param>
private void TuneExpectedMiningTimeForTinyBlock(int miningInterval, Timestamp originExpectedMiningTime,
    ref Timestamp expectedMiningTime)
{var timeForEachBlock = miningInterval.Div(AEDPoSContractConstants.TotalTinySlots);//8 for now
    var currentBlockTime = Context.CurrentBlockTime;
    while (expectedMiningTime < currentBlockTime &&
           expectedMiningTime < originExpectedMiningTime.AddMilliseconds(miningInterval))
    {expectedMiningTime = expectedMiningTime.AddMilliseconds(timeForEachBlock);
        var toPrint = expectedMiningTime.Clone();
        Context.LogDebug(() => $"Moving to next tiny block time slot. {toPrint}");
    }
}

最后再次调整区块执行时间限制

在根据 Behaviour 计算出下一次生产区块的时间后,有可能会出现下一次出快时间为负数的情况(即当前时间已经超过理论上的下一次出块时间),此时可以把区块打包时间限制设置为 0。最后为了给生成系统交易、网络延时等预留一定的时间,会把区块执行时间限制再乘以一个系数(待优化)。

private void AdjustLimitMillisecondsOfMiningBlock(Round currentRound, string publicKey,

    int nextBlockMiningLeftMilliseconds, out int limitMillisecondsOfMiningBlock)
{var minerInRound = currentRound.RealTimeMinersInformation[publicKey];
    var miningInterval = currentRound.GetMiningInterval();
    var offset = 0;
    if (nextBlockMiningLeftMilliseconds < 0)
    {Context.LogDebug(() => "Next block mining left milliseconds is less than 0.");
        offset = nextBlockMiningLeftMilliseconds;
    }

    limitMillisecondsOfMiningBlock = miningInterval.Div(AEDPoSContractConstants.TotalTinySlots).Add(offset);
    limitMillisecondsOfMiningBlock = limitMillisecondsOfMiningBlock < 0 ? 0 : limitMillisecondsOfMiningBlock;

    var currentRoundStartTime = currentRound.GetStartTime();
    var producedTinyBlocksForPreviousRound =
        minerInRound.ActualMiningTimes.Count(t => t < currentRoundStartTime);

    if (minerInRound.ProducedTinyBlocks == AEDPoSContractConstants.TinyBlocksNumber ||
        minerInRound.ProducedTinyBlocks ==
        AEDPoSContractConstants.TinyBlocksNumber.Add(producedTinyBlocksForPreviousRound))
    {limitMillisecondsOfMiningBlock = limitMillisecondsOfMiningBlock.Div(2);
    }
    else
    {
        limitMillisecondsOfMiningBlock = limitMillisecondsOfMiningBlock
            .Mul(AEDPoSContractConstants.LimitBlockExecutionTimeWeight)//3 for now
            .Div(AEDPoSContractConstants.LimitBlockExecutionTimeTotalWeight);//5 for now
    }
}

以上完整代码可见:

https://github.com/AElfProjec…

可能的优化方向

基于现有逻辑优化条件,有可能的话实现成函数式,以增加代码可读性(也可能更糟)。

本文初衷只是我自己整理之前的代码,看有没有可能优化的地方,果然有一点收获,删掉了一些没有必要的判断。

如果有人能 review 完代码请务必告诉我。能发现任何问题就感激不尽了……

                                -----aelf 开发者社区:EanCuznaivy  
退出移动版