diff --git a/src/ripple/app/consensus/RCLConsensus.cpp b/src/ripple/app/consensus/RCLConsensus.cpp index df576ba26..747b520fe 100644 --- a/src/ripple/app/consensus/RCLConsensus.cpp +++ b/src/ripple/app/consensus/RCLConsensus.cpp @@ -256,23 +256,28 @@ RCLConsensus::Adaptor::propose(RCLCxPeerPos::Proposal const& proposal) prop.set_signature(sig.data(), sig.size()); // Store our own proposal proof for embedding in SHAMap entries. - // The proposal signature already covers the full ExtendedPosition, - // so this proof lets peers verify provenance of our commit/reveal. + // commitProofs_ gets seq=0 only (deterministic commitSet). + // proposalProofs_ gets the latest with a reveal (for entropySet). if (proposal.position().myCommitment || proposal.position().myReveal) { - ProposalProof proof; - proof.proposeSeq = proposal.proposeSeq(); - proof.closeTime = static_cast( - proposal.closeTime().time_since_epoch().count()); - proof.prevLedger = proposal.prevLedger(); + auto makeProof = [&]() { + ProposalProof proof; + proof.proposeSeq = proposal.proposeSeq(); + proof.closeTime = static_cast( + proposal.closeTime().time_since_epoch().count()); + proof.prevLedger = proposal.prevLedger(); + Serializer s; + proposal.position().add(s); + proof.positionData = std::move(s); + proof.signature = Buffer(sig.data(), sig.size()); + return proof; + }; - Serializer s; - proposal.position().add(s); - proof.positionData = std::move(s); + if (proposal.position().myCommitment && proposal.proposeSeq() == 0) + commitProofs_.emplace(validatorKeys_.nodeID, makeProof()); - proof.signature = Buffer(sig.data(), sig.size()); - - proposalProofs_[validatorKeys_.nodeID] = std::move(proof); + if (proposal.position().myReveal) + proposalProofs_[validatorKeys_.nodeID] = makeProof(); } auto const suppression = proposalUniqueId( @@ -1173,13 +1178,65 @@ RCLConsensus::Adaptor::quorumThreshold() const return (base * 80 + 99) / 100; } +void +RCLConsensus::Adaptor::setExpectedProposers(hash_set proposers) +{ + if (!proposers.empty()) + { + // Recent proposers from last round — best signal for who's active. + // Always include ourselves. + proposers.insert(validatorKeys_.nodeID); + expectedProposers_ = std::move(proposers); + JLOG(j_.debug()) << "RNG: expectedProposers from recent proposers: " + << expectedProposers_.size(); + return; + } + + // First round (no previous proposers): fall back to activeUNL. + // cacheActiveUNL() was called just before this, so it's populated. + if (!activeUNLNodeIds_.empty()) + { + expectedProposers_ = activeUNLNodeIds_; + JLOG(j_.debug()) << "RNG: expectedProposers from activeUNL: " + << expectedProposers_.size(); + return; + } + + // No data at all (shouldn't happen — cacheActiveUNL falls back to + // trusted keys). Leave empty → hasQuorumOfCommits uses 80% fallback. + JLOG(j_.warn()) << "RNG: no expectedProposers available"; +} + bool RCLConsensus::Adaptor::hasQuorumOfCommits() const { + if (!expectedProposers_.empty()) + { + // Wait for commits from all expected proposers. + // rngPIPELINE_TIMEOUT is the safety valve for dead nodes. + for (auto const& id : expectedProposers_) + { + if (pendingCommits_.find(id) == pendingCommits_.end()) + { + JLOG(j_.debug()) + << "RNG: hasQuorumOfCommits? " << pendingCommits_.size() + << "/" << expectedProposers_.size() << " -> no"; + return false; + } + } + JLOG(j_.debug()) << "RNG: hasQuorumOfCommits? " + << pendingCommits_.size() << "/" + << expectedProposers_.size() + << " -> YES (all expected)"; + return true; + } + + // Fallback: 80% of active UNL (cold boot, no expected set) auto threshold = quorumThreshold(); bool result = pendingCommits_.size() >= threshold; JLOG(j_.debug()) << "RNG: hasQuorumOfCommits? " << pendingCommits_.size() - << "/" << threshold << " -> " << (result ? "YES" : "no"); + << "/" << threshold << " -> " << (result ? "YES" : "no") + << " (80% fallback)"; return result; } @@ -1234,8 +1291,8 @@ RCLConsensus::Adaptor::buildCommitSet(LedgerIndex seq) obj.setFieldAmount(sfFee, STAmount{}); obj.setFieldH256(sfDigest, commit); obj.setFieldVL(sfSigningPubKey, kit->second.slice()); - auto proofIt = proposalProofs_.find(nodeId); - if (proofIt != proposalProofs_.end()) + auto proofIt = commitProofs_.find(nodeId); + if (proofIt != commitProofs_.end()) obj.setFieldVL(sfBlob, serializeProof(proofIt->second)); }); @@ -1345,6 +1402,8 @@ RCLConsensus::Adaptor::clearRngState() entropySetMap_.reset(); pendingRngFetches_.clear(); activeUNLNodeIds_.clear(); + expectedProposers_.clear(); + commitProofs_.clear(); proposalProofs_.clear(); } @@ -1769,25 +1828,30 @@ RCLConsensus::Adaptor::harvestRngData( } } - // Store proposal proof for embedding in SHAMap entries. - // The proposal signature covers the full ExtendedPosition (including - // myCommitment/myReveal), so this proof lets any node verify provenance - // of entries in fetched commit/entropy SHAMaps. + // Store proposal proofs for embedding in SHAMap entries. + // commitProofs_: only seq=0 (commitments always ride on seq=0, + // so all nodes store the same proof → deterministic commitSet). + // proposalProofs_: latest proof carrying a reveal (for entropySet). if (position.myCommitment || position.myReveal) { - ProposalProof proof; - proof.proposeSeq = proposeSeq; - proof.closeTime = - static_cast(closeTime.time_since_epoch().count()); - proof.prevLedger = prevLedger; + auto makeProof = [&]() { + ProposalProof proof; + proof.proposeSeq = proposeSeq; + proof.closeTime = static_cast( + closeTime.time_since_epoch().count()); + proof.prevLedger = prevLedger; + Serializer s; + position.add(s); + proof.positionData = std::move(s); + proof.signature = Buffer(signature.data(), signature.size()); + return proof; + }; - Serializer s; - position.add(s); - proof.positionData = std::move(s); + if (position.myCommitment && proposeSeq == 0) + commitProofs_.emplace(nodeId, makeProof()); - proof.signature = Buffer(signature.data(), signature.size()); - - proposalProofs_[nodeId] = std::move(proof); + if (position.myReveal) + proposalProofs_[nodeId] = makeProof(); } } diff --git a/src/ripple/app/consensus/RCLConsensus.h b/src/ripple/app/consensus/RCLConsensus.h index 8433872db..44f09ff4d 100644 --- a/src/ripple/app/consensus/RCLConsensus.h +++ b/src/ripple/app/consensus/RCLConsensus.h @@ -107,6 +107,10 @@ class RCLConsensus // Cached set of NodeIDs from UNL Report (or fallback UNL) hash_set activeUNLNodeIds_; + // Expected proposers for commit quorum — derived from last round's + // actual proposers (best signal), falling back to activeUNL. + hash_set expectedProposers_; + /** Proof data from a proposal signature, for embedding in SHAMap entries. Contains everything needed to independently verify that a validator committed/revealed a specific value. */ @@ -119,7 +123,10 @@ class RCLConsensus Buffer signature; }; - // Proposal proofs keyed by NodeID + // Proposal proofs keyed by NodeID. + // commitProofs_: only seq=0 proofs (deterministic across all nodes). + // proposalProofs_: latest proof with reveal (for entropySet). + hash_map commitProofs_; hash_map proposalProofs_; public: @@ -220,6 +227,12 @@ class RCLConsensus std::size_t quorumThreshold() const; + /** Set expected proposers for this round's commit quorum. + Cascade: recent proposers > activeUNL > (empty = 80% fallback). + */ + void + setExpectedProposers(hash_set proposers); + /** Check if we have quorum of commits */ bool hasQuorumOfCommits() const; diff --git a/src/ripple/consensus/Consensus.h b/src/ripple/consensus/Consensus.h index 8b5268cac..8d6701066 100644 --- a/src/ripple/consensus/Consensus.h +++ b/src/ripple/consensus/Consensus.h @@ -719,6 +719,18 @@ Consensus::startRoundInternal( convergePercent_ = 0; haveCloseTimeConsensus_ = false; openTime_.reset(clock_.now()); + + // Capture last round's proposer IDs before clearing — this is the + // best signal for who will propose this round. + hash_set lastProposers; + if constexpr (requires(Adaptor & a) { + a.setExpectedProposers(hash_set{}); + }) + { + for (auto const& [id, pos] : currPeerPositions_) + lastProposers.insert(id); + } + currPeerPositions_.clear(); acquired_.clear(); rawCloseTimes_.peers.clear(); @@ -733,6 +745,8 @@ Consensus::startRoundInternal( // onClose only caches for proposing validators, so observers // would otherwise have an empty set and reject all RNG data. adaptor_.cacheActiveUNL(); + // Set expected proposers: recent proposers > activeUNL > 80% fallback + adaptor_.setExpectedProposers(std::move(lastProposers)); } // Reset establish sub-state for new round