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:
Nicholas Dudfield
2026-02-06 16:27:10 +07:00
parent ae88fd3d24
commit b6811a6f59
3 changed files with 123 additions and 32 deletions

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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