mirror of
https://github.com/Xahau/xahaud.git
synced 2026-04-29 15:37:46 +00:00
feat(consensus): deterministic commitSets via expected proposers and seq=0 proofs
Wait for commits from last round's proposers (falling back to activeUNL on cold boot) instead of 80% of UNL. This ensures all nodes build the commitSet at the same moment with the same entries. Split proof storage: commitProofs_ (seq=0 only, deterministic) and proposalProofs_ (latest with reveal, for entropySet). Previously the proof blob contained whichever proposeSeq was last seen, causing identical commits to produce different SHAMap hashes across nodes. 20-node testnet: all nodes now produce identical commitSet hashes.
This commit is contained in:
@@ -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<std::uint32_t>(
|
||||
proposal.closeTime().time_since_epoch().count());
|
||||
proof.prevLedger = proposal.prevLedger();
|
||||
auto makeProof = [&]() {
|
||||
ProposalProof proof;
|
||||
proof.proposeSeq = proposal.proposeSeq();
|
||||
proof.closeTime = static_cast<std::uint32_t>(
|
||||
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<NodeID> 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<std::uint32_t>(closeTime.time_since_epoch().count());
|
||||
proof.prevLedger = prevLedger;
|
||||
auto makeProof = [&]() {
|
||||
ProposalProof proof;
|
||||
proof.proposeSeq = proposeSeq;
|
||||
proof.closeTime = static_cast<std::uint32_t>(
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,10 @@ class RCLConsensus
|
||||
// Cached set of NodeIDs from UNL Report (or fallback UNL)
|
||||
hash_set<NodeID> activeUNLNodeIds_;
|
||||
|
||||
// Expected proposers for commit quorum — derived from last round's
|
||||
// actual proposers (best signal), falling back to activeUNL.
|
||||
hash_set<NodeID> 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<NodeID, ProposalProof> commitProofs_;
|
||||
hash_map<NodeID, ProposalProof> 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<NodeID> proposers);
|
||||
|
||||
/** Check if we have quorum of commits */
|
||||
bool
|
||||
hasQuorumOfCommits() const;
|
||||
|
||||
@@ -719,6 +719,18 @@ Consensus<Adaptor>::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<NodeID_t> lastProposers;
|
||||
if constexpr (requires(Adaptor & a) {
|
||||
a.setExpectedProposers(hash_set<NodeID_t>{});
|
||||
})
|
||||
{
|
||||
for (auto const& [id, pos] : currPeerPositions_)
|
||||
lastProposers.insert(id);
|
||||
}
|
||||
|
||||
currPeerPositions_.clear();
|
||||
acquired_.clear();
|
||||
rawCloseTimes_.peers.clear();
|
||||
@@ -733,6 +745,8 @@ Consensus<Adaptor>::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
|
||||
|
||||
Reference in New Issue
Block a user