From b1ce2103ad2d0ced9ecc17411568581fb72ed29b Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Mon, 2 Mar 2026 14:28:34 +0700 Subject: [PATCH] test(csf): add RNG consensus hooks and edge-case tests --- src/test/consensus/Consensus_test.cpp | 124 +++++++++ src/test/csf/Peer.h | 350 +++++++++++++++++++++++++- src/test/csf/Proposal.h | 110 +++++++- 3 files changed, 572 insertions(+), 12 deletions(-) diff --git a/src/test/consensus/Consensus_test.cpp b/src/test/consensus/Consensus_test.cpp index b398cad5c..2c8e71204 100644 --- a/src/test/consensus/Consensus_test.cpp +++ b/src/test/consensus/Consensus_test.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include namespace ripple { @@ -1061,6 +1062,126 @@ public: BEAST_EXPECT(sim.synchronized()); } + void + testRngCommitRevealConverges() + { + using namespace csf; + using namespace std::chrono; + + testcase("RNG commit/reveal converges"); + + ConsensusParms const parms{}; + Sim sim; + PeerGroup peers = sim.createGroup(5); + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + for (Peer* peer : peers) + peer->enableRngConsensus_ = true; + + sim.run(3); + + if (BEAST_EXPECT(sim.synchronized())) + { + for (Peer const* peer : peers) + { + BEAST_EXPECT(!peer->lastEntropyWasFallback_); + BEAST_EXPECT(peer->lastEntropyCount_ > 0); + BEAST_EXPECT(peer->lastEntropyDigest_ != uint256{}); + } + } + } + + void + testRngImpossibleQuorumFallback() + { + using namespace csf; + using namespace std::chrono; + + testcase("RNG impossible quorum fallback"); + + ConsensusParms const parms{}; + Sim sim; + + PeerGroup majority = sim.createGroup(2); + PeerGroup isolated = sim.createGroup(1); + PeerGroup network = majority + isolated; + + for (Peer* peer : network) + peer->enableRngConsensus_ = true; + + // First run fully connected so expected proposers include all three. + network.trust(network); + network.connect( + network, round(0.2 * parms.ledgerGRANULARITY)); + sim.run(1); + + // Then isolate one node so 80% quorum becomes impossible for majority. + majority.disconnect(isolated); + isolated.disconnect(majority); + majority.connect( + majority, round(0.2 * parms.ledgerGRANULARITY)); + + sim.run(1); + + if (BEAST_EXPECT(sim.synchronized(majority))) + { + for (Peer const* peer : majority) + { + BEAST_EXPECT(peer->lastEntropyWasFallback_); + BEAST_EXPECT(peer->lastEntropyDigest_ == uint256{}); + BEAST_EXPECT(peer->lastEntropyCount_ == 0); + } + } + } + + void + testRngTimeoutWithPartialQuorum() + { + using namespace csf; + using namespace std::chrono; + + testcase("RNG timeout with partial quorum keeps entropy"); + + ConsensusParms const parms{}; + Sim sim; + + PeerGroup majority = sim.createGroup(4); + PeerGroup isolated = sim.createGroup(1); + PeerGroup network = majority + isolated; + + for (Peer* peer : network) + peer->enableRngConsensus_ = true; + + network.trust(network); + network.connect( + network, round(0.2 * parms.ledgerGRANULARITY)); + + // Seed expected proposers from a fully connected round. + sim.run(1); + + // Isolate one expected proposer. Majority should still progress after + // timeout using available commit quorum instead of zero-entropy + // fallback. + majority.disconnect(isolated); + isolated.disconnect(majority); + majority.connect( + majority, round(0.2 * parms.ledgerGRANULARITY)); + + sim.run(1); + + if (BEAST_EXPECT(sim.synchronized(majority))) + { + for (Peer const* peer : majority) + { + BEAST_EXPECT(!peer->lastEntropyWasFallback_); + BEAST_EXPECT(peer->lastEntropyDigest_ != uint256{}); + BEAST_EXPECT(peer->lastEntropyCount_ > 0); + } + } + } + void run() override { @@ -1077,6 +1198,9 @@ public: testHubNetwork(); testPreferredByBranch(); testPauseForLaggards(); + testRngCommitRevealConverges(); + testRngImpossibleQuorumFallback(); + testRngTimeoutWithPartialQuorum(); } }; diff --git a/src/test/csf/Peer.h b/src/test/csf/Peer.h index fed2df6e1..15e3962a3 100644 --- a/src/test/csf/Peer.h +++ b/src/test/csf/Peer.h @@ -20,6 +20,7 @@ #define RIPPLE_TEST_CSF_PEER_H_INCLUDED #include +#include #include #include #include @@ -28,11 +29,14 @@ #include #include #include +#include #include #include #include #include #include +#include +#include namespace ripple { namespace test { @@ -61,6 +65,8 @@ struct Peer class Position { public: + using Proposal = csf::Proposal; + Position(Proposal const& p) : proposal_(p) { } @@ -77,6 +83,18 @@ struct Peer return proposal_.getJson(); } + PeerKey + publicKey() const + { + return {proposal_.nodeID(), 0}; + } + + std::uint64_t + signature() const + { + return 0; + } + std::string render() const { @@ -169,8 +187,7 @@ struct Peer using NodeKey_t = PeerKey; using TxSet_t = TxSet; using PeerPosition_t = Position; - using Position_t = - typename TxSet_t::ID; // Use TxSet::ID for test framework + using Position_t = ProposalPosition; using Result = ConsensusResult; using NodeKey = Validation::NodeKey; @@ -243,6 +260,8 @@ struct Peer //! Whether to simulate running as validator or a tracking node bool runAsValidator = true; + //! Enable CSF RNG sub-state behavior for this peer. + bool enableRngConsensus_ = false; // TODO: Consider removing these two, they are only a convenience for tests // Number of proposers in the prior round @@ -259,6 +278,20 @@ struct Peer // Simulation parameters ConsensusParms consensusParms; + // RNG simulation state (for CSF RNG consensus hooks) + hash_set unlNodes_; + hash_set expectedProposers_; + hash_map pendingCommits_; + hash_map pendingReveals_; + hash_map nodeKeys_; + uint256 myEntropySecret_; + bool entropyFailed_ = false; + + // Last round summary (available for assertions in tests) + uint256 lastEntropyDigest_; + std::uint16_t lastEntropyCount_ = 0; + bool lastEntropyWasFallback_ = true; + //! The collectors to report events to CollectorRefs& collectors; @@ -512,15 +545,29 @@ struct Peer { issue(CloseLedger{prevLedger, openTxs}); + Position_t pos{TxSet::calcID(openTxs)}; + auto const seq = static_cast(prevLedger.seq()) + 1; + + // Bootstrap commit/reveal by including a commitment in our initial + // proposal when we are actively proposing this round. + if (enableRngConsensus_ && mode == ConsensusMode::proposing && + runAsValidator) + { + generateEntropySecret(); + auto const commitment = sha512Half( + myEntropySecret_, + static_cast(id), + key.second, + seq); + pos.myCommitment = commitment; + pendingCommits_[id] = commitment; + nodeKeys_.insert_or_assign(id, key); + } + return Result( TxSet{openTxs}, Proposal( - prevLedger.id(), - Proposal::seqJoin, - TxSet::calcID(openTxs), - closeTime, - now(), - id)); + prevLedger.id(), Proposal::seqJoin, pos, closeTime, now(), id)); } void @@ -555,6 +602,9 @@ struct Peer schedule(delays.ledgerAccept, [=, this]() { const bool proposing = mode == ConsensusMode::proposing; const bool consensusFail = result.state == ConsensusState::MovedOn; + auto const seq = static_cast(prevLedger.seq()) + 1; + + finalizeRoundEntropy(seq); TxSet const acceptedTxs = injectTxs(prevLedger, result.txns); Ledger const newLedger = oracle.accept( @@ -657,6 +707,290 @@ struct Peer return consensusParms; } + //-------------------------------------------------------------------------- + // RNG helpers for generic Consensus RNG sub-state support + + void + clearRngState() + { + pendingCommits_.clear(); + pendingReveals_.clear(); + nodeKeys_.clear(); + expectedProposers_.clear(); + myEntropySecret_.zero(); + entropyFailed_ = false; + } + + void + cacheUNLReport() + { + unlNodes_.clear(); + for (auto const* p : trustGraph.trustedPeers(this)) + unlNodes_.insert(p->id); + unlNodes_.insert(id); + } + + void + setExpectedProposers(hash_set proposers) + { + if (!proposers.empty()) + { + hash_set filtered; + for (auto const& nid : proposers) + if (isUNLReportMember(nid)) + filtered.insert(nid); + filtered.insert(id); + expectedProposers_ = std::move(filtered); + return; + } + + expectedProposers_.clear(); + if (!unlNodes_.empty()) + expectedProposers_ = unlNodes_; + } + + std::size_t + quorumThreshold() const + { + if (!enableRngConsensus_) + return (std::numeric_limits::max)() / 4; + + auto const base = expectedProposers_.empty() + ? unlNodes_.size() + : expectedProposers_.size(); + return calculateQuorumThreshold(base == 0 ? 1 : base); + } + + std::size_t + pendingCommitCount() const + { + return pendingCommits_.size(); + } + + bool + hasQuorumOfCommits() const + { + if (!enableRngConsensus_) + return false; + + if (!expectedProposers_.empty()) + { + for (auto const& nid : expectedProposers_) + { + if (pendingCommits_.find(nid) == pendingCommits_.end()) + return false; + } + return true; + } + + return pendingCommits_.size() >= quorumThreshold(); + } + + bool + hasMinimumReveals() const + { + if (!enableRngConsensus_) + return false; + return pendingReveals_.size() >= pendingCommits_.size(); + } + + bool + hasAnyReveals() const + { + if (!enableRngConsensus_) + return false; + return !pendingReveals_.empty(); + } + + uint256 + buildCommitSet(Ledger::Seq seq) + { + return hashRngSet(pendingCommits_, seq, "commit"); + } + + uint256 + buildEntropySet(Ledger::Seq seq) + { + return hashRngSet(pendingReveals_, seq, "reveal"); + } + + void + generateEntropySecret() + { + if (!enableRngConsensus_) + return; + + auto const seq = static_cast(lastClosedLedger.seq()) + 1; + myEntropySecret_ = sha512Half( + std::string("csf-rng-secret"), + static_cast(id), + key.second, + seq, + completedLedgers); + } + + uint256 const& + getEntropySecret() const + { + return myEntropySecret_; + } + + void + setEntropyFailed() + { + if (!enableRngConsensus_) + return; + entropyFailed_ = true; + } + + void + fetchRngSetIfNeeded(std::optional const&) + { + // CSF does not model separate SHAMap acquisition for RNG sidecar data. + } + + void + harvestRngData( + NodeID_t const& nodeId, + NodeKey_t const& publicKey, + Position_t const& position, + std::uint32_t, + NetClock::time_point, + Ledger::ID const& prevLedger, + std::uint64_t) + { + if (!enableRngConsensus_) + return; + + if (!isUNLReportMember(nodeId)) + return; + + nodeKeys_.insert_or_assign(nodeId, publicKey); + + if (position.myCommitment) + pendingCommits_[nodeId] = *position.myCommitment; + + if (!position.myReveal) + return; + + auto const commitIt = pendingCommits_.find(nodeId); + if (commitIt == pendingCommits_.end()) + return; + + auto const prevIt = ledgers.find(prevLedger); + if (prevIt == ledgers.end()) + return; + + auto const seq = static_cast(prevIt->second.seq()) + 1; + auto const expected = sha512Half( + *position.myReveal, + static_cast(publicKey.first), + publicKey.second, + seq); + if (expected != commitIt->second) + return; + + pendingReveals_[nodeId] = *position.myReveal; + } + + bool + isUNLReportMember(NodeID_t const& nodeId) const + { + return unlNodes_.count(nodeId) > 0; + } + + uint256 + hashRngSet( + hash_map const& entries, + Ledger::Seq seq, + std::string const& domain) const + { + std::vector> ordered; + ordered.reserve(entries.size()); + for (auto const& [nodeId, digest] : entries) + { + if (!isUNLReportMember(nodeId)) + continue; + ordered.emplace_back(static_cast(nodeId), digest); + } + + if (ordered.empty()) + return uint256{}; + + std::sort( + ordered.begin(), ordered.end(), [](auto const& a, auto const& b) { + return a.first < b.first; + }); + + uint256 out = sha512Half( + std::string("csf-rng-set"), + domain, + static_cast(seq)); + for (auto const& [nodeId, digest] : ordered) + out = sha512Half(out, nodeId, digest); + return out; + } + + void + finalizeRoundEntropy(std::uint32_t seq) + { + if (!enableRngConsensus_) + { + lastEntropyDigest_.zero(); + lastEntropyCount_ = 0; + lastEntropyWasFallback_ = true; + return; + } + + if (entropyFailed_ || pendingReveals_.empty()) + { + lastEntropyDigest_.zero(); + lastEntropyCount_ = 0; + lastEntropyWasFallback_ = true; + return; + } + + std::vector> ordered; + ordered.reserve(pendingReveals_.size()); + for (auto const& [nodeId, reveal] : pendingReveals_) + { + auto const it = nodeKeys_.find(nodeId); + if (it == nodeKeys_.end()) + continue; + ordered.emplace_back(it->second, reveal); + } + + if (ordered.empty()) + { + lastEntropyDigest_.zero(); + lastEntropyCount_ = 0; + lastEntropyWasFallback_ = true; + return; + } + + std::sort( + ordered.begin(), ordered.end(), [](auto const& a, auto const& b) { + if (a.first.first != b.first.first) + return a.first.first < b.first.first; + return a.first.second < b.first.second; + }); + + uint256 digest = sha512Half( + std::string("csf-rng-entropy"), static_cast(seq)); + for (auto const& [keyId, reveal] : ordered) + { + digest = sha512Half( + digest, + static_cast(keyId.first), + keyId.second, + reveal); + } + + lastEntropyDigest_ = digest; + lastEntropyCount_ = static_cast(ordered.size()); + lastEntropyWasFallback_ = false; + } + // Not interested in tracking consensus mode changes for now void onModeChange(ConsensusMode, ConsensusMode) diff --git a/src/test/csf/Proposal.h b/src/test/csf/Proposal.h index 1d70464ba..e4c5eecb3 100644 --- a/src/test/csf/Proposal.h +++ b/src/test/csf/Proposal.h @@ -23,17 +23,119 @@ #include #include #include +#include +#include +#include +#include +#include +#include namespace ripple { namespace test { namespace csf { -/** Proposal is a position taken in the consensus process and is represented - directly from the generic types. +/** Position sidecar for CSF that can model RNG commit/reveal fields. + + Core tx-set convergence remains keyed on txSetHash only, matching + production's ExtendedPosition behavior. */ -using Proposal = ConsensusProposal; +struct RngPosition +{ + TxSet::ID txSetHash{}; + std::optional commitSetHash; + std::optional entropySetHash; + std::optional myCommitment; + std::optional myReveal; + + RngPosition() = default; + explicit RngPosition(TxSet::ID txSet) : txSetHash(txSet) + { + } + + operator TxSet::ID() const + { + return txSetHash; + } + + void + updateTxSet(TxSet::ID txSet) + { + txSetHash = txSet; + } + + bool + operator==(RngPosition const& other) const + { + return txSetHash == other.txSetHash; + } + + bool + operator!=(RngPosition const& other) const + { + return !(*this == other); + } + + bool + operator==(TxSet::ID txSet) const + { + return txSetHash == txSet; + } + + bool + operator!=(TxSet::ID txSet) const + { + return txSetHash != txSet; + } +}; + +inline bool +operator==(TxSet::ID txSet, RngPosition const& pos) +{ + return pos == txSet; +} + +inline bool +operator!=(TxSet::ID txSet, RngPosition const& pos) +{ + return pos != txSet; +} + +inline std::string +to_string(RngPosition const& pos) +{ + return std::to_string(pos.txSetHash); +} + +inline std::ostream& +operator<<(std::ostream& os, RngPosition const& pos) +{ + return os << pos.txSetHash; +} + +template +void +hash_append(Hasher& h, RngPosition const& pos) +{ + using beast::hash_append; + auto appendOpt = [&](std::optional const& o) { + hash_append(h, static_cast(o.has_value() ? 1 : 0)); + if (o) + hash_append(h, *o); + }; + + hash_append(h, pos.txSetHash); + appendOpt(pos.commitSetHash); + appendOpt(pos.entropySetHash); + appendOpt(pos.myCommitment); + appendOpt(pos.myReveal); +} + +/** Proposal is a position taken in the consensus process. + */ +using Proposal = ConsensusProposal; +using ProposalPosition = RngPosition; } // namespace csf } // namespace test } // namespace ripple -#endif \ No newline at end of file +#endif