From b27dfc3edda6675fdeaa10c0748945587137a283 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:15:43 +0000 Subject: [PATCH 1/4] Add isolated Crown consensus engine Co-authored-by: defunctec <9864793+defunctec@users.noreply.github.com> --- src/CMakeLists.txt | 2 + src/crown/consensus/engine.cpp | 391 +++++++++++++++++ src/crown/consensus/engine.h | 121 ++++++ src/crown/consensus/types.cpp | 198 +++++++++ src/crown/consensus/types.h | 93 ++++ src/test/CMakeLists.txt | 1 + src/test/crown_consensus_tests.cpp | 670 +++++++++++++++++++++++++++++ 7 files changed, 1476 insertions(+) create mode 100644 src/crown/consensus/engine.cpp create mode 100644 src/crown/consensus/engine.h create mode 100644 src/crown/consensus/types.cpp create mode 100644 src/crown/consensus/types.h create mode 100644 src/test/crown_consensus_tests.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2f7bc20b8d76..31aa3ccade01 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -107,6 +107,8 @@ add_library(bitcoin_common STATIC EXCLUDE_FROM_ALL common/url.cpp compressor.cpp core_io.cpp + crown/consensus/engine.cpp + crown/consensus/types.cpp crown/options.cpp crown/validator.cpp deploymentinfo.cpp diff --git a/src/crown/consensus/engine.cpp b/src/crown/consensus/engine.cpp new file mode 100644 index 000000000000..f2b490a17c34 --- /dev/null +++ b/src/crown/consensus/engine.cpp @@ -0,0 +1,391 @@ +// Copyright (c) 2026-present The Crown developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace crown::consensus { +namespace { + +bool ContainsBlock(const std::vector>& values, const std::optional& block_id) +{ + return std::ranges::find(values, block_id) != values.end(); +} + +const StaticValidator* FindValidatorById(const std::vector& validators, const std::string_view id) +{ + for (const auto& validator : validators) { + if (validator.id == id) return &validator; + } + return nullptr; +} + +} // namespace + +Action Action::BroadcastVoteAction(Vote vote) +{ + Action action{Type::BROADCAST_VOTE}; + action.vote = std::move(vote); + return action; +} + +Action Action::CommitAction(CommitDecision commit) +{ + Action action{Type::COMMIT_DECISION}; + action.commit = std::move(commit); + return action; +} + +Action Action::EquivocationAction(EquivocationEvidence evidence) +{ + Action action{Type::EQUIVOCATION_EVIDENCE}; + action.evidence = std::move(evidence); + return action; +} + +ConsensusEngine::ConsensusEngine(EngineConfig config) : + m_validators(config.validator_set.begin(), config.validator_set.end()), + m_local_signer(std::move(config.local_signer)), + m_total_voting_power(TotalVotingPower(config.validator_set)) +{ +} + +std::vector ConsensusEngine::StartHeight(const int64_t height) +{ + m_state = ConsensusState{ + .height = height, + .round = 0, + .step = Step::PROPOSE, + }; + m_round_votes.clear(); + m_local_signed_votes.clear(); + m_equivocations.clear(); + m_emitted_equivocations.clear(); + return {}; +} + +std::vector ConsensusEngine::EnterRound(const int round) +{ + m_state.round = round; + m_state.step = Step::PROPOSE; + m_state.current_proposal.reset(); + m_state.current_proposal_is_valid = false; + return TryAdvance(); +} + +ConsensusEngine::RoundVotes& ConsensusEngine::MutableRoundVotes(const int round) +{ + return m_round_votes[round]; +} + +const ConsensusEngine::RoundVotes* ConsensusEngine::FindRoundVotes(const int round) const +{ + const auto it = m_round_votes.find(round); + return it == m_round_votes.end() ? nullptr : &it->second; +} + +std::string_view ConsensusEngine::ProposerForRound(const int round) const +{ + return m_validators[round % m_validators.size()].id; +} + +bool ConsensusEngine::HasSeenPrevoteProof(const int round, const BlockID& block_id) const +{ + const RoundVotes* round_votes = FindRoundVotes(round); + if (!round_votes) return false; + return FindQuorum(round_votes->prevotes).has_quorum && FindQuorum(round_votes->prevotes).block_id == block_id; +} + +bool ConsensusEngine::IsCurrentProposalUsable() const +{ + return m_state.current_proposal.has_value() && + m_state.current_proposal_is_valid && + m_state.current_proposal->block_id.has_value(); +} + +bool ConsensusEngine::ProposalAllowsPrevote(const Proposal& proposal) const +{ + if (!proposal.block_id.has_value()) return false; + if (!m_state.locked_block.has_value()) return true; + if (m_state.locked_block == proposal.block_id) return true; + if (proposal.valid_round == NO_VALID_ROUND) return false; + if (proposal.valid_round < m_state.locked_round) return false; + return HasSeenPrevoteProof(proposal.valid_round, *proposal.block_id); +} + +util::Result ConsensusEngine::MakeProposal(const BlockID block_id, const int valid_round) const +{ + if (!IsLocalValidator()) { + return util::Error{Untranslated("This Crown consensus engine has no local validator signer.")}; + } + if (m_state.commit.has_value()) { + return util::Error{Untranslated("Cannot create a Crown proposal after commit.")}; + } + if (m_state.height == 0) { + return util::Error{Untranslated("Consensus height has not been started.")}; + } + if (m_local_signer->validator_id != ProposerForRound(m_state.round)) { + return util::Error{Untranslated(strprintf("Validator '%s' is not the proposer for round %d.", m_local_signer->validator_id, m_state.round))}; + } + if (valid_round < NO_VALID_ROUND || valid_round >= m_state.round) { + return util::Error{Untranslated("Crown proposal valid_round must be -1 or an earlier round.")}; + } + + Proposal proposal{ + .height = m_state.height, + .round = m_state.round, + .proposer_id = m_local_signer->validator_id, + .block_id = block_id, + .valid_round = valid_round, + }; + if (auto signed_result = SignProposal(proposal, m_local_signer->private_key); !signed_result) { + return util::Error{util::ErrorString(signed_result)}; + } + return proposal; +} + +util::Result ConsensusEngine::MakeLocalVote(const VoteType type, const std::optional block_id) +{ + return BuildLocalVote(type, block_id); +} + +bool ConsensusEngine::RegisterVote(const Vote& vote, std::vector& actions) +{ + auto& round_votes = MutableRoundVotes(vote.round); + auto& vote_map = vote.type == VoteType::PREVOTE ? round_votes.prevotes : round_votes.precommits; + const auto it = vote_map.find(vote.validator_id); + if (it == vote_map.end()) { + vote_map.emplace(vote.validator_id, vote); + return true; + } + if (SameVotePayload(it->second, vote)) return false; + + if (VotesConflict(it->second, vote)) { + const auto evidence_key = std::make_tuple(vote.validator_id, vote.type, vote.round); + if (!m_emitted_equivocations.contains(evidence_key)) { + EquivocationEvidence evidence{it->second, vote}; + m_equivocations.push_back(evidence); + actions.push_back(Action::EquivocationAction(evidence)); + m_emitted_equivocations.emplace(evidence_key, true); + } + } + return false; +} + +ConsensusEngine::QuorumResult ConsensusEngine::FindQuorum(const std::map& votes) const +{ + std::vector> candidates; + candidates.emplace_back(std::nullopt); + for (const auto& [validator_id, vote] : votes) { + (void)validator_id; + if (!ContainsBlock(candidates, vote.block_id)) candidates.push_back(vote.block_id); + } + + for (const auto& candidate : candidates) { + int64_t voting_power{0}; + for (const auto& [validator_id, vote] : votes) { + if (vote.block_id != candidate) continue; + const StaticValidator* validator = FindValidatorById(m_validators, validator_id); + if (!validator) continue; + voting_power += validator->voting_power; + } + if (HasQuorum(voting_power, m_total_voting_power)) { + return QuorumResult{ + .has_quorum = true, + .block_id = candidate, + .voting_power = voting_power, + }; + } + } + + return {}; +} + +std::vector ConsensusEngine::CollectSupportingVotes(const std::map& votes, const std::optional& block_id) const +{ + std::vector supporting_votes; + for (const auto& [validator_id, vote] : votes) { + (void)validator_id; + if (vote.block_id == block_id) supporting_votes.push_back(vote); + } + return supporting_votes; +} + +util::Result ConsensusEngine::BuildLocalVote(const VoteType type, const std::optional block_id) +{ + if (!IsLocalValidator()) { + return util::Error{Untranslated("This Crown consensus engine has no local validator signer.")}; + } + + const auto key = std::make_pair(type, m_state.round); + const auto existing = m_local_signed_votes.find(key); + if (existing != m_local_signed_votes.end()) { + if (existing->second.block_id == block_id) { + return util::Error{Untranslated(strprintf("Local validator already signed %s at height %d round %d.", VoteTypeName(type), m_state.height, m_state.round))}; + } + return util::Error{Untranslated(strprintf("Local double-sign guard rejected conflicting %s at height %d round %d.", VoteTypeName(type), m_state.height, m_state.round))}; + } + + Vote vote{ + .type = type, + .height = m_state.height, + .round = m_state.round, + .validator_id = m_local_signer->validator_id, + .block_id = block_id, + }; + if (auto signed_result = SignVote(vote, m_local_signer->private_key); !signed_result) { + return util::Error{util::ErrorString(signed_result)}; + } + m_local_signed_votes.emplace(key, vote); + return vote; +} + +std::vector ConsensusEngine::TryAdvance() +{ + std::vector actions; + if (m_state.commit.has_value()) return actions; + + auto* current_round_votes = &MutableRoundVotes(m_state.round); + if (current_round_votes->proposal.has_value()) { + m_state.current_proposal = current_round_votes->proposal; + m_state.current_proposal_is_valid = current_round_votes->proposal_is_valid; + } + + if (IsLocalValidator() && m_state.step == Step::PROPOSE && IsCurrentProposalUsable()) { + const auto prevote_block = ProposalAllowsPrevote(*m_state.current_proposal) ? m_state.current_proposal->block_id : std::nullopt; + if (auto vote = BuildLocalVote(VoteType::PREVOTE, prevote_block); vote) { + RegisterVote(*vote, actions); + actions.push_back(Action::BroadcastVoteAction(*vote)); + m_state.step = Step::PREVOTE; + } + } + + const auto prevote_quorum = FindQuorum(current_round_votes->prevotes); + if (prevote_quorum.has_quorum) { + if (prevote_quorum.block_id.has_value() && IsCurrentProposalUsable() && m_state.current_proposal->block_id == prevote_quorum.block_id) { + m_state.valid_block = prevote_quorum.block_id; + m_state.valid_round = m_state.round; + if (IsLocalValidator() && m_state.step != Step::COMMIT) { + if (auto vote = BuildLocalVote(VoteType::PRECOMMIT, prevote_quorum.block_id); vote) { + RegisterVote(*vote, actions); + actions.push_back(Action::BroadcastVoteAction(*vote)); + m_state.locked_block = prevote_quorum.block_id; + m_state.locked_round = m_state.round; + m_state.step = Step::PRECOMMIT; + } + } + } else if (!prevote_quorum.block_id.has_value() && IsLocalValidator() && m_state.step != Step::COMMIT) { + if (auto vote = BuildLocalVote(VoteType::PRECOMMIT, std::nullopt); vote) { + RegisterVote(*vote, actions); + actions.push_back(Action::BroadcastVoteAction(*vote)); + m_state.step = Step::PRECOMMIT; + } + } + } + + const auto precommit_quorum = FindQuorum(current_round_votes->precommits); + if (precommit_quorum.has_quorum && precommit_quorum.block_id.has_value() && !m_state.commit.has_value()) { + m_state.step = Step::COMMIT; + m_state.commit = CommitDecision{ + .height = m_state.height, + .round = m_state.round, + .block_id = *precommit_quorum.block_id, + .supporting_votes = CollectSupportingVotes(current_round_votes->precommits, precommit_quorum.block_id), + }; + actions.push_back(Action::CommitAction(*m_state.commit)); + } + + return actions; +} + +std::vector ConsensusEngine::ReceiveProposal(const Proposal& proposal, const bool block_valid) +{ + std::vector actions; + if (m_state.commit.has_value()) return actions; + + if (auto verification = VerifyProposalSignature(proposal, m_validators); !verification) return actions; + if (proposal.height != m_state.height || proposal.round != m_state.round) return actions; + if (proposal.proposer_id != ProposerForRound(proposal.round)) return actions; + if (proposal.valid_round != NO_VALID_ROUND && proposal.valid_round >= proposal.round) return actions; + + auto& round_votes = MutableRoundVotes(proposal.round); + if (round_votes.proposal.has_value() && !SameProposalPayload(*round_votes.proposal, proposal)) return actions; + + round_votes.proposal = proposal; + round_votes.proposal_is_valid = block_valid; + m_state.current_proposal = proposal; + m_state.current_proposal_is_valid = block_valid; + auto next_actions = TryAdvance(); + actions.insert(actions.end(), next_actions.begin(), next_actions.end()); + return actions; +} + +std::vector ConsensusEngine::ReceiveVote(const Vote& vote) +{ + std::vector actions; + if (m_state.commit.has_value()) return actions; + + if (auto verification = VerifyVoteSignature(vote, m_validators); !verification) return actions; + if (vote.height != m_state.height) return actions; + + RegisterVote(vote, actions); + if (vote.round == m_state.round) { + auto next_actions = TryAdvance(); + actions.insert(actions.end(), next_actions.begin(), next_actions.end()); + } + return actions; +} + +std::vector ConsensusEngine::OnTimeout(const TimeoutKind timeout) +{ + std::vector actions; + if (m_state.commit.has_value()) return actions; + + switch (timeout) { + case TimeoutKind::PROPOSE: + if (IsLocalValidator() && m_state.step == Step::PROPOSE) { + if (auto vote = BuildLocalVote(VoteType::PREVOTE, std::nullopt); vote) { + RegisterVote(*vote, actions); + actions.push_back(Action::BroadcastVoteAction(*vote)); + m_state.step = Step::PREVOTE; + } + } + break; + case TimeoutKind::PREVOTE: + if (IsLocalValidator() && m_state.step == Step::PREVOTE) { + if (auto vote = BuildLocalVote(VoteType::PRECOMMIT, std::nullopt); vote) { + RegisterVote(*vote, actions); + actions.push_back(Action::BroadcastVoteAction(*vote)); + m_state.step = Step::PRECOMMIT; + } + } + break; + case TimeoutKind::PRECOMMIT: + if (m_state.step == Step::PRECOMMIT) { + return EnterRound(m_state.round + 1); + } + break; + } + + auto next_actions = TryAdvance(); + actions.insert(actions.end(), next_actions.begin(), next_actions.end()); + return actions; +} + +} // namespace crown::consensus diff --git a/src/crown/consensus/engine.h b/src/crown/consensus/engine.h new file mode 100644 index 000000000000..ca896239b6a3 --- /dev/null +++ b/src/crown/consensus/engine.h @@ -0,0 +1,121 @@ +// Copyright (c) 2026-present The Crown developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_CROWN_CONSENSUS_ENGINE_H +#define BITCOIN_CROWN_CONSENSUS_ENGINE_H + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace crown::consensus { + +struct LocalSigner { + std::string validator_id; + CKey private_key; +}; + +struct ConsensusState { + int64_t height{0}; + int round{0}; + Step step{Step::PROPOSE}; + std::optional locked_block; + int locked_round{NO_VALID_ROUND}; + std::optional valid_block; + int valid_round{NO_VALID_ROUND}; + std::optional current_proposal; + bool current_proposal_is_valid{false}; + std::optional commit; +}; + +struct Action { + enum class Type : uint8_t { + BROADCAST_VOTE = 0, + COMMIT_DECISION, + EQUIVOCATION_EVIDENCE, + }; + + Type type; + std::optional vote; + std::optional commit; + std::optional evidence; + + static Action BroadcastVoteAction(Vote vote); + static Action CommitAction(CommitDecision commit); + static Action EquivocationAction(EquivocationEvidence evidence); +}; + +struct EngineConfig { + std::span validator_set; + std::optional local_signer; +}; + +class ConsensusEngine +{ +public: + explicit ConsensusEngine(EngineConfig config); + + std::vector StartHeight(int64_t height); + std::vector ReceiveProposal(const Proposal& proposal, bool block_valid); + std::vector ReceiveVote(const Vote& vote); + std::vector OnTimeout(TimeoutKind timeout); + + util::Result MakeProposal(BlockID block_id, int valid_round = NO_VALID_ROUND) const; + util::Result MakeLocalVote(VoteType type, std::optional block_id); + + const ConsensusState& GetState() const { return m_state; } + std::string_view ProposerForRound(int round) const; + bool HasSeenPrevoteProof(int round, const BlockID& block_id) const; + const std::vector& GetEquivocationEvidence() const { return m_equivocations; } + +private: + struct RoundVotes { + std::optional proposal; + bool proposal_is_valid{false}; + std::map prevotes; + std::map precommits; + }; + + struct QuorumResult { + bool has_quorum{false}; + std::optional block_id; + int64_t voting_power{0}; + }; + + std::vector m_validators; + std::optional m_local_signer; + int64_t m_total_voting_power{0}; + ConsensusState m_state; + std::map m_round_votes; + std::map, Vote> m_local_signed_votes; + std::vector m_equivocations; + std::map, bool> m_emitted_equivocations; + + RoundVotes& MutableRoundVotes(int round); + const RoundVotes* FindRoundVotes(int round) const; + + bool IsLocalValidator() const { return m_local_signer.has_value(); } + bool IsCurrentProposalUsable() const; + bool ProposalAllowsPrevote(const Proposal& proposal) const; + bool RegisterVote(const Vote& vote, std::vector& actions); + QuorumResult FindQuorum(const std::map& votes) const; + std::vector CollectSupportingVotes(const std::map& votes, const std::optional& block_id) const; + util::Result BuildLocalVote(VoteType type, std::optional block_id); + std::vector TryAdvance(); + std::vector EnterRound(int round); +}; + +} // namespace crown::consensus + +#endif // BITCOIN_CROWN_CONSENSUS_ENGINE_H diff --git a/src/crown/consensus/types.cpp b/src/crown/consensus/types.cpp new file mode 100644 index 000000000000..f323682a32bd --- /dev/null +++ b/src/crown/consensus/types.cpp @@ -0,0 +1,198 @@ +// Copyright (c) 2026-present The Crown developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace crown::consensus { +namespace { + +constexpr std::string_view PROPOSAL_SIGN_DOMAIN{"crown-consensus-proposal-v1"}; +constexpr std::string_view VOTE_SIGN_DOMAIN{"crown-consensus-vote-v1"}; + +void WriteOptionalBlock(HashWriter& writer, const std::optional& block_id) +{ + writer << static_cast(block_id.has_value()); + if (block_id) writer << *block_id; +} + +const StaticValidator* FindValidatorInSet(std::span validators, const std::string_view id) +{ + for (const auto& validator : validators) { + if (validator.id == id) return &validator; + } + return nullptr; +} + +util::Result DecodeKnownValidatorKey(std::span validators, const std::string_view id) +{ + const StaticValidator* validator = FindValidatorInSet(validators, id); + if (!validator) { + return util::Error{Untranslated(strprintf("Unknown Crown validator '%s'.", id))}; + } + return DecodeValidatorPublicKey(validator->consensus_public_key_hex); +} + +template +util::Result SignMessage(const uint256& hash, Message& message, const CKey& private_key) +{ + message.signature.clear(); + if (!private_key.Sign(hash, message.signature)) { + return util::Error{Untranslated("Failed to sign Crown consensus message.")}; + } + return {}; +} + +} // namespace + +std::string_view StepName(const Step step) +{ + switch (step) { + case Step::PROPOSE: + return "PROPOSE"; + case Step::PREVOTE: + return "PREVOTE"; + case Step::PRECOMMIT: + return "PRECOMMIT"; + case Step::COMMIT: + return "COMMIT"; + } + return "UNKNOWN"; +} + +std::string_view VoteTypeName(const VoteType type) +{ + switch (type) { + case VoteType::PREVOTE: + return "PREVOTE"; + case VoteType::PRECOMMIT: + return "PRECOMMIT"; + } + return "UNKNOWN"; +} + +std::string_view TimeoutName(const TimeoutKind timeout) +{ + switch (timeout) { + case TimeoutKind::PROPOSE: + return "PROPOSE_TIMEOUT"; + case TimeoutKind::PREVOTE: + return "PREVOTE_TIMEOUT"; + case TimeoutKind::PRECOMMIT: + return "PRECOMMIT_TIMEOUT"; + } + return "UNKNOWN"; +} + +bool SameProposalPayload(const Proposal& lhs, const Proposal& rhs) +{ + return lhs.height == rhs.height && + lhs.round == rhs.round && + lhs.proposer_id == rhs.proposer_id && + lhs.block_id == rhs.block_id && + lhs.valid_round == rhs.valid_round; +} + +bool SameVotePayload(const Vote& lhs, const Vote& rhs) +{ + return lhs.type == rhs.type && + lhs.height == rhs.height && + lhs.round == rhs.round && + lhs.validator_id == rhs.validator_id && + lhs.block_id == rhs.block_id; +} + +bool VotesConflict(const Vote& lhs, const Vote& rhs) +{ + return lhs.type == rhs.type && + lhs.height == rhs.height && + lhs.round == rhs.round && + lhs.validator_id == rhs.validator_id && + lhs.block_id != rhs.block_id; +} + +uint256 ProposalSigningHash(const Proposal& proposal) +{ + HashWriter writer{}; + writer << std::string{PROPOSAL_SIGN_DOMAIN} + << proposal.height + << proposal.round + << proposal.proposer_id + << proposal.valid_round; + WriteOptionalBlock(writer, proposal.block_id); + return writer.GetHash(); +} + +uint256 VoteSigningHash(const Vote& vote) +{ + HashWriter writer{}; + writer << std::string{VOTE_SIGN_DOMAIN} + << static_cast(vote.type) + << vote.height + << vote.round + << vote.validator_id; + WriteOptionalBlock(writer, vote.block_id); + return writer.GetHash(); +} + +util::Result SignProposal(Proposal& proposal, const CKey& private_key) +{ + return SignMessage(ProposalSigningHash(proposal), proposal, private_key); +} + +util::Result SignVote(Vote& vote, const CKey& private_key) +{ + return SignMessage(VoteSigningHash(vote), vote, private_key); +} + +util::Result VerifyProposalSignature(const Proposal& proposal, const std::span validators) +{ + if (proposal.proposer_id.empty()) { + return util::Error{Untranslated("Malformed Crown proposal: missing proposer id.")}; + } + if (!proposal.block_id.has_value()) { + return util::Error{Untranslated("Malformed Crown proposal: missing block id.")}; + } + if (proposal.round < 0) { + return util::Error{Untranslated("Malformed Crown proposal: negative round.")}; + } + if (proposal.valid_round < NO_VALID_ROUND) { + return util::Error{Untranslated("Malformed Crown proposal: invalid valid_round.")}; + } + auto pubkey = DecodeKnownValidatorKey(validators, proposal.proposer_id); + if (!pubkey) return util::Error{util::ErrorString(pubkey)}; + if (proposal.signature.empty() || !pubkey->Verify(ProposalSigningHash(proposal), proposal.signature)) { + return util::Error{Untranslated(strprintf("Invalid Crown proposal signature from '%s'.", proposal.proposer_id))}; + } + return {}; +} + +util::Result VerifyVoteSignature(const Vote& vote, const std::span validators) +{ + if (vote.validator_id.empty()) { + return util::Error{Untranslated("Malformed Crown vote: missing validator id.")}; + } + if (vote.round < 0) { + return util::Error{Untranslated("Malformed Crown vote: negative round.")}; + } + auto pubkey = DecodeKnownValidatorKey(validators, vote.validator_id); + if (!pubkey) return util::Error{util::ErrorString(pubkey)}; + if (vote.signature.empty() || !pubkey->Verify(VoteSigningHash(vote), vote.signature)) { + return util::Error{Untranslated(strprintf("Invalid Crown %s signature from '%s'.", VoteTypeName(vote.type), vote.validator_id))}; + } + return {}; +} + +} // namespace crown::consensus diff --git a/src/crown/consensus/types.h b/src/crown/consensus/types.h new file mode 100644 index 000000000000..96119ca9c9cd --- /dev/null +++ b/src/crown/consensus/types.h @@ -0,0 +1,93 @@ +// Copyright (c) 2026-present The Crown developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_CROWN_CONSENSUS_TYPES_H +#define BITCOIN_CROWN_CONSENSUS_TYPES_H + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace crown::consensus { + +using BlockID = uint256; + +inline constexpr int NO_VALID_ROUND{-1}; + +enum class Step : uint8_t { + PROPOSE = 0, + PREVOTE, + PRECOMMIT, + COMMIT, +}; + +enum class VoteType : uint8_t { + PREVOTE = 0, + PRECOMMIT, +}; + +enum class TimeoutKind : uint8_t { + PROPOSE = 0, + PREVOTE, + PRECOMMIT, +}; + +struct Proposal { + int64_t height{0}; + int round{0}; + std::string proposer_id; + std::optional block_id; + int valid_round{NO_VALID_ROUND}; + std::vector signature; +}; + +struct Vote { + VoteType type{VoteType::PREVOTE}; + int64_t height{0}; + int round{0}; + std::string validator_id; + std::optional block_id; + std::vector signature; +}; + +struct EquivocationEvidence { + Vote first_vote; + Vote conflicting_vote; +}; + +struct CommitDecision { + int64_t height{0}; + int round{0}; + BlockID block_id; + std::vector supporting_votes; +}; + +std::string_view StepName(Step step); +std::string_view VoteTypeName(VoteType type); +std::string_view TimeoutName(TimeoutKind timeout); + +bool SameProposalPayload(const Proposal& lhs, const Proposal& rhs); +bool SameVotePayload(const Vote& lhs, const Vote& rhs); +bool VotesConflict(const Vote& lhs, const Vote& rhs); + +uint256 ProposalSigningHash(const Proposal& proposal); +uint256 VoteSigningHash(const Vote& vote); + +util::Result SignProposal(Proposal& proposal, const CKey& private_key); +util::Result SignVote(Vote& vote, const CKey& private_key); + +util::Result VerifyProposalSignature(const Proposal& proposal, std::span validators); +util::Result VerifyVoteSignature(const Vote& vote, std::span validators); + +} // namespace crown::consensus + +#endif // BITCOIN_CROWN_CONSENSUS_TYPES_H diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index a7d95496bb00..dffaaf6c2c37 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -36,6 +36,7 @@ add_executable(test_bitcoin coinsviewoverlay_tests.cpp common_url_tests.cpp compress_tests.cpp + crown_consensus_tests.cpp crown_tests.cpp crypto_tests.cpp cuckoocache_tests.cpp diff --git a/src/test/crown_consensus_tests.cpp b/src/test/crown_consensus_tests.cpp new file mode 100644 index 000000000000..7e8dc70c8855 --- /dev/null +++ b/src/test/crown_consensus_tests.cpp @@ -0,0 +1,670 @@ +// Copyright (c) 2026-present The Crown developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +BOOST_FIXTURE_TEST_SUITE(crown_consensus_tests, BasicTestingSetup) + +namespace { + +using crown::StaticValidator; +using crown::consensus::Action; +using crown::consensus::BlockID; +using crown::consensus::CommitDecision; +using crown::consensus::ConsensusEngine; +using crown::consensus::EngineConfig; +using crown::consensus::EquivocationEvidence; +using crown::consensus::LocalSigner; +using crown::consensus::NO_VALID_ROUND; +using crown::consensus::Proposal; +using crown::consensus::Step; +using crown::consensus::TimeoutKind; +using crown::consensus::Vote; +using crown::consensus::VoteType; + +constexpr int64_t TEST_HEIGHT{101}; + +const std::array VALIDATOR_IDS{ + "validator-a", + "validator-b", + "validator-c", + "validator-d", +}; + +BlockID TestBlock(std::string_view name) +{ + return (HashWriter{} << std::string{name}).GetHash(); +} + +LocalSigner GetSigner(std::string_view id) +{ + const auto* test_key = crown::FindTestValidatorPrivateKey(id); + BOOST_REQUIRE_MESSAGE(test_key != nullptr, strprintf("missing test key for %s", id)); + auto private_key = crown::DecodeValidatorPrivateKey(test_key->private_key_hex); + BOOST_REQUIRE(private_key); + return LocalSigner{ + .validator_id = std::string{id}, + .private_key = *private_key, + }; +} + +ConsensusEngine MakeEngine(std::string_view id) +{ + return ConsensusEngine(EngineConfig{ + .validator_set = crown::GetStaticValidatorSet(), + .local_signer = GetSigner(id), + }); +} + +Proposal MakeSignedProposal(std::string_view proposer_id, int64_t height, int round, BlockID block_id, int valid_round = NO_VALID_ROUND) +{ + Proposal proposal{ + .height = height, + .round = round, + .proposer_id = std::string{proposer_id}, + .block_id = block_id, + .valid_round = valid_round, + }; + auto signed_result = crown::consensus::SignProposal(proposal, GetSigner(proposer_id).private_key); + BOOST_REQUIRE(signed_result); + return proposal; +} + +Vote MakeSignedVote(std::string_view validator_id, VoteType type, int64_t height, int round, std::optional block_id) +{ + Vote vote{ + .type = type, + .height = height, + .round = round, + .validator_id = std::string{validator_id}, + .block_id = block_id, + }; + auto signed_result = crown::consensus::SignVote(vote, GetSigner(validator_id).private_key); + BOOST_REQUIRE(signed_result); + return vote; +} + +std::vector VoteActions(const std::vector& actions) +{ + std::vector votes; + for (const auto& action : actions) { + if (action.type == Action::Type::BROADCAST_VOTE) { + BOOST_REQUIRE(action.vote.has_value()); + votes.push_back(*action.vote); + } + } + return votes; +} + +std::vector CommitActions(const std::vector& actions) +{ + std::vector commits; + for (const auto& action : actions) { + if (action.type == Action::Type::COMMIT_DECISION) { + BOOST_REQUIRE(action.commit.has_value()); + commits.push_back(*action.commit); + } + } + return commits; +} + +std::vector EvidenceActions(const std::vector& actions) +{ + std::vector evidence; + for (const auto& action : actions) { + if (action.type == Action::Type::EQUIVOCATION_EVIDENCE) { + BOOST_REQUIRE(action.evidence.has_value()); + evidence.push_back(*action.evidence); + } + } + return evidence; +} + +class DeterministicSimulator +{ +public: + DeterministicSimulator() + { + for (const auto& id : VALIDATOR_IDS) { + m_nodes.emplace(id, std::make_unique(EngineConfig{ + .validator_set = crown::GetStaticValidatorSet(), + .local_signer = GetSigner(id), + })); + } + ResetLinks(); + for (const auto& id : VALIDATOR_IDS) { + m_nodes.at(id)->StartHeight(TEST_HEIGHT); + } + } + + void ResetLinks() + { + for (const auto& sender : VALIDATOR_IDS) { + auto& recipients = m_links[sender]; + recipients.clear(); + for (const auto& receiver : VALIDATOR_IDS) { + if (receiver != sender) recipients.push_back(receiver); + } + } + } + + void ClearLinks() + { + for (const auto& id : VALIDATOR_IDS) m_links[id].clear(); + } + + void ConnectClique(std::initializer_list ids) + { + const std::vector clique(ids.begin(), ids.end()); + for (const auto& sender : clique) { + auto& recipients = m_links[sender]; + recipients.clear(); + for (const auto& receiver : clique) { + if (receiver != sender) recipients.push_back(receiver); + } + } + } + + void SetRecipients(std::string_view sender, std::initializer_list recipients) + { + auto& list = m_links[std::string{sender}]; + list.clear(); + for (const auto& recipient : recipients) list.push_back(std::string{recipient}); + } + + Proposal MakeProposal(std::string_view proposer_id, BlockID block_id, int valid_round = NO_VALID_ROUND) + { + auto proposal = m_nodes.at(std::string{proposer_id})->MakeProposal(block_id, valid_round); + BOOST_REQUIRE(proposal); + return *proposal; + } + + void DeliverProposal(const Proposal& proposal, bool block_valid, std::initializer_list recipients) + { + for (const auto& recipient : recipients) { + auto actions = m_nodes.at(std::string{recipient})->ReceiveProposal(proposal, block_valid); + EnqueueActions(recipient, actions); + CheckSafetyInvariant(); + } + } + + void DeliverVote(const Vote& vote, std::initializer_list recipients) + { + for (const auto& recipient : recipients) { + auto actions = m_nodes.at(std::string{recipient})->ReceiveVote(vote); + EnqueueActions(recipient, actions); + CheckSafetyInvariant(); + } + } + + void Timeout(TimeoutKind timeout, std::initializer_list recipients) + { + for (const auto& recipient : recipients) { + auto actions = m_nodes.at(std::string{recipient})->OnTimeout(timeout); + EnqueueActions(recipient, actions); + CheckSafetyInvariant(); + } + } + + void DeliverNextQueuedVote(std::size_t index = 0) + { + BOOST_REQUIRE(index < m_queue.size()); + const QueuedVote queued = m_queue.at(index); + m_queue.erase(m_queue.begin() + index); + for (const auto& recipient : queued.recipients) { + auto actions = m_nodes.at(recipient)->ReceiveVote(queued.vote); + EnqueueActions(recipient, actions); + CheckSafetyInvariant(); + } + } + + void DeliverAllQueuedVotes() + { + while (!m_queue.empty()) DeliverNextQueuedVote(0); + } + + std::size_t PendingVotes() const + { + return m_queue.size(); + } + + std::vector QueuedVotes() const + { + std::vector votes; + for (const auto& queued : m_queue) votes.push_back(queued.vote); + return votes; + } + + const ConsensusEngine& Node(std::string_view id) const + { + return *m_nodes.at(std::string{id}); + } + +private: + struct QueuedVote { + Vote vote; + std::vector recipients; + }; + + std::map> m_nodes; + std::map> m_links; + std::deque m_queue; + + void EnqueueActions(std::string_view origin, const std::vector& actions) + { + for (const auto& action : actions) { + if (action.type != Action::Type::BROADCAST_VOTE) continue; + BOOST_REQUIRE(action.vote.has_value()); + m_queue.push_back(QueuedVote{ + .vote = *action.vote, + .recipients = m_links.at(std::string{origin}), + }); + } + } + + void CheckSafetyInvariant() const + { + std::optional committed_block; + for (const auto& [id, node] : m_nodes) { + (void)id; + const auto& commit = node->GetState().commit; + if (!commit.has_value()) continue; + if (!committed_block.has_value()) { + committed_block = commit->block_id; + continue; + } + BOOST_REQUIRE_MESSAGE(*committed_block == commit->block_id, "conflicting block commits observed at the same height"); + } + } +}; + +void AssertCommitted(const ConsensusEngine& engine, BlockID block_id, int round) +{ + BOOST_REQUIRE(engine.GetState().commit.has_value()); + BOOST_CHECK(engine.GetState().step == Step::COMMIT); + BOOST_CHECK_EQUAL(engine.GetState().commit->height, TEST_HEIGHT); + BOOST_CHECK_EQUAL(engine.GetState().commit->round, round); + BOOST_CHECK(engine.GetState().commit->block_id == block_id); +} + +void AssertNotCommitted(const ConsensusEngine& engine) +{ + BOOST_CHECK(!engine.GetState().commit.has_value()); +} + +} // namespace + +BOOST_AUTO_TEST_CASE(crown_consensus_honest_four_of_four_commit) +{ + DeterministicSimulator sim; + const BlockID x = TestBlock("X"); + + const Proposal proposal = sim.MakeProposal("validator-a", x); + sim.DeliverProposal(proposal, /*block_valid=*/true, {"validator-a", "validator-b", "validator-c", "validator-d"}); + sim.DeliverAllQueuedVotes(); + + for (const auto& id : VALIDATOR_IDS) AssertCommitted(sim.Node(id), x, 0); +} + +BOOST_AUTO_TEST_CASE(crown_consensus_availability_thresholds) +{ + const BlockID x = TestBlock("availability"); + + { + DeterministicSimulator sim; + sim.ClearLinks(); + sim.ConnectClique({"validator-a", "validator-b", "validator-c"}); + + const Proposal proposal = sim.MakeProposal("validator-a", x); + sim.DeliverProposal(proposal, true, {"validator-a", "validator-b", "validator-c"}); + sim.DeliverAllQueuedVotes(); + + AssertCommitted(sim.Node("validator-a"), x, 0); + AssertCommitted(sim.Node("validator-b"), x, 0); + AssertCommitted(sim.Node("validator-c"), x, 0); + AssertNotCommitted(sim.Node("validator-d")); + } + + { + DeterministicSimulator sim; + sim.ClearLinks(); + sim.ConnectClique({"validator-a", "validator-b"}); + + const Proposal proposal = sim.MakeProposal("validator-a", x); + sim.DeliverProposal(proposal, true, {"validator-a", "validator-b"}); + sim.DeliverAllQueuedVotes(); + + AssertNotCommitted(sim.Node("validator-a")); + AssertNotCommitted(sim.Node("validator-b")); + AssertNotCommitted(sim.Node("validator-c")); + AssertNotCommitted(sim.Node("validator-d")); + } +} + +BOOST_AUTO_TEST_CASE(crown_consensus_proposer_timeout_invalid_proposal_and_nil_round_progress) +{ + const BlockID x = TestBlock("round-progress"); + + { + DeterministicSimulator sim; + sim.ClearLinks(); + sim.ConnectClique({"validator-b", "validator-c", "validator-d"}); + + sim.Timeout(TimeoutKind::PROPOSE, {"validator-b", "validator-c", "validator-d"}); + sim.DeliverAllQueuedVotes(); + sim.Timeout(TimeoutKind::PRECOMMIT, {"validator-b", "validator-c", "validator-d"}); + + BOOST_CHECK_EQUAL(sim.Node("validator-b").GetState().round, 1); + BOOST_CHECK_EQUAL(sim.Node("validator-c").GetState().round, 1); + BOOST_CHECK_EQUAL(sim.Node("validator-d").GetState().round, 1); + + const Proposal proposal = sim.MakeProposal("validator-b", x); + sim.DeliverProposal(proposal, true, {"validator-b", "validator-c", "validator-d"}); + sim.DeliverAllQueuedVotes(); + + AssertCommitted(sim.Node("validator-b"), x, 1); + AssertCommitted(sim.Node("validator-c"), x, 1); + AssertCommitted(sim.Node("validator-d"), x, 1); + AssertNotCommitted(sim.Node("validator-a")); + } + + { + DeterministicSimulator sim; + const Proposal invalid = sim.MakeProposal("validator-a", x); + sim.DeliverProposal(invalid, /*block_valid=*/false, {"validator-a", "validator-b", "validator-c", "validator-d"}); + BOOST_CHECK_EQUAL(sim.PendingVotes(), 0U); + + sim.Timeout(TimeoutKind::PROPOSE, {"validator-a", "validator-b", "validator-c", "validator-d"}); + const auto nil_prevotes = sim.QueuedVotes(); + BOOST_REQUIRE(!nil_prevotes.empty()); + for (const auto& vote : nil_prevotes) { + BOOST_CHECK(vote.type == VoteType::PREVOTE); + BOOST_CHECK(!vote.block_id.has_value()); + } + + sim.DeliverAllQueuedVotes(); + AssertNotCommitted(sim.Node("validator-a")); + AssertNotCommitted(sim.Node("validator-b")); + AssertNotCommitted(sim.Node("validator-c")); + AssertNotCommitted(sim.Node("validator-d")); + + sim.Timeout(TimeoutKind::PRECOMMIT, {"validator-a", "validator-b", "validator-c", "validator-d"}); + BOOST_CHECK_EQUAL(sim.Node("validator-a").GetState().round, 1); + } +} + +BOOST_AUTO_TEST_CASE(crown_consensus_vote_validation_duplicate_and_filtering) +{ + ConsensusEngine engine = MakeEngine("validator-a"); + const BlockID x = TestBlock("valid"); + const BlockID y = TestBlock("other"); + + engine.StartHeight(TEST_HEIGHT); + const Proposal proposal = MakeSignedProposal("validator-a", TEST_HEIGHT, 0, x); + const auto on_proposal = engine.ReceiveProposal(proposal, true); + BOOST_REQUIRE_EQUAL(VoteActions(on_proposal).size(), 1U); + BOOST_CHECK(VoteActions(on_proposal).front().block_id == x); + + const Vote b_prevote_x = MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, x); + BOOST_CHECK(engine.ReceiveVote(b_prevote_x).empty()); + BOOST_CHECK(engine.ReceiveVote(b_prevote_x).empty()); + BOOST_CHECK(engine.GetState().step == Step::PREVOTE); + + Vote unknown_vote = b_prevote_x; + unknown_vote.validator_id = "validator-z"; + BOOST_CHECK(engine.ReceiveVote(unknown_vote).empty()); + + Vote invalid_signature = b_prevote_x; + invalid_signature.signature.back() ^= 1; + BOOST_CHECK(engine.ReceiveVote(invalid_signature).empty()); + + const Vote wrong_height = MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT + 1, 0, x); + BOOST_CHECK(engine.ReceiveVote(wrong_height).empty()); + + engine.OnTimeout(TimeoutKind::PROPOSE); + engine.OnTimeout(TimeoutKind::PREVOTE); + engine.OnTimeout(TimeoutKind::PRECOMMIT); + BOOST_CHECK_EQUAL(engine.GetState().round, 1); + + const Vote stale_round = MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, x); + BOOST_CHECK(engine.ReceiveVote(stale_round).empty()); + BOOST_CHECK_EQUAL(engine.GetState().round, 1); + BOOST_CHECK(engine.GetState().step == Step::PROPOSE); + + const Vote c_prevote_x = MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, x); + const auto after_c = engine.ReceiveVote(c_prevote_x); + BOOST_CHECK(after_c.empty()); + + const auto explicit_guard_vote = engine.MakeLocalVote(VoteType::PREVOTE, y); + BOOST_CHECK(!explicit_guard_vote); +} + +BOOST_AUTO_TEST_CASE(crown_consensus_prevote_nil_and_precommit_nil_do_not_commit) +{ + ConsensusEngine engine = MakeEngine("validator-a"); + engine.StartHeight(TEST_HEIGHT); + + const auto propose_timeout = engine.OnTimeout(TimeoutKind::PROPOSE); + const auto prevotes = VoteActions(propose_timeout); + BOOST_REQUIRE_EQUAL(prevotes.size(), 1U); + BOOST_CHECK(prevotes.front().type == VoteType::PREVOTE); + BOOST_CHECK(!prevotes.front().block_id.has_value()); + BOOST_CHECK(engine.GetState().step == Step::PREVOTE); + + const Vote b_nil = MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, std::nullopt); + const Vote c_nil = MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, std::nullopt); + const auto after_b = engine.ReceiveVote(b_nil); + BOOST_CHECK(after_b.empty()); + const auto after_c = engine.ReceiveVote(c_nil); + const auto precommits = VoteActions(after_c); + BOOST_REQUIRE_EQUAL(precommits.size(), 1U); + BOOST_CHECK(precommits.front().type == VoteType::PRECOMMIT); + BOOST_CHECK(!precommits.front().block_id.has_value()); + AssertNotCommitted(engine); + + const Vote d_nil = MakeSignedVote("validator-d", VoteType::PRECOMMIT, TEST_HEIGHT, 0, std::nullopt); + BOOST_CHECK(engine.ReceiveVote(d_nil).empty()); + AssertNotCommitted(engine); +} + +BOOST_AUTO_TEST_CASE(crown_consensus_equivocation_detection_for_prevote_and_precommit) +{ + ConsensusEngine engine = MakeEngine("validator-a"); + const BlockID x = TestBlock("eq-x"); + const BlockID y = TestBlock("eq-y"); + + engine.StartHeight(TEST_HEIGHT); + const Proposal proposal = MakeSignedProposal("validator-a", TEST_HEIGHT, 0, x); + engine.ReceiveProposal(proposal, true); + + const Vote b_prevote_x = MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, x); + const Vote b_prevote_y = MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, y); + BOOST_CHECK(engine.ReceiveVote(b_prevote_x).empty()); + const auto prevote_conflict = engine.ReceiveVote(b_prevote_y); + const auto prevote_evidence = EvidenceActions(prevote_conflict); + BOOST_REQUIRE_EQUAL(prevote_evidence.size(), 1U); + BOOST_CHECK(prevote_evidence.front().first_vote.block_id == x); + BOOST_CHECK(prevote_evidence.front().conflicting_vote.block_id == y); + + const Vote c_prevote_x = MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, x); + const auto after_c = engine.ReceiveVote(c_prevote_x); + const auto precommit_actions = VoteActions(after_c); + BOOST_REQUIRE_EQUAL(precommit_actions.size(), 1U); + BOOST_CHECK(precommit_actions.front().type == VoteType::PRECOMMIT); + BOOST_CHECK(precommit_actions.front().block_id == x); + + const Vote b_precommit_x = MakeSignedVote("validator-b", VoteType::PRECOMMIT, TEST_HEIGHT, 0, x); + const Vote b_precommit_y = MakeSignedVote("validator-b", VoteType::PRECOMMIT, TEST_HEIGHT, 0, y); + BOOST_CHECK(engine.ReceiveVote(b_precommit_x).empty()); + const auto precommit_conflict = engine.ReceiveVote(b_precommit_y); + const auto precommit_evidence = EvidenceActions(precommit_conflict); + BOOST_REQUIRE_EQUAL(precommit_evidence.size(), 1U); + BOOST_CHECK(precommit_evidence.front().first_vote.block_id == x); + BOOST_CHECK(precommit_evidence.front().conflicting_vote.block_id == y); +} + +BOOST_AUTO_TEST_CASE(crown_consensus_lock_rule_rejects_conflicting_proposal_without_valid_round) +{ + ConsensusEngine engine = MakeEngine("validator-a"); + const BlockID x = TestBlock("lock-x"); + const BlockID y = TestBlock("lock-y"); + + engine.StartHeight(TEST_HEIGHT); + engine.ReceiveProposal(MakeSignedProposal("validator-a", TEST_HEIGHT, 0, x), true); + engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + const auto lock_actions = engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + BOOST_REQUIRE_EQUAL(VoteActions(lock_actions).size(), 1U); + BOOST_CHECK(engine.GetState().locked_block == x); + BOOST_CHECK_EQUAL(engine.GetState().locked_round, 0); + + engine.OnTimeout(TimeoutKind::PRECOMMIT); + BOOST_CHECK_EQUAL(engine.GetState().round, 1); + + const auto conflict_actions = engine.ReceiveProposal(MakeSignedProposal("validator-b", TEST_HEIGHT, 1, y), true); + const auto votes = VoteActions(conflict_actions); + BOOST_REQUIRE_EQUAL(votes.size(), 1U); + BOOST_CHECK(votes.front().type == VoteType::PREVOTE); + BOOST_CHECK(!votes.front().block_id.has_value()); + BOOST_CHECK(engine.GetState().locked_block == x); +} + +BOOST_AUTO_TEST_CASE(crown_consensus_valid_round_allows_safe_lock_transition) +{ + ConsensusEngine engine = MakeEngine("validator-a"); + const BlockID x = TestBlock("valid-round-x"); + const BlockID y = TestBlock("valid-round-y"); + + engine.StartHeight(TEST_HEIGHT); + engine.ReceiveProposal(MakeSignedProposal("validator-a", TEST_HEIGHT, 0, x), true); + engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + BOOST_CHECK(engine.GetState().locked_block == x); + BOOST_CHECK_EQUAL(engine.GetState().locked_round, 0); + + engine.OnTimeout(TimeoutKind::PRECOMMIT); + engine.OnTimeout(TimeoutKind::PROPOSE); + engine.OnTimeout(TimeoutKind::PREVOTE); + engine.OnTimeout(TimeoutKind::PRECOMMIT); + BOOST_CHECK_EQUAL(engine.GetState().round, 2); + + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 1, y)).empty()); + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 1, y)).empty()); + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-d", VoteType::PREVOTE, TEST_HEIGHT, 1, y)).empty()); + BOOST_CHECK(engine.HasSeenPrevoteProof(1, y)); + + const auto actions = engine.ReceiveProposal(MakeSignedProposal("validator-c", TEST_HEIGHT, 2, y, 1), true); + const auto votes = VoteActions(actions); + BOOST_REQUIRE_EQUAL(votes.size(), 1U); + BOOST_CHECK(votes.front().type == VoteType::PREVOTE); + BOOST_CHECK(votes.front().block_id == y); +} + +BOOST_AUTO_TEST_CASE(crown_consensus_partition_behavior) +{ + const BlockID x = TestBlock("partition-3-1"); + + { + DeterministicSimulator sim; + sim.ClearLinks(); + sim.ConnectClique({"validator-a", "validator-b", "validator-c"}); + sim.SetRecipients("validator-d", {}); + + const Proposal proposal = sim.MakeProposal("validator-a", x); + sim.DeliverProposal(proposal, true, {"validator-a", "validator-b", "validator-c"}); + sim.DeliverAllQueuedVotes(); + + AssertCommitted(sim.Node("validator-a"), x, 0); + AssertCommitted(sim.Node("validator-b"), x, 0); + AssertCommitted(sim.Node("validator-c"), x, 0); + AssertNotCommitted(sim.Node("validator-d")); + } + + { + DeterministicSimulator sim; + sim.ClearLinks(); + sim.ConnectClique({"validator-a", "validator-b"}); + sim.ConnectClique({"validator-c", "validator-d"}); + + const Proposal proposal = sim.MakeProposal("validator-a", x); + sim.DeliverProposal(proposal, true, {"validator-a", "validator-b"}); + sim.DeliverAllQueuedVotes(); + + AssertNotCommitted(sim.Node("validator-a")); + AssertNotCommitted(sim.Node("validator-b")); + AssertNotCommitted(sim.Node("validator-c")); + AssertNotCommitted(sim.Node("validator-d")); + } +} + +BOOST_AUTO_TEST_CASE(crown_consensus_message_order_permutations_preserve_safety) +{ + const BlockID x = TestBlock("ordering"); + + for (uint32_t seed = 1; seed <= 24; ++seed) { + DeterministicSimulator sim; + const Proposal proposal = sim.MakeProposal("validator-a", x); + sim.DeliverProposal(proposal, true, {"validator-a", "validator-b", "validator-c", "validator-d"}); + + uint32_t state = seed; + while (sim.PendingVotes() > 0) { + state = state * 1103515245U + 12345U; + const std::size_t index = state % sim.PendingVotes(); + sim.DeliverNextQueuedVote(index); + } + + for (const auto& id : VALIDATOR_IDS) AssertCommitted(sim.Node(id), x, 0); + } +} + +BOOST_AUTO_TEST_CASE(crown_consensus_commit_action_contains_supporting_votes) +{ + ConsensusEngine engine = MakeEngine("validator-a"); + const BlockID x = TestBlock("commit-support"); + + engine.StartHeight(TEST_HEIGHT); + engine.ReceiveProposal(MakeSignedProposal("validator-a", TEST_HEIGHT, 0, x), true); + engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PRECOMMIT, TEST_HEIGHT, 0, x)); + const auto commit_actions = engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PRECOMMIT, TEST_HEIGHT, 0, x)); + const auto commits = CommitActions(commit_actions); + BOOST_REQUIRE_EQUAL(commits.size(), 1U); + BOOST_CHECK_EQUAL(commits.front().supporting_votes.size(), 3U); + BOOST_CHECK(commits.front().block_id == x); +} + +BOOST_AUTO_TEST_CASE(crown_consensus_local_double_sign_guard_blocks_conflicting_signatures) +{ + ConsensusEngine engine = MakeEngine("validator-a"); + const BlockID x = TestBlock("double-sign-x"); + const BlockID y = TestBlock("double-sign-y"); + + engine.StartHeight(TEST_HEIGHT); + auto first = engine.MakeLocalVote(VoteType::PREVOTE, x); + BOOST_REQUIRE(first); + auto second = engine.MakeLocalVote(VoteType::PREVOTE, y); + BOOST_CHECK(!second); +} + +BOOST_AUTO_TEST_SUITE_END() From 3c52a6e137734cd7c28a3d6d217855f0737a1cdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:29:56 +0000 Subject: [PATCH 2/4] Document Phase 2 consensus state machine Co-authored-by: defunctec <9864793+defunctec@users.noreply.github.com> --- doc/crown/PHASE2_CONSENSUS_STATE_MACHINE.md | 193 ++++++++++++++++++++ doc/crown/SUCCESSOR_POC_PATCH_SURFACE.md | 41 +++++ src/test/crown_consensus_tests.cpp | 5 +- 3 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 doc/crown/PHASE2_CONSENSUS_STATE_MACHINE.md create mode 100644 doc/crown/SUCCESSOR_POC_PATCH_SURFACE.md diff --git a/doc/crown/PHASE2_CONSENSUS_STATE_MACHINE.md b/doc/crown/PHASE2_CONSENSUS_STATE_MACHINE.md new file mode 100644 index 000000000000..f5fa5c26373f --- /dev/null +++ b/doc/crown/PHASE2_CONSENSUS_STATE_MACHINE.md @@ -0,0 +1,193 @@ +# Crown Successor PoC Phase 2 + +## Scope + +Phase 2 adds an isolated native Crown consensus state machine that operates on abstract `BlockID` values (`uint256`) and remains disconnected from live Bitcoin block production, fork choice, validation, and P2P message handling. + +Implemented: + +- deterministic proposer rotation over the four static Phase 1 validators +- signed proposals and signed `PREVOTE` / `PRECOMMIT` messages +- explicit `NIL` voting via absent vote `block_id` +- exact voting-power quorum reuse from Phase 1 +- one-height event-driven round progression across `PROPOSE`, `PREVOTE`, `PRECOMMIT`, and `COMMIT` +- Tendermint-style lock tracking with `locked_block` / `locked_round` +- `valid_block` / `valid_round` tracking and proposal handling +- equivocation detection for conflicting votes +- logical local double-sign protection for the in-process signer +- deterministic in-process multi-validator simulation tests + +Deferred: + +- live Bitcoin `CBlock` proposal and validation integration +- `net_processing.cpp` or live consensus P2P messages +- production finality certificates +- persistent crash-safe signing state +- staking, delegation, dynamic validator sets, and slashing economics + +## State machine + +Each engine models a single consensus height with: + +- `height` +- `round` +- `step` +- `locked_block` +- `locked_round` +- `valid_block` +- `valid_round` +- the current round proposal +- an optional `CommitDecision` + +Round changes do not reset the height. `PRECOMMIT_TIMEOUT` advances from round `r` to round `r+1` while preserving lock and valid-round state. + +## Proposals + +`Proposal` contains: + +- `height` +- `round` +- proposer validator id +- proposed `BlockID` +- `valid_round` +- signature + +The provisional proposer rule is deterministic rotation in static validator order: + +1. `validator-a` +2. `validator-b` +3. `validator-c` +4. `validator-d` + +Round `r` uses proposer index `r mod 4`. + +The engine rejects proposals with: + +- unknown proposer id +- proposer mismatch for the round +- invalid signature +- wrong height +- wrong round +- missing `BlockID` +- malformed `valid_round` + +## Votes + +`Vote` contains: + +- vote type (`PREVOTE` or `PRECOMMIT`) +- `height` +- `round` +- validator id +- `BlockID` or explicit `NIL` +- signature + +Canonical signing bytes are deterministic hash inputs over message domain, vote/proposal metadata, validator identity, and the optional block value. Unknown validators and invalid signatures contribute zero voting power. + +## NIL semantics + +`NIL` is a first-class consensus value used when: + +- a proposal is missing +- a proposal is externally marked invalid +- a proposal conflicts with the local lock and does not carry sufficient `valid_round` proof +- timeout progression requires voting without a valid block + +`PREVOTE(NIL)` and `PRECOMMIT(NIL)` advance the protocol without committing a block. + +## Quorum rule + +Phase 2 reuses the Phase 1 exact power model: + +- total power = `100` +- quorum threshold = `floor(2 * total_power / 3) + 1 = 67` + +Any three validators provide `75` power and satisfy quorum. Two validators provide `50` power and do not. + +Duplicate identical votes are counted once. Conflicting votes from the same validator never both count. + +## Locking semantics + +The engine locks when it observes `>2/3` `PREVOTE`s for a concrete block in the current round and then emits `PRECOMMIT(block)`. + +While locked on block `X`, the engine: + +- may `PREVOTE(X)` again in later rounds +- must not `PREVOTE(Y)` for conflicting `Y` without sufficient `valid_round` evidence +- may `PREVOTE(NIL)` if a conflicting proposal lacks such evidence + +The lock changes only after the engine sees a later-round `PREVOTE` quorum for another block and then emits `PRECOMMIT` for that later block. + +## `valid_round` semantics + +When the engine observes `>2/3` `PREVOTE`s for block `B` in round `r`, it stores: + +- `valid_block = B` +- `valid_round = r` + +A later proposal for `B` may reference that `valid_round`. If the local node is locked on another block from an earlier round, and it has stored proof of the referenced `PREVOTE` quorum, it may safely `PREVOTE(B)` in the later round. + +## Timeout events + +Phase 2 uses deterministic timeout events instead of real clocks: + +- `PROPOSE_TIMEOUT` +- `PREVOTE_TIMEOUT` +- `PRECOMMIT_TIMEOUT` + +`PROPOSE_TIMEOUT` drives `PREVOTE(NIL)` when needed. `PREVOTE_TIMEOUT` drives `PRECOMMIT(NIL)` when needed. `PRECOMMIT_TIMEOUT` starts the next round. + +## Equivocation detection + +The engine records evidence when a validator sends conflicting votes for the same: + +- height +- round +- vote type + +Evidence is exposed as paired conflicting votes and is suitable for later slashing integration. Phase 2 does not implement economics or punishment. + +## Local double-sign protection + +The local in-process signer refuses to produce two different votes for the same: + +- height +- round +- vote type + +This is a logical guard only. Persistent crash-safe signing state is deferred. Production validator safety still requires durable signing-state persistence before any live multi-process deployment. + +## Simulator and tests + +The Phase 2 unit tests instantiate four local engines and deliver: + +- proposals +- votes +- timeouts +- delayed messages +- dropped messages +- 3/1 and 2/2 partitions +- conflicting Byzantine vote messages +- deterministic message-order permutations + +The simulator enforces the invariant that no two honest engines may commit different block ids at the same height. + +Tested scenarios include: + +- 4/4, 3/4, and 2/4 availability thresholds +- offline proposer round change +- invalid proposals +- duplicate, stale, wrong-height, unknown-validator, and bad-signature votes +- `NIL` quorum handling +- prevote and precommit equivocation +- lock retention and valid-round transitions +- partition behavior +- deterministic message-order permutations + +## Known Phase 2 limitations + +- Consensus operates on abstract `BlockID` values only. +- No Bitcoin chainstate, mempool, miner, wallet, or script code is modified. +- No live proposal/vote networking exists. +- No persistent signer state exists yet. +- Only the static Phase 1 validator set is supported. diff --git a/doc/crown/SUCCESSOR_POC_PATCH_SURFACE.md b/doc/crown/SUCCESSOR_POC_PATCH_SURFACE.md new file mode 100644 index 000000000000..c462b55e8c8e --- /dev/null +++ b/doc/crown/SUCCESSOR_POC_PATCH_SURFACE.md @@ -0,0 +1,41 @@ +# Crown Successor PoC Patch Surface + +## Scope + +This file tracks the incremental patch surface beyond the Phase 1 baseline documented in `doc/crown/phase1-network-validator-scaffold.md`. + +Phase 2 remains overwhelmingly isolated to new Crown-owned files plus minimal build/test wiring. + +## New Crown-owned files added in Phase 2 + +| File | Reason | Classification | Merge risk | +| --- | --- | --- | --- | +| `src/crown/consensus/types.h` | Declares Phase 2 consensus message/state types, `BlockID`, timeout enums, equivocation evidence, and commit results. | New isolated Crown module | Low | +| `src/crown/consensus/types.cpp` | Implements deterministic signing hashes plus proposal/vote signing and verification against the static validator set. | New isolated Crown module | Low | +| `src/crown/consensus/engine.h` | Declares the event-driven one-height consensus engine, state, and emitted actions. | New isolated Crown module | Low | +| `src/crown/consensus/engine.cpp` | Implements proposer checks, quorum handling, locking, valid-round handling, equivocation detection, round changes, and local double-sign guard logic. | New isolated Crown module | Low | +| `src/test/crown_consensus_tests.cpp` | Adds deterministic multi-validator simulation and Phase 2 unit coverage. | Crown-specific test | Low | +| `doc/crown/PHASE2_CONSENSUS_STATE_MACHINE.md` | Documents the implemented Phase 2 consensus behavior and deferred items. | Crown documentation | Low | +| `doc/crown/SUCCESSOR_POC_PATCH_SURFACE.md` | Tracks the cumulative PoC patch surface starting from the Phase 1 baseline. | Crown documentation | Low | + +## Existing upstream Bitcoin files modified in Phase 2 + +| File | Reason | Classification | Merge risk | +| --- | --- | --- | --- | +| `src/CMakeLists.txt` | Links the new isolated Crown consensus sources into existing common build targets. | Build wiring only | Low | +| `src/test/CMakeLists.txt` | Registers the new Crown consensus unit-test file with `test_bitcoin`. | Test build wiring only | Low | + +## Files intentionally left untouched in Phase 2 + +- `src/validation.cpp` +- `src/net_processing.cpp` +- chainstate +- mempool +- miner +- wallet +- script validation +- fork-choice logic + +## Merge-risk summary + +Phase 2 introduces no meaningful new upstream consensus-coupled surface. The only upstream edits are localized build/test registration changes, so rebasing risk should remain low relative to the larger Phase 1 chain-selection and startup plumbing already present. diff --git a/src/test/crown_consensus_tests.cpp b/src/test/crown_consensus_tests.cpp index 7e8dc70c8855..1f989bf1007b 100644 --- a/src/test/crown_consensus_tests.cpp +++ b/src/test/crown_consensus_tests.cpp @@ -85,6 +85,7 @@ Proposal MakeSignedProposal(std::string_view proposer_id, int64_t height, int ro .proposer_id = std::string{proposer_id}, .block_id = block_id, .valid_round = valid_round, + .signature = {}, }; auto signed_result = crown::consensus::SignProposal(proposal, GetSigner(proposer_id).private_key); BOOST_REQUIRE(signed_result); @@ -99,6 +100,7 @@ Vote MakeSignedVote(std::string_view validator_id, VoteType type, int64_t height .round = round, .validator_id = std::string{validator_id}, .block_id = block_id, + .signature = {}, }; auto signed_result = crown::consensus::SignVote(vote, GetSigner(validator_id).private_key); BOOST_REQUIRE(signed_result); @@ -425,6 +427,7 @@ BOOST_AUTO_TEST_CASE(crown_consensus_vote_validation_duplicate_and_filtering) const auto on_proposal = engine.ReceiveProposal(proposal, true); BOOST_REQUIRE_EQUAL(VoteActions(on_proposal).size(), 1U); BOOST_CHECK(VoteActions(on_proposal).front().block_id == x); + BOOST_CHECK(!engine.MakeLocalVote(VoteType::PREVOTE, y)); const Vote b_prevote_x = MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, x); BOOST_CHECK(engine.ReceiveVote(b_prevote_x).empty()); @@ -456,8 +459,6 @@ BOOST_AUTO_TEST_CASE(crown_consensus_vote_validation_duplicate_and_filtering) const auto after_c = engine.ReceiveVote(c_prevote_x); BOOST_CHECK(after_c.empty()); - const auto explicit_guard_vote = engine.MakeLocalVote(VoteType::PREVOTE, y); - BOOST_CHECK(!explicit_guard_vote); } BOOST_AUTO_TEST_CASE(crown_consensus_prevote_nil_and_precommit_nil_do_not_commit) From 3f1f11914df35bce81e0c943d4992571c0e163a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:20:40 +0000 Subject: [PATCH 3/4] Changes before error encountered Agent-Logs-Url: https://github.com/defunctec/crown-core/sessions/444d8abf-b27b-4d1f-9334-4a8a1ae4d06b Co-authored-by: defunctec <9864793+defunctec@users.noreply.github.com> --- src/test/crown_consensus_tests.cpp | 100 +++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/test/crown_consensus_tests.cpp b/src/test/crown_consensus_tests.cpp index 1f989bf1007b..f605f0f007d6 100644 --- a/src/test/crown_consensus_tests.cpp +++ b/src/test/crown_consensus_tests.cpp @@ -581,6 +581,106 @@ BOOST_AUTO_TEST_CASE(crown_consensus_valid_round_allows_safe_lock_transition) BOOST_CHECK(votes.front().block_id == y); } +BOOST_AUTO_TEST_CASE(crown_consensus_rejects_forged_valid_round_for_different_block) +{ + ConsensusEngine engine = MakeEngine("validator-a"); + const BlockID x = TestBlock("forged-vr-lock-x"); + const BlockID y = TestBlock("forged-vr-proposal-y"); + const BlockID z = TestBlock("forged-vr-proof-z"); + + engine.StartHeight(TEST_HEIGHT); + engine.ReceiveProposal(MakeSignedProposal("validator-a", TEST_HEIGHT, 0, x), true); + engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + BOOST_CHECK(engine.GetState().locked_block == x); + BOOST_CHECK_EQUAL(engine.GetState().locked_round, 0); + + engine.OnTimeout(TimeoutKind::PRECOMMIT); + BOOST_CHECK_EQUAL(engine.GetState().round, 1); + + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 1, z)).empty()); + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 1, z)).empty()); + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-d", VoteType::PREVOTE, TEST_HEIGHT, 1, z)).empty()); + BOOST_CHECK(engine.HasSeenPrevoteProof(1, z)); + BOOST_CHECK(!engine.HasSeenPrevoteProof(1, y)); + + engine.OnTimeout(TimeoutKind::PROPOSE); + engine.OnTimeout(TimeoutKind::PREVOTE); + engine.OnTimeout(TimeoutKind::PRECOMMIT); + BOOST_CHECK_EQUAL(engine.GetState().round, 2); + + const auto actions = engine.ReceiveProposal(MakeSignedProposal("validator-c", TEST_HEIGHT, 2, y, 1), true); + const auto votes = VoteActions(actions); + BOOST_REQUIRE_EQUAL(votes.size(), 1U); + BOOST_CHECK(votes.front().type == VoteType::PREVOTE); + BOOST_CHECK(!votes.front().block_id.has_value()); + BOOST_CHECK(engine.GetState().locked_block == x); + BOOST_CHECK_EQUAL(engine.GetState().locked_round, 0); +} + +BOOST_AUTO_TEST_CASE(crown_consensus_rejects_valid_round_below_locked_round) +{ + ConsensusEngine engine = MakeEngine("validator-a"); + const BlockID x = TestBlock("locked-round-x"); + const BlockID y = TestBlock("locked-round-y"); + + engine.StartHeight(TEST_HEIGHT); + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, y)).empty()); + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, y)).empty()); + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-d", VoteType::PREVOTE, TEST_HEIGHT, 0, y)).empty()); + BOOST_CHECK(engine.HasSeenPrevoteProof(0, y)); + + engine.OnTimeout(TimeoutKind::PROPOSE); + engine.OnTimeout(TimeoutKind::PREVOTE); + engine.OnTimeout(TimeoutKind::PRECOMMIT); + BOOST_CHECK_EQUAL(engine.GetState().round, 1); + + engine.ReceiveProposal(MakeSignedProposal("validator-b", TEST_HEIGHT, 1, x), true); + engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 1, x)); + engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 1, x)); + BOOST_CHECK(engine.GetState().locked_block == x); + BOOST_CHECK_EQUAL(engine.GetState().locked_round, 1); + + engine.OnTimeout(TimeoutKind::PRECOMMIT); + BOOST_CHECK_EQUAL(engine.GetState().round, 2); + + const auto actions = engine.ReceiveProposal(MakeSignedProposal("validator-c", TEST_HEIGHT, 2, y, 0), true); + const auto votes = VoteActions(actions); + BOOST_REQUIRE_EQUAL(votes.size(), 1U); + BOOST_CHECK(votes.front().type == VoteType::PREVOTE); + BOOST_CHECK(!votes.front().block_id.has_value()); + BOOST_CHECK(engine.GetState().locked_block == x); + BOOST_CHECK_EQUAL(engine.GetState().locked_round, 1); +} + +BOOST_AUTO_TEST_CASE(crown_consensus_late_prevote_quorum_after_precommit_nil_does_not_double_sign) +{ + ConsensusEngine engine = MakeEngine("validator-a"); + const BlockID x = TestBlock("late-prevote-quorum"); + + engine.StartHeight(TEST_HEIGHT); + const auto nil_prevote_actions = engine.OnTimeout(TimeoutKind::PROPOSE); + const auto nil_prevotes = VoteActions(nil_prevote_actions); + BOOST_REQUIRE_EQUAL(nil_prevotes.size(), 1U); + BOOST_CHECK(!nil_prevotes.front().block_id.has_value()); + + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, std::nullopt)).empty()); + const auto nil_precommit_actions = engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, std::nullopt)); + const auto nil_precommits = VoteActions(nil_precommit_actions); + BOOST_REQUIRE_EQUAL(nil_precommits.size(), 1U); + BOOST_CHECK(nil_precommits.front().type == VoteType::PRECOMMIT); + BOOST_CHECK(!nil_precommits.front().block_id.has_value()); + BOOST_CHECK(engine.GetState().step == Step::PRECOMMIT); + + BOOST_CHECK(engine.ReceiveProposal(MakeSignedProposal("validator-a", TEST_HEIGHT, 0, x), true).empty()); + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, x)).empty()); + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, x)).empty()); + const auto late_quorum_actions = engine.ReceiveVote(MakeSignedVote("validator-d", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + BOOST_CHECK(VoteActions(late_quorum_actions).empty()); + BOOST_CHECK(!engine.GetState().locked_block.has_value()); + BOOST_CHECK(engine.GetState().step == Step::PRECOMMIT); +} + BOOST_AUTO_TEST_CASE(crown_consensus_partition_behavior) { const BlockID x = TestBlock("partition-3-1"); From 7faba2f9a3a1352c51abe254ede6056d33408661 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:50:49 +0000 Subject: [PATCH 4/4] Fix late round quorum commit handling Co-authored-by: defunctec <9864793+defunctec@users.noreply.github.com> --- src/crown/consensus/engine.cpp | 58 ++++++++++++++++++-------- src/crown/consensus/engine.h | 3 ++ src/test/crown_consensus_tests.cpp | 66 +++++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 19 deletions(-) diff --git a/src/crown/consensus/engine.cpp b/src/crown/consensus/engine.cpp index f2b490a17c34..62a6e51dbfd2 100644 --- a/src/crown/consensus/engine.cpp +++ b/src/crown/consensus/engine.cpp @@ -227,6 +227,42 @@ std::vector ConsensusEngine::CollectSupportingVotes(const std::map& actions) +{ + if (m_state.commit.has_value()) return; + + const auto precommit_quorum = FindQuorum(round_votes.precommits); + if (!precommit_quorum.has_quorum || !precommit_quorum.block_id.has_value()) return; + + m_state.step = Step::COMMIT; + m_state.commit = CommitDecision{ + .height = m_state.height, + .round = round, + .block_id = *precommit_quorum.block_id, + .supporting_votes = CollectSupportingVotes(round_votes.precommits, precommit_quorum.block_id), + }; + actions.push_back(Action::CommitAction(*m_state.commit)); +} + +void ConsensusEngine::ObserveKnownRoundQuorums(std::vector& actions) +{ + for (const auto& [round, round_votes] : m_round_votes) { + if (round > m_state.round) continue; + UpdateValidBlock(round, FindQuorum(round_votes.prevotes)); + TryCommitRound(round, round_votes, actions); + if (m_state.commit.has_value()) return; + } +} + util::Result ConsensusEngine::BuildLocalVote(const VoteType type, const std::optional block_id) { if (!IsLocalValidator()) { @@ -279,8 +315,7 @@ std::vector ConsensusEngine::TryAdvance() const auto prevote_quorum = FindQuorum(current_round_votes->prevotes); if (prevote_quorum.has_quorum) { if (prevote_quorum.block_id.has_value() && IsCurrentProposalUsable() && m_state.current_proposal->block_id == prevote_quorum.block_id) { - m_state.valid_block = prevote_quorum.block_id; - m_state.valid_round = m_state.round; + UpdateValidBlock(m_state.round, prevote_quorum); if (IsLocalValidator() && m_state.step != Step::COMMIT) { if (auto vote = BuildLocalVote(VoteType::PRECOMMIT, prevote_quorum.block_id); vote) { RegisterVote(*vote, actions); @@ -299,17 +334,7 @@ std::vector ConsensusEngine::TryAdvance() } } - const auto precommit_quorum = FindQuorum(current_round_votes->precommits); - if (precommit_quorum.has_quorum && precommit_quorum.block_id.has_value() && !m_state.commit.has_value()) { - m_state.step = Step::COMMIT; - m_state.commit = CommitDecision{ - .height = m_state.height, - .round = m_state.round, - .block_id = *precommit_quorum.block_id, - .supporting_votes = CollectSupportingVotes(current_round_votes->precommits, precommit_quorum.block_id), - }; - actions.push_back(Action::CommitAction(*m_state.commit)); - } + ObserveKnownRoundQuorums(actions); return actions; } @@ -345,10 +370,9 @@ std::vector ConsensusEngine::ReceiveVote(const Vote& vote) if (vote.height != m_state.height) return actions; RegisterVote(vote, actions); - if (vote.round == m_state.round) { - auto next_actions = TryAdvance(); - actions.insert(actions.end(), next_actions.begin(), next_actions.end()); - } + auto next_actions = vote.round == m_state.round ? TryAdvance() : std::vector{}; + if (vote.round != m_state.round) ObserveKnownRoundQuorums(actions); + actions.insert(actions.end(), next_actions.begin(), next_actions.end()); return actions; } diff --git a/src/crown/consensus/engine.h b/src/crown/consensus/engine.h index ca896239b6a3..5887349ae54f 100644 --- a/src/crown/consensus/engine.h +++ b/src/crown/consensus/engine.h @@ -110,6 +110,9 @@ class ConsensusEngine bool ProposalAllowsPrevote(const Proposal& proposal) const; bool RegisterVote(const Vote& vote, std::vector& actions); QuorumResult FindQuorum(const std::map& votes) const; + void UpdateValidBlock(int round, const QuorumResult& prevote_quorum); + void TryCommitRound(int round, const RoundVotes& round_votes, std::vector& actions); + void ObserveKnownRoundQuorums(std::vector& actions); std::vector CollectSupportingVotes(const std::map& votes, const std::optional& block_id) const; util::Result BuildLocalVote(VoteType type, std::optional block_id); std::vector TryAdvance(); diff --git a/src/test/crown_consensus_tests.cpp b/src/test/crown_consensus_tests.cpp index f605f0f007d6..d3514eb7b52a 100644 --- a/src/test/crown_consensus_tests.cpp +++ b/src/test/crown_consensus_tests.cpp @@ -673,14 +673,50 @@ BOOST_AUTO_TEST_CASE(crown_consensus_late_prevote_quorum_after_precommit_nil_doe BOOST_CHECK(engine.GetState().step == Step::PRECOMMIT); BOOST_CHECK(engine.ReceiveProposal(MakeSignedProposal("validator-a", TEST_HEIGHT, 0, x), true).empty()); - BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, x)).empty()); - BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, x)).empty()); + const auto conflicting_b = engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + BOOST_CHECK(VoteActions(conflicting_b).empty()); + BOOST_REQUIRE_EQUAL(EvidenceActions(conflicting_b).size(), 1U); + const auto conflicting_c = engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + BOOST_CHECK(VoteActions(conflicting_c).empty()); + BOOST_REQUIRE_EQUAL(EvidenceActions(conflicting_c).size(), 1U); const auto late_quorum_actions = engine.ReceiveVote(MakeSignedVote("validator-d", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); BOOST_CHECK(VoteActions(late_quorum_actions).empty()); BOOST_CHECK(!engine.GetState().locked_block.has_value()); BOOST_CHECK(engine.GetState().step == Step::PRECOMMIT); } +BOOST_AUTO_TEST_CASE(crown_consensus_late_precommit_quorum_after_round_change_still_commits) +{ + ConsensusEngine engine = MakeEngine("validator-d"); + const BlockID x = TestBlock("late-precommit-quorum"); + + engine.StartHeight(TEST_HEIGHT); + const auto on_proposal = engine.ReceiveProposal(MakeSignedProposal("validator-a", TEST_HEIGHT, 0, x), true); + const auto local_prevote = VoteActions(on_proposal); + BOOST_REQUIRE_EQUAL(local_prevote.size(), 1U); + BOOST_CHECK(local_prevote.front().type == VoteType::PREVOTE); + BOOST_CHECK(local_prevote.front().block_id == x); + + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PREVOTE, TEST_HEIGHT, 0, x)).empty()); + const auto on_c_prevote = engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PREVOTE, TEST_HEIGHT, 0, x)); + const auto local_precommit = VoteActions(on_c_prevote); + BOOST_REQUIRE_EQUAL(local_precommit.size(), 1U); + BOOST_CHECK(local_precommit.front().type == VoteType::PRECOMMIT); + BOOST_CHECK(local_precommit.front().block_id == x); + + engine.OnTimeout(TimeoutKind::PRECOMMIT); + BOOST_CHECK_EQUAL(engine.GetState().round, 1); + AssertNotCommitted(engine); + + BOOST_CHECK(engine.ReceiveVote(MakeSignedVote("validator-b", VoteType::PRECOMMIT, TEST_HEIGHT, 0, x)).empty()); + const auto late_commit = engine.ReceiveVote(MakeSignedVote("validator-c", VoteType::PRECOMMIT, TEST_HEIGHT, 0, x)); + const auto commits = CommitActions(late_commit); + BOOST_REQUIRE_EQUAL(commits.size(), 1U); + BOOST_CHECK_EQUAL(commits.front().round, 0); + BOOST_CHECK(commits.front().block_id == x); + AssertCommitted(engine, x, 0); +} + BOOST_AUTO_TEST_CASE(crown_consensus_partition_behavior) { const BlockID x = TestBlock("partition-3-1"); @@ -716,6 +752,32 @@ BOOST_AUTO_TEST_CASE(crown_consensus_partition_behavior) AssertNotCommitted(sim.Node("validator-c")); AssertNotCommitted(sim.Node("validator-d")); } + + { + DeterministicSimulator sim; + sim.ClearLinks(); + sim.ConnectClique({"validator-a", "validator-b", "validator-c"}); + sim.SetRecipients("validator-d", {}); + + const Proposal proposal = sim.MakeProposal("validator-a", x); + sim.DeliverProposal(proposal, true, {"validator-a", "validator-b", "validator-c", "validator-d"}); + sim.DeliverAllQueuedVotes(); + + sim.Timeout(TimeoutKind::PREVOTE, {"validator-d"}); + sim.Timeout(TimeoutKind::PRECOMMIT, {"validator-d"}); + + AssertCommitted(sim.Node("validator-a"), x, 0); + AssertCommitted(sim.Node("validator-b"), x, 0); + AssertCommitted(sim.Node("validator-c"), x, 0); + AssertNotCommitted(sim.Node("validator-d")); + BOOST_CHECK_EQUAL(sim.Node("validator-d").GetState().round, 1); + + sim.DeliverVote(MakeSignedVote("validator-a", VoteType::PRECOMMIT, TEST_HEIGHT, 0, x), {"validator-d"}); + sim.DeliverVote(MakeSignedVote("validator-b", VoteType::PRECOMMIT, TEST_HEIGHT, 0, x), {"validator-d"}); + sim.DeliverVote(MakeSignedVote("validator-c", VoteType::PRECOMMIT, TEST_HEIGHT, 0, x), {"validator-d"}); + + AssertCommitted(sim.Node("validator-d"), x, 0); + } } BOOST_AUTO_TEST_CASE(crown_consensus_message_order_permutations_preserve_safety)