diff --git a/src/Nethermind/Nethermind.Xdc.Test/Helpers/XdcBlockHeaderBuilder.cs b/src/Nethermind/Nethermind.Xdc.Test/Helpers/XdcBlockHeaderBuilder.cs index 08c73ddd43e8..1b951fe0ab89 100644 --- a/src/Nethermind/Nethermind.Xdc.Test/Helpers/XdcBlockHeaderBuilder.cs +++ b/src/Nethermind/Nethermind.Xdc.Test/Helpers/XdcBlockHeaderBuilder.cs @@ -84,12 +84,24 @@ public XdcBlockHeaderBuilder WithExtraConsensusData(ExtraFieldsV2 extraFieldsV2) return this; } + public new XdcBlockHeaderBuilder WithParentHash(Hash256 parentHash) + { + XdcTestObjectInternal.ParentHash = parentHash; + return this; + } + public new XdcBlockHeaderBuilder WithBaseFee(UInt256 baseFee) { TestObjectInternal.BaseFeePerGas = baseFee; return this; } + public new XdcBlockHeaderBuilder WithNumber(long blockNumber) + { + TestObjectInternal.Number = blockNumber; + return this; + } + public new XdcBlockHeaderBuilder WithHash(Hash256 hash256) { TestObjectInternal.Hash = hash256; diff --git a/src/Nethermind/Nethermind.Xdc.Test/QuorumCertificateManagerTest.cs b/src/Nethermind/Nethermind.Xdc.Test/QuorumCertificateManagerTest.cs index 05fe1d05c2c9..f93869f5a97a 100644 --- a/src/Nethermind/Nethermind.Xdc.Test/QuorumCertificateManagerTest.cs +++ b/src/Nethermind/Nethermind.Xdc.Test/QuorumCertificateManagerTest.cs @@ -30,7 +30,8 @@ public void VerifyCertificate_CertificateIsNull_ThrowsArgumentNullException() Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For()); + Substitute.For(), + new BlockInfoValidator()); Assert.That(() => quorumCertificateManager.VerifyCertificate(null!, Build.A.XdcBlockHeader().TestObject, out _), Throws.ArgumentNullException); } @@ -43,7 +44,8 @@ public void VerifyCertificate_HeaderIsNull_ThrowsArgumentNullException() Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For()); + Substitute.For(), + new BlockInfoValidator()); Assert.That(() => quorumCertificateManager.VerifyCertificate(Build.A.QuorumCertificate().TestObject, null!, out _), Throws.ArgumentNullException); } @@ -97,7 +99,8 @@ public void VerifyCertificate_QcWithDifferentParameters_ReturnsExpected(QuorumCe Substitute.For(), Substitute.For(), specProvider, - epochSwitchManager); + epochSwitchManager, + new BlockInfoValidator()); Assert.That(quorumCertificateManager.VerifyCertificate(quorumCert, xdcBlockHeaderBuilder.TestObject, out _), Is.EqualTo(expected)); } diff --git a/src/Nethermind/Nethermind.Xdc.Test/VotesManagerTests.cs b/src/Nethermind/Nethermind.Xdc.Test/VotesManagerTests.cs new file mode 100644 index 000000000000..4bc770c08fe7 --- /dev/null +++ b/src/Nethermind/Nethermind.Xdc.Test/VotesManagerTests.cs @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Nethermind.Blockchain; +using Nethermind.Consensus; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; +using Nethermind.Core.Test.Builders; +using Nethermind.Crypto; +using Nethermind.Serialization.Rlp; +using Nethermind.Xdc.Spec; +using Nethermind.Xdc.Types; +using NSubstitute; +using NUnit.Framework; + +namespace Nethermind.Xdc.Test; + +public class VotesManagerTests +{ + public static IEnumerable HandleVoteCases() + { + var (keys, _) = MakeKeys(20); + var masternodes = keys.Select(k => k.Address).ToArray(); + + ulong currentRound = 1; + XdcBlockHeader header = Build.A.XdcBlockHeader() + .WithExtraConsensusData(new ExtraFieldsV2(currentRound, new QuorumCertificate(new BlockRoundInfo(Hash256.Zero, 0, 0), null, 450))) + .TestObject; + var info = new BlockRoundInfo(header.Hash!, currentRound, header.Number); + + // Base case + yield return new TestCaseData(masternodes, header, currentRound, keys.Select(k => BuildSignedVote(info, 450, k)).ToArray(), info, 1); + + // Not enough valid signers + var (extraKeys, _) = MakeKeys(2); + var votes = keys.Take(12).Select(k => BuildSignedVote(info, 450, k)).ToArray(); + var extraVotes = extraKeys.Select(k => BuildSignedVote(info, 450, k)).ToArray(); + yield return new TestCaseData(masternodes, header, currentRound, votes.Concat(extraVotes).ToArray(), info, 0); + + // Wrong gap number generates different keys for the vote pool + var keysForVotes = keys.Take(14).ToArray(); + var votesWithDiffGap = new List(capacity: keysForVotes.Length); + for (var i = 0; i < keysForVotes.Length - 3; i++) votesWithDiffGap.Add(BuildSignedVote(info, 450, keysForVotes[i])); + for (var i = keysForVotes.Length - 3; i < keysForVotes.Length; i++) votesWithDiffGap.Add(BuildSignedVote(info, 451, keysForVotes[i])); + yield return new TestCaseData(masternodes, header, currentRound, votesWithDiffGap.ToArray(), info, 0); + } + + [TestCaseSource(nameof(HandleVoteCases))] + public async Task HandleVote_VariousScenarios_CommitsQcExpectedTimes(Address[] masternodes, XdcBlockHeader header, ulong currentRound, Vote[] votes, BlockRoundInfo info, int expectedCalls) + { + var context = new XdcContext { CurrentRound = currentRound }; + IBlockTree blockTree = Substitute.For(); + blockTree.FindHeader(Arg.Any(), Arg.Any()).Returns(header); + + IEpochSwitchManager epochSwitchManager = Substitute.For(); + var epochSwitchInfo = new EpochSwitchInfo(masternodes, [], [], info); + epochSwitchManager + .GetEpochSwitchInfo(header) + .Returns(epochSwitchInfo); + + ISnapshotManager snapshotManager = Substitute.For(); + IQuorumCertificateManager quorumCertificateManager = Substitute.For(); + ISpecProvider specProvider = Substitute.For(); + IXdcReleaseSpec xdcReleaseSpec = Substitute.For(); + xdcReleaseSpec.CertThreshold.Returns(0.667); + specProvider.GetSpec(Arg.Any()).Returns(xdcReleaseSpec); + + ISigner signer = Substitute.For(); + IForensicsProcessor forensicsProcessor = Substitute.For(); + IBlockInfoValidator blockInfoValidator = new BlockInfoValidator(); + + var voteManager = new VotesManager(context, blockTree, epochSwitchManager, snapshotManager, quorumCertificateManager, + specProvider, signer, forensicsProcessor, blockInfoValidator); + + foreach (var v in votes) + await voteManager.HandleVote(v); + + quorumCertificateManager.Received(expectedCalls).CommitCertificate(Arg.Any()); + } + + [Test] + public async Task HandleVote_HeaderMissing_ReturnsEarly() + { + var (keys, _) = MakeKeys(20); + var masternodes = keys.Select(k => k.Address).ToArray(); + + ulong currentRound = 1; + var context = new XdcContext { CurrentRound = currentRound }; + IBlockTree blockTree = Substitute.For(); + XdcBlockHeader header = Build.A.XdcBlockHeader() + .WithExtraConsensusData(new ExtraFieldsV2(currentRound, new QuorumCertificate(new BlockRoundInfo(Hash256.Zero, 0, 0), null, 450))) + .TestObject; + + var info = new BlockRoundInfo(header.Hash!, currentRound, header.Number); + IEpochSwitchManager epochSwitchManager = Substitute.For(); + var epochSwitchInfo = new EpochSwitchInfo(masternodes, [], [], info); + epochSwitchManager + .GetEpochSwitchInfo(header) + .Returns(epochSwitchInfo); + + ISnapshotManager snapshotManager = Substitute.For(); + IQuorumCertificateManager quorumCertificateManager = Substitute.For(); + ISpecProvider specProvider = Substitute.For(); + IXdcReleaseSpec xdcReleaseSpec = Substitute.For(); + xdcReleaseSpec.CertThreshold.Returns(0.667); + specProvider.GetSpec(Arg.Any()).Returns(xdcReleaseSpec); + + ISigner signer = Substitute.For(); + IForensicsProcessor forensicsProcessor = Substitute.For(); + IBlockInfoValidator blockInfoValidator = new BlockInfoValidator(); + + var voteManager = new VotesManager(context, blockTree, epochSwitchManager, snapshotManager, quorumCertificateManager, + specProvider, signer, forensicsProcessor, blockInfoValidator); + + var keysForVotes = keys.ToArray(); + for (var i = 0; i < keysForVotes.Length - 1; i++) + await voteManager.HandleVote(BuildSignedVote(info, gap: 450, keysForVotes[i])); + + quorumCertificateManager.DidNotReceive().CommitCertificate(Arg.Any()); + + // Now insert header and send one more + blockTree.FindHeader(header.Hash!, Arg.Any()).Returns(header); + await voteManager.HandleVote(BuildSignedVote(info, 450, keysForVotes[keysForVotes.Length - 1])); + + quorumCertificateManager.Received(1).CommitCertificate(Arg.Any()); + } + + [TestCase(5UL, 5UL, 5UL, false)] // Current round already voted + [TestCase(5UL, 4UL, 4UL, false)] // Current round different from blockInfoRound + [TestCase(5UL, 4UL, 5UL, true)] // No LockQc + public void VerifyVotingRules_FirstChecks_ReturnsExpected(ulong currentRound, ulong highestVotedRound, ulong blockInfoRound, bool expected) + { + var ctx = new XdcContext { CurrentRound = currentRound, HighestVotedRound = highestVotedRound }; + VotesManager votesManager = BuildVoteManager(ctx); + + var blockInfo = new BlockRoundInfo(Hash256.Zero, blockInfoRound, 100); + var qc = new QuorumCertificate(blockInfo, null, 0); + + Assert.That(votesManager.VerifyVotingRules(blockInfo, qc), Is.EqualTo(expected)); + } + + [Test] + public void VerifyVotingRules_QcNewerThanLockQc_ReturnsTrue() + { + var lockQc = new QuorumCertificate(new BlockRoundInfo(Hash256.Zero, 4, 99), null, 0); + var ctx = new XdcContext { CurrentRound = 5, HighestVotedRound = 4, LockQC = lockQc }; + VotesManager votesManager = BuildVoteManager(ctx); + + var blockInfo = new BlockRoundInfo(Hash256.Zero, 5, 100); + var qc = new QuorumCertificate(blockInfo, null, 0); + + Assert.That(votesManager.VerifyVotingRules(blockInfo, qc), Is.True); + } + + public static IEnumerable ExtendingFromAncestorCases() + { + XdcBlockHeader[] headers = GenerateBlockHeaders(3, 99); + IBlockTree blockTree = Substitute.For(); + var headerByHash = headers.ToDictionary(h => h.Hash!, h => h); + + XdcBlockHeader nonRelatedHeader = Build.A.XdcBlockHeader().WithNumber(99).TestObject; + nonRelatedHeader.Hash ??= nonRelatedHeader.CalculateHash().ToHash256(); + headerByHash[nonRelatedHeader.Hash] = nonRelatedHeader; + + blockTree.FindHeader(Arg.Any()).Returns(args => + { + var hash = (Hash256)args[0]; + return headerByHash.TryGetValue(hash, out var header) ? header : null; + }); + + var blockInfo = new BlockRoundInfo(headers[2].Hash!, 5, headers[2].Number); + + var ancestorQc = new QuorumCertificate(new BlockRoundInfo(headers[0].Hash!, 3, headers[0].Number), null, 0); + yield return new TestCaseData(blockTree, ancestorQc, blockInfo, true); + + var nonRelatedQc = new QuorumCertificate(new BlockRoundInfo(nonRelatedHeader.Hash, 3, nonRelatedHeader.Number), null, 0); + yield return new TestCaseData(blockTree, nonRelatedQc, blockInfo, false); + } + + [TestCaseSource(nameof(ExtendingFromAncestorCases))] + public void VerifyVotingRules_CheckExtendingFromAncestor_ReturnsExpected(IBlockTree tree, QuorumCertificate lockQc, BlockRoundInfo blockInfo, bool expected) + { + var ctx = new XdcContext { CurrentRound = 5, HighestVotedRound = 4, LockQC = lockQc }; + VotesManager votesManager = BuildVoteManager(ctx, tree); + var qc = new QuorumCertificate(new BlockRoundInfo(Hash256.Zero, 3, 99), null, 0); + + Assert.That(votesManager.VerifyVotingRules(blockInfo, qc), Is.EqualTo(expected)); + } + + private static (PrivateKey[] keys, Address[] addrs) MakeKeys(int n) + { + var keyBuilder = new PrivateKeyGenerator(); + PrivateKey[] keys = keyBuilder.Generate(n).ToArray(); + Address[] addrs = keys.Select(k => k.Address).ToArray(); + return (keys, addrs); + } + + private static Vote BuildSignedVote( + BlockRoundInfo info, ulong gap, PrivateKey key) + { + var decoder = new VoteDecoder(); + var ecdsa = new EthereumEcdsa(0); + var vote = new Vote(info, gap); + var stream = new KeccakRlpStream(); + decoder.Encode(stream, vote, RlpBehaviors.ForSealing); + vote.Signature = ecdsa.Sign(key, stream.GetValueHash()); + vote.Signer = key.Address; + return vote; + } + + private static VotesManager BuildVoteManager(XdcContext ctx, IBlockTree? blockTree = null) + { + blockTree ??= Substitute.For(); + IEpochSwitchManager epochSwitchManager = Substitute.For(); + ISnapshotManager snapshotManager = Substitute.For(); + IQuorumCertificateManager quorumCertificateManager = Substitute.For(); + ISpecProvider specProvider = Substitute.For(); + ISigner signer = Substitute.For(); + IForensicsProcessor forensicsProcessor = Substitute.For(); + IBlockInfoValidator blockInfoValidator = Substitute.For(); + + return new VotesManager(ctx, blockTree, epochSwitchManager, snapshotManager, quorumCertificateManager, + specProvider, signer, forensicsProcessor, blockInfoValidator); + } + + private static XdcBlockHeader[] GenerateBlockHeaders(int n, long blockNumber) + { + var headers = new XdcBlockHeader[n]; + var parentHash = Hash256.Zero; + var number = blockNumber; + for (int i = 0; i < n; i++, number++) + { + headers[i] = Build.A.XdcBlockHeader() + .WithNumber(number) + .WithParentHash(parentHash) + .TestObject; + parentHash = headers[i].CalculateHash().ToHash256(); + } + + return headers; + } +} diff --git a/src/Nethermind/Nethermind.Xdc/BlockInfoValidator.cs b/src/Nethermind/Nethermind.Xdc/BlockInfoValidator.cs new file mode 100644 index 000000000000..262edbe9684a --- /dev/null +++ b/src/Nethermind/Nethermind.Xdc/BlockInfoValidator.cs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Xdc.Types; + +namespace Nethermind.Xdc; + +public interface IBlockInfoValidator +{ + bool ValidateBlockInfo(BlockRoundInfo blockInfo, XdcBlockHeader blockHeader); +} + +public class BlockInfoValidator : IBlockInfoValidator +{ + public bool ValidateBlockInfo(BlockRoundInfo blockInfo, XdcBlockHeader blockHeader) => + (blockInfo.BlockNumber == blockHeader.Number) + && (blockInfo.Hash == blockHeader.Hash) + && (blockInfo.Round == blockHeader.ExtraConsensusData.CurrentRound); +} diff --git a/src/Nethermind/Nethermind.Xdc/Errors/QuorumCertificateException.cs b/src/Nethermind/Nethermind.Xdc/Errors/QuorumCertificateException.cs index 81c208dfe276..9cb282969361 100644 --- a/src/Nethermind/Nethermind.Xdc/Errors/QuorumCertificateException.cs +++ b/src/Nethermind/Nethermind.Xdc/Errors/QuorumCertificateException.cs @@ -3,19 +3,9 @@ using Nethermind.Blockchain; using Nethermind.Xdc.Types; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Nethermind.Xdc.Errors; -internal class QuorumCertificateException : BlockchainException +internal class QuorumCertificateException(QuorumCertificate certificate, string message) : BlockchainException(message) { - public QuorumCertificateException(QuorumCertificate certificate, string message) : base(message) - { - Certificate = certificate; - } - - public QuorumCertificate Certificate { get; } + public QuorumCertificate Certificate { get; } = certificate; } diff --git a/src/Nethermind/Nethermind.Xdc/IBlockInfoValidator.cs b/src/Nethermind/Nethermind.Xdc/IBlockInfoValidator.cs deleted file mode 100644 index 60164059a164..000000000000 --- a/src/Nethermind/Nethermind.Xdc/IBlockInfoValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Nethermind.Xdc.Types; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Nethermind.Xdc; -internal interface IBlockInfoValidator -{ - void VerifyBlockInfo(BlockRoundInfo blockInfo, XdcBlockHeader blockHeader); -} diff --git a/src/Nethermind/Nethermind.Xdc/IEpochSwitchManager.cs b/src/Nethermind/Nethermind.Xdc/IEpochSwitchManager.cs index 51da0419e926..f5d0b3d7fa7e 100644 --- a/src/Nethermind/Nethermind.Xdc/IEpochSwitchManager.cs +++ b/src/Nethermind/Nethermind.Xdc/IEpochSwitchManager.cs @@ -1,14 +1,8 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Blockchain; using Nethermind.Xdc.Types; using Nethermind.Core.Crypto; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Nethermind.Xdc; public interface IEpochSwitchManager diff --git a/src/Nethermind/Nethermind.Xdc/IForensicsProcessor.cs b/src/Nethermind/Nethermind.Xdc/IForensicsProcessor.cs index 7ee5fe2a8de2..a9aac8a3e094 100644 --- a/src/Nethermind/Nethermind.Xdc/IForensicsProcessor.cs +++ b/src/Nethermind/Nethermind.Xdc/IForensicsProcessor.cs @@ -27,7 +27,7 @@ public interface IForensicsProcessor Task ProcessVoteEquivocation(Vote incomingVote); - Task DetectEquivocationInVotePool(Vote vote, List votePool); + Task DetectEquivocationInVotePool(Vote vote, IEnumerable votePool); Task SendVoteEquivocationProof(Vote vote1, Vote vote2, Address signer); } diff --git a/src/Nethermind/Nethermind.Xdc/IPenaltyHandler.cs b/src/Nethermind/Nethermind.Xdc/IPenaltyHandler.cs index 242441f9791d..a4eec6486712 100644 --- a/src/Nethermind/Nethermind.Xdc/IPenaltyHandler.cs +++ b/src/Nethermind/Nethermind.Xdc/IPenaltyHandler.cs @@ -3,13 +3,6 @@ using Nethermind.Core; using Nethermind.Core.Crypto; -using Nethermind.Evm.State; -using Nethermind.Xdc.Types; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Nethermind.Xdc; public interface IPenaltyHandler diff --git a/src/Nethermind/Nethermind.Xdc/IVotesManager.cs b/src/Nethermind/Nethermind.Xdc/IVotesManager.cs index 49da4cb39dd9..1b55e88cf94f 100644 --- a/src/Nethermind/Nethermind.Xdc/IVotesManager.cs +++ b/src/Nethermind/Nethermind.Xdc/IVotesManager.cs @@ -2,10 +2,6 @@ // SPDX-License-Identifier: LGPL-3.0-only using Nethermind.Xdc.Types; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; namespace Nethermind.Xdc; @@ -13,7 +9,6 @@ internal interface IVotesManager { Task CastVote(BlockRoundInfo blockInfo); Task HandleVote(Vote vote); - Task VerifyVotes(List votes, XdcBlockHeader header); + Task OnReceiveVote(Vote vote); bool VerifyVotingRules(BlockRoundInfo blockInfo, QuorumCertificate qc); - List GetVotes(); } diff --git a/src/Nethermind/Nethermind.Xdc/QuorumCertificateManager.cs b/src/Nethermind/Nethermind.Xdc/QuorumCertificateManager.cs index 27bb9efb426d..fb09828f18c2 100644 --- a/src/Nethermind/Nethermind.Xdc/QuorumCertificateManager.cs +++ b/src/Nethermind/Nethermind.Xdc/QuorumCertificateManager.cs @@ -2,23 +2,16 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; -using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; using Nethermind.Blockchain; -using Nethermind.Config; using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.Core.Specs; using Nethermind.Crypto; using Nethermind.Db; -using Nethermind.Logging; using Nethermind.Serialization.Rlp; -using Nethermind.Xdc; using Nethermind.Xdc.Errors; using Nethermind.Xdc.Spec; using Nethermind.Xdc.Types; @@ -32,18 +25,21 @@ public QuorumCertificateManager( IBlockTree chain, IDb qcDb, ISpecProvider xdcConfig, - IEpochSwitchManager epochSwitchManager) + IEpochSwitchManager epochSwitchManager, + IBlockInfoValidator blockInfoValidator) { _context = context; _blockTree = chain; _qcDb = qcDb; _specProvider = xdcConfig; _epochSwitchManager = epochSwitchManager; + _blockInfoValidator = blockInfoValidator; } private XdcContext _context { get; } private IBlockTree _blockTree; private readonly IDb _qcDb; + private IBlockInfoValidator _blockInfoValidator; private IEpochSwitchManager _epochSwitchManager { get; } private ISpecProvider _specProvider { get; } private EthereumEcdsa _ethereumEcdsa = new EthereumEcdsa(0); @@ -61,8 +57,7 @@ public void CommitCertificate(QuorumCertificate qc) if (proposedBlockHeader is null) throw new InvalidBlockException(proposedBlockHeader, "Proposed block header not found in chain"); - //TODO this could be wrong way of fetching spec if a release spec is defined on a round basis - IXdcReleaseSpec spec = _specProvider.GetXdcSpec(proposedBlockHeader); + IXdcReleaseSpec spec = _specProvider.GetXdcSpec(proposedBlockHeader, _context.CurrentRound); //Can only look for a QC in proposed block after the switch block if (proposedBlockHeader.Number > spec.SwitchBlock) @@ -83,7 +78,7 @@ public void CommitCertificate(QuorumCertificate qc) if (qc.ProposedBlockInfo.Round >= _context.CurrentRound) { - _context.SetNewRound(_blockTree, qc.ProposedBlockInfo.Round); + _context.SetNewRound(_blockTree, qc.ProposedBlockInfo.Round + 1); } } @@ -107,7 +102,7 @@ private void SaveQc(QuorumCertificate qc, long key) private bool CommitBlock(IBlockTree chain, XdcBlockHeader proposedBlockHeader, ulong proposedRound, QuorumCertificate proposedQuorumCert) { IXdcReleaseSpec spec = _specProvider.GetXdcSpec(proposedBlockHeader); - //Can only commit a QC if the proposed block is at least 2 blocks after the switch block, since we want to check grand parent of proposed QC + //Can only commit a QC if the proposed block is at least 2 blocks after the switch block, since we want to check grandparent of proposed QC if ((proposedBlockHeader.Number - 2) <= spec.SwitchBlock) return false; @@ -196,7 +191,7 @@ public bool VerifyCertificate(QuorumCertificate qc, XdcBlockHeader parentHeader, return false; } - if (!ValidateBlockInfo(qc, parentHeader)) + if (!_blockInfoValidator.ValidateBlockInfo(qc.ProposedBlockInfo, parentHeader)) { error = "QC block data does not match header data."; return false; @@ -205,15 +200,4 @@ public bool VerifyCertificate(QuorumCertificate qc, XdcBlockHeader parentHeader, error = null; return true; } - - private bool ValidateBlockInfo(QuorumCertificate qc, XdcBlockHeader parentHeader) - { - if (qc.ProposedBlockInfo.BlockNumber != parentHeader.Number) - return false; - if (qc.ProposedBlockInfo.Hash != parentHeader.Hash) - return false; - if (qc.ProposedBlockInfo.Round != parentHeader.ExtraConsensusData.CurrentRound) - return false; - return true; - } } diff --git a/src/Nethermind/Nethermind.Xdc/Spec/XdcReleaseSpec.cs b/src/Nethermind/Nethermind.Xdc/Spec/XdcReleaseSpec.cs index b7cf4afef86a..48e5629df82b 100644 --- a/src/Nethermind/Nethermind.Xdc/Spec/XdcReleaseSpec.cs +++ b/src/Nethermind/Nethermind.Xdc/Spec/XdcReleaseSpec.cs @@ -29,6 +29,7 @@ public class XdcReleaseSpec : ReleaseSpec, IXdcReleaseSpec public int MinimumSigningTx { get; set; } // Signing txs that a node needs to produce to get out of penalty, after `LimitPenaltyEpoch` public List V2Configs { get; set; } + public void ApplyV2Config(ulong round) { V2ConfigParams configParams = GetConfigAtRound(V2Configs, round); diff --git a/src/Nethermind/Nethermind.Xdc/Types/Vote.cs b/src/Nethermind/Nethermind.Xdc/Types/Vote.cs index cef0aeb5530a..cd92e37e982d 100644 --- a/src/Nethermind/Nethermind.Xdc/Types/Vote.cs +++ b/src/Nethermind/Nethermind.Xdc/Types/Vote.cs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using Nethermind.Core; using Nethermind.Core.Crypto; using RlpBehaviors = Nethermind.Serialization.Rlp.RlpBehaviors; @@ -12,6 +13,7 @@ public class Vote(BlockRoundInfo proposedBlockInfo, ulong gapNumber, Signature s public BlockRoundInfo ProposedBlockInfo { get; set; } = proposedBlockInfo; public ulong GapNumber { get; set; } = gapNumber; public Signature? Signature { get; set; } = signature; + public Address? Signer { get; set; } public override string ToString() => $"{ProposedBlockInfo.Round}:{GapNumber}:{ProposedBlockInfo.BlockNumber}"; diff --git a/src/Nethermind/Nethermind.Xdc/Types/VoteForSign.cs b/src/Nethermind/Nethermind.Xdc/Types/VoteForSign.cs deleted file mode 100644 index a8b7e1a075f9..000000000000 --- a/src/Nethermind/Nethermind.Xdc/Types/VoteForSign.cs +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Nethermind.Core.Crypto; -using Nethermind.Serialization.Rlp; - -namespace Nethermind.Xdc.Types; - -public class VoteForSign(BlockRoundInfo proposedBlockInfo, long gapNumber) -{ - public BlockRoundInfo ProposedBlockInfo { get; set; } = proposedBlockInfo; - public long GapNumber { get; set; } = gapNumber; - public Hash256 SigHash() => Keccak.Compute(Rlp.Encode(this).Bytes); -} diff --git a/src/Nethermind/Nethermind.Xdc/VotesManager.cs b/src/Nethermind/Nethermind.Xdc/VotesManager.cs new file mode 100644 index 000000000000..4988b2753117 --- /dev/null +++ b/src/Nethermind/Nethermind.Xdc/VotesManager.cs @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Blockchain; +using Nethermind.Consensus; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; +using Nethermind.Crypto; +using Nethermind.Serialization.Rlp; +using Nethermind.Xdc.Spec; +using Nethermind.Xdc.Types; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Nethermind.Xdc; + +internal class VotesManager( + XdcContext context, + IBlockTree tree, + IEpochSwitchManager epochSwitchManager, + ISnapshotManager snapshotManager, + IQuorumCertificateManager quorumCertificateManager, + ISpecProvider specProvider, + ISigner signer, + IForensicsProcessor forensicsProcessor, + IBlockInfoValidator blockInfoValidator) : IVotesManager +{ + private IBlockTree _tree = tree; + private IEpochSwitchManager _epochSwitchManager = epochSwitchManager; + private ISnapshotManager _snapshotManager = snapshotManager; + private IQuorumCertificateManager _quorumCertificateManager = quorumCertificateManager; + private XdcContext _ctx = context; + private IForensicsProcessor _forensicsProcessor = forensicsProcessor; + private ISpecProvider _specProvider = specProvider; + private ISigner _signer = signer; + private IBlockInfoValidator _blockInfoValidator = blockInfoValidator; + + private XdcPool _votePool = new(); + private static VoteDecoder _voteDecoder = new(); + private static EthereumEcdsa _ethereumEcdsa = new(0); + private readonly ConcurrentDictionary _qcBuildStartedByRound = new(); + private const int _maxBlockDistance = 7; // Maximum allowed backward distance from the chain head + + public Task CastVote(BlockRoundInfo blockInfo) + { + EpochSwitchInfo epochSwitchInfo = _epochSwitchManager.GetEpochSwitchInfo(blockInfo.Hash); + if (epochSwitchInfo is null) + throw new ArgumentException($"Cannot find epoch info for block {blockInfo.Hash}", nameof(EpochSwitchInfo)); + //Optimize this by fetching with block number and round only + + var header = _tree.FindHeader(blockInfo.Hash) as XdcBlockHeader; + IXdcReleaseSpec spec = _specProvider.GetXdcSpec(header, blockInfo.Round); + long epochSwitchNumber = epochSwitchInfo.EpochSwitchBlockInfo.BlockNumber; + long gapNumber = Math.Max(0, epochSwitchNumber - epochSwitchNumber % spec.EpochLength - spec.Gap); + + var vote = new Vote(blockInfo, (ulong)gapNumber); + // Sets signature and signer for the vote + Sign(vote); + + _ctx.HighestVotedRound = blockInfo.Round; + + HandleVote(vote); + //TODO Broadcast vote to peers + return Task.CompletedTask; + } + + public Task HandleVote(Vote vote) + { + if ((vote.ProposedBlockInfo.Round != _ctx.CurrentRound) && (vote.ProposedBlockInfo.Round != _ctx.CurrentRound + 1)) + { + //We only care about votes for the current round or the next round + return Task.CompletedTask; + } + + // Collect votes + _votePool.Add(vote); + IReadOnlyCollection roundVotes = _votePool.GetItems(vote); + _ = _forensicsProcessor.DetectEquivocationInVotePool(vote, roundVotes); + _ = _forensicsProcessor.ProcessVoteEquivocation(vote); + + //TODO Optimize this by fetching with block number and round only + XdcBlockHeader proposedHeader = _tree.FindHeader(vote.ProposedBlockInfo.Hash, vote.ProposedBlockInfo.BlockNumber) as XdcBlockHeader; + if (proposedHeader is null) + { + //This is a vote for a block we have not seen yet, just return for now + return Task.CompletedTask; + } + + EpochSwitchInfo epochInfo = _epochSwitchManager.GetEpochSwitchInfo(proposedHeader); + if (epochInfo is null) + { + //Unknown epoch switch info, cannot process vote + return Task.CompletedTask; + } + if (epochInfo.Masternodes.Length == 0) + { + throw new InvalidOperationException($"Epoch has empty master node list for {vote.ProposedBlockInfo.Hash}"); + } + + double certThreshold = _specProvider.GetXdcSpec(proposedHeader, vote.ProposedBlockInfo.Round).CertThreshold; + bool thresholdReached = roundVotes.Count >= epochInfo.Masternodes.Length * certThreshold; + if (thresholdReached) + { + if (!_blockInfoValidator.ValidateBlockInfo(vote.ProposedBlockInfo, proposedHeader)) + return Task.CompletedTask; + + Signature[] validSignatures = GetValidSignatures(roundVotes, epochInfo.Masternodes); + if (validSignatures.Length < epochInfo.Masternodes.Length * certThreshold) + return Task.CompletedTask; + + // At this point, the QC should be processed for this *round*. + // Ensure this runs only once per round: + var round = vote.ProposedBlockInfo.Round; + if (!_qcBuildStartedByRound.TryAdd(round, 0)) + return Task.CompletedTask; + OnVotePoolThresholdReached(validSignatures, vote); + } + return Task.CompletedTask; + } + + public void EndRound(ulong round) + { + _votePool.EndRound(round); + + foreach (var key in _qcBuildStartedByRound.Keys) + if (key <= round) _qcBuildStartedByRound.TryRemove(key, out _); + } + + public bool VerifyVotingRules(BlockRoundInfo blockInfo, QuorumCertificate qc) + { + if (_ctx.CurrentRound <= _ctx.HighestVotedRound) + { + return false; + } + + if (blockInfo.Round != _ctx.CurrentRound) + { + return false; + } + + if (_ctx.LockQC is null) + { + return true; + } + + if (qc.ProposedBlockInfo.Round > _ctx.LockQC.ProposedBlockInfo.Round) + { + return true; + } + + if (!IsExtendingFromAncestor(blockInfo, _ctx.LockQC.ProposedBlockInfo)) + { + return false; + } + + return true; + } + + public Task OnReceiveVote(Vote vote) + { + var voteBlockNumber = vote.ProposedBlockInfo.BlockNumber; + var currentBlockNumber = _tree.Head?.Number ?? throw new InvalidOperationException("Failed to get current block number"); + if (Math.Abs(voteBlockNumber - currentBlockNumber) > _maxBlockDistance) + { + // Discarded propagated vote, too far away + return Task.CompletedTask; + } + + if (FilterVote(vote)) + { + //TODO: Broadcast Vote + return HandleVote(vote); + } + return Task.CompletedTask; + } + + private bool FilterVote(Vote vote) + { + if (vote.ProposedBlockInfo.Round < _ctx.CurrentRound) return false; + + Snapshot snapshot = _snapshotManager.GetSnapshotByGapNumber(_tree, vote.GapNumber); + if (snapshot is null) throw new InvalidOperationException($"Failed to get snapshot by gapNumber={vote.GapNumber}"); + // Verify message signature + vote.Signer ??= _ethereumEcdsa.RecoverVoteSigner(vote); + return snapshot.NextEpochCandidates.Any(x => x == vote.Signer); + } + + private void OnVotePoolThresholdReached(Signature[] validSignatures, Vote currVote) + { + QuorumCertificate qc = new(currVote.ProposedBlockInfo, validSignatures, currVote.GapNumber); + _quorumCertificateManager.CommitCertificate(qc); + } + + private bool IsExtendingFromAncestor(BlockRoundInfo currentBlockInfo, BlockRoundInfo ancestorBlockInfo) + { + long blockNumDiff = currentBlockInfo.BlockNumber - ancestorBlockInfo.BlockNumber; + var nextBlockHash = currentBlockInfo.Hash; + + for (int i = 0; i < blockNumDiff; i++) + { + XdcBlockHeader parentHeader = _tree.FindHeader(nextBlockHash) as XdcBlockHeader; + if (parentHeader is null) + return false; + + nextBlockHash = parentHeader.ParentHash; + } + + return nextBlockHash == ancestorBlockInfo.Hash; + } + + private Signature[] GetValidSignatures(IEnumerable votes, Address[] masternodes) + { + var masternodeSet = new HashSet
(masternodes); + var signatures = new List(); + foreach (var vote in votes) + { + if (vote.Signer is null) + { + vote.Signer = _ethereumEcdsa.RecoverVoteSigner(vote); + } + + if (masternodeSet.Contains(vote.Signer)) + { + signatures.Add(vote.Signature); + } + } + return signatures.ToArray(); + } + + private void Sign(Vote vote) + { + KeccakRlpStream stream = new(); + _voteDecoder.Encode(stream, vote, RlpBehaviors.ForSealing); + vote.Signature = _signer.Sign(stream.GetValueHash()); + vote.Signer = _signer.Address; + } +} diff --git a/src/Nethermind/Nethermind.Xdc/XdcBlockHeader.cs b/src/Nethermind/Nethermind.Xdc/XdcBlockHeader.cs index d5d7fbf02d98..e3c91cc92654 100644 --- a/src/Nethermind/Nethermind.Xdc/XdcBlockHeader.cs +++ b/src/Nethermind/Nethermind.Xdc/XdcBlockHeader.cs @@ -67,7 +67,7 @@ public ImmutableArray
? PenaltiesAddress private ExtraFieldsV2 _extraFieldsV2; /// - /// Consensus data that must be included in a V2 block, which contains the quorum certificate and round information. + /// Consensus data that must be included in a V2 block, which contains the quorum certificate and round information. /// public ExtraFieldsV2? ExtraConsensusData { diff --git a/src/Nethermind/Nethermind.Xdc/XdcConstants.cs b/src/Nethermind/Nethermind.Xdc/XdcConstants.cs index b225e63e4a30..a22a5ce1b8a1 100644 --- a/src/Nethermind/Nethermind.Xdc/XdcConstants.cs +++ b/src/Nethermind/Nethermind.Xdc/XdcConstants.cs @@ -3,11 +3,6 @@ using Nethermind.Core.Crypto; using Nethermind.Int256; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Nethermind.Xdc; diff --git a/src/Nethermind/Nethermind.Xdc/XdcExtensions.cs b/src/Nethermind/Nethermind.Xdc/XdcExtensions.cs index 3f5a827733b7..d703804d01a0 100644 --- a/src/Nethermind/Nethermind.Xdc/XdcExtensions.cs +++ b/src/Nethermind/Nethermind.Xdc/XdcExtensions.cs @@ -7,13 +7,10 @@ using Nethermind.Core.Specs; using Nethermind.Crypto; using Nethermind.Serialization.Rlp; -using Nethermind.Xdc; using Nethermind.Xdc.Spec; using Nethermind.Xdc.Types; using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; namespace Nethermind.Xdc; public static class XdcExtensions diff --git a/src/Nethermind/Nethermind.Xdc/XdcPool.cs b/src/Nethermind/Nethermind.Xdc/XdcPool.cs index 8e44117d3334..a30a0878e74a 100644 --- a/src/Nethermind/Nethermind.Xdc/XdcPool.cs +++ b/src/Nethermind/Nethermind.Xdc/XdcPool.cs @@ -1,7 +1,5 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only using System.Collections.Generic; using System.Linq;