feat(consensus): add ExtendedPosition for RNG entropy support

Introduce data structures for consensus-derived randomness using
commit-reveal scheme:

- Add ExtendedPosition struct with consensus targets (txSetHash,
  commitSetHash, entropySetHash) and pipelined leaves (myCommitment,
  myReveal)
- operator== excludes leaves to allow convergence with unique leaves
- add() includes ALL fields to prevent signature stripping attacks
- Add EstablishState enum for sub-phases: ConvergingTx, ConvergingCommit,
  ConvergingReveal
- Update Consensus template to use Adaptor::Position_t
- Add Position_t typedef to RCLConsensus::Adaptor and test CSF Peer

This is the foundational data structure work for the RNG implementation.
The gating logic and entropy computation will follow.
This commit is contained in:
Nicholas Dudfield
2026-02-05 16:20:54 +07:00
parent 12e1afb694
commit bb33e7cf64
9 changed files with 233 additions and 21 deletions

View File

@@ -166,9 +166,10 @@ RCLConsensus::Adaptor::share(RCLCxPeerPos const& peerPos)
prop.set_closetime(proposal.closeTime().time_since_epoch().count());
prop.set_currenttxhash(
proposal.position().begin(), proposal.position().size());
proposal.position().txSetHash.begin(),
proposal.position().txSetHash.size());
prop.set_previousledger(
proposal.prevLedger().begin(), proposal.position().size());
proposal.prevLedger().begin(), proposal.prevLedger().size());
auto const pk = peerPos.publicKey().slice();
prop.set_nodepubkey(pk.data(), pk.size());
@@ -210,7 +211,8 @@ RCLConsensus::Adaptor::propose(RCLCxPeerPos::Proposal const& proposal)
protocol::TMProposeSet prop;
prop.set_currenttxhash(
proposal.position().begin(), proposal.position().size());
proposal.position().txSetHash.begin(),
proposal.position().txSetHash.size());
prop.set_previousledger(
proposal.prevLedger().begin(), proposal.prevLedger().size());
prop.set_proposeseq(proposal.proposeSeq());
@@ -390,7 +392,7 @@ RCLConsensus::Adaptor::onClose(
RCLCxPeerPos::Proposal{
initialLedger->info().parentHash,
RCLCxPeerPos::Proposal::seqJoin,
setHash,
ExtendedPosition{setHash},
closeTime,
app_.timeKeeper().closeTime(),
validatorKeys_.nodeID}};

View File

@@ -93,6 +93,7 @@ class RCLConsensus
using NodeKey_t = PublicKey;
using TxSet_t = RCLTxSet;
using PeerPosition_t = RCLCxPeerPos;
using Position_t = ExtendedPosition;
using Result = ConsensusResult<Adaptor>;

View File

@@ -47,8 +47,16 @@ RCLCxPeerPos::RCLCxPeerPos(
bool
RCLCxPeerPos::checkSign() const
{
return verifyDigest(
publicKey(), proposal_.signingHash(), signature(), false);
// Use proposalUniqueId to ensure full ExtendedPosition is covered
auto const signingHash = proposalUniqueId(
proposal_.position(),
proposal_.prevLedger(),
proposal_.proposeSeq(),
proposal_.closeTime(),
publicKey_.slice(),
Slice{nullptr, 0}); // Exclude signature for signing hash
return verifyDigest(publicKey(), signingHash, signature(), false);
}
Json::Value
@@ -64,7 +72,7 @@ RCLCxPeerPos::getJson() const
uint256
proposalUniqueId(
uint256 const& proposeHash,
ExtendedPosition const& position,
uint256 const& previousLedger,
std::uint32_t proposeSeq,
NetClock::time_point closeTime,
@@ -72,10 +80,14 @@ proposalUniqueId(
Slice const& signature)
{
Serializer s(512);
s.addBitString(proposeHash);
s.addBitString(previousLedger);
s.add32(HashPrefix::proposal);
s.add32(proposeSeq);
s.add32(closeTime.time_since_epoch().count());
s.addBitString(previousLedger);
// Serialize full ExtendedPosition (TxSet + Sets + Leaves)
position.add(s);
s.addVL(publicKey);
s.addVL(signature);

View File

@@ -28,13 +28,160 @@
#include <ripple/protocol/HashPrefix.h>
#include <ripple/protocol/PublicKey.h>
#include <ripple/protocol/SecretKey.h>
#include <ripple/protocol/Serializer.h>
#include <boost/container/static_vector.hpp>
#include <chrono>
#include <cstdint>
#include <optional>
#include <ostream>
#include <string>
namespace ripple {
/** Extended position for consensus with RNG entropy support.
Carries both the consensus targets (set hashes that require agreement)
and pipelined leaves (per-validator data transported via gossip).
Critical design:
- operator== excludes leaves (allows convergence with unique leaves)
- add() includes ALL fields (prevents signature stripping attacks)
*/
struct ExtendedPosition
{
// === Consensus Targets (Agreement Required) ===
uint256 txSetHash;
std::optional<uint256> commitSetHash;
std::optional<uint256> entropySetHash;
// === Pipelined Leaves (No Agreement Required) ===
std::optional<uint256> myCommitment;
std::optional<uint256> myReveal;
ExtendedPosition() = default;
explicit ExtendedPosition(uint256 const& txSet) : txSetHash(txSet)
{
}
// Implicit conversion for legacy compatibility
operator uint256() const
{
return txSetHash;
}
// Helper to update TxSet while preserving sidecar data
void
updateTxSet(uint256 const& set)
{
txSetHash = set;
}
// CRITICAL: Exclude leaves from equality - consensus only on set hashes
bool
operator==(ExtendedPosition const& other) const
{
return txSetHash == other.txSetHash &&
commitSetHash == other.commitSetHash &&
entropySetHash == other.entropySetHash;
}
bool
operator!=(ExtendedPosition const& other) const
{
return !(*this == other);
}
// Comparison with uint256 (compares txSetHash only)
bool
operator==(uint256 const& hash) const
{
return txSetHash == hash;
}
bool
operator!=(uint256 const& hash) const
{
return txSetHash != hash;
}
friend bool
operator==(uint256 const& hash, ExtendedPosition const& pos)
{
return pos.txSetHash == hash;
}
friend bool
operator!=(uint256 const& hash, ExtendedPosition const& pos)
{
return pos.txSetHash != hash;
}
// CRITICAL: Include ALL fields for signing (prevents stripping attacks)
void
add(Serializer& s) const
{
s.addBitString(txSetHash);
std::uint8_t flags = 0;
if (commitSetHash)
flags |= 0x01;
if (entropySetHash)
flags |= 0x02;
if (myCommitment)
flags |= 0x04;
if (myReveal)
flags |= 0x08;
s.add8(flags);
if (commitSetHash)
s.addBitString(*commitSetHash);
if (entropySetHash)
s.addBitString(*entropySetHash);
if (myCommitment)
s.addBitString(*myCommitment);
if (myReveal)
s.addBitString(*myReveal);
}
Json::Value
getJson() const
{
Json::Value ret = Json::objectValue;
ret["tx_set"] = to_string(txSetHash);
if (commitSetHash)
ret["commit_set"] = to_string(*commitSetHash);
if (entropySetHash)
ret["entropy_set"] = to_string(*entropySetHash);
return ret;
}
};
// For logging/debugging - returns txSetHash as string
inline std::string
to_string(ExtendedPosition const& pos)
{
return to_string(pos.txSetHash);
}
// Stream output for logging
inline std::ostream&
operator<<(std::ostream& os, ExtendedPosition const& pos)
{
return os << pos.txSetHash;
}
// For hash_append (used in sha512Half and similar)
template <class Hasher>
void
hash_append(Hasher& h, ExtendedPosition const& pos)
{
using beast::hash_append;
// Serialize full position including all fields
Serializer s;
pos.add(s);
hash_append(h, s.slice());
}
/** A peer's signed, proposed position for use in RCLConsensus.
Carries a ConsensusProposal signed by a peer. Provides value semantics
@@ -43,8 +190,9 @@ namespace ripple {
class RCLCxPeerPos
{
public:
//< The type of the proposed position
using Proposal = ConsensusProposal<NodeID, uint256, uint256>;
//< The type of the proposed position (uses ExtendedPosition for RNG
//support)
using Proposal = ConsensusProposal<NodeID, uint256, ExtendedPosition>;
/** Constructor
@@ -112,7 +260,10 @@ private:
hash_append(h, std::uint32_t(proposal().proposeSeq()));
hash_append(h, proposal().closeTime());
hash_append(h, proposal().prevLedger());
hash_append(h, proposal().position());
// Serialize full ExtendedPosition for hashing
Serializer s;
proposal().position().add(s);
hash_append(h, s.slice());
}
};
@@ -125,7 +276,7 @@ private:
order to validate the signature. If the last closed ledger is left out, then
it is considered as all zeroes for the purposes of signing.
@param proposeHash The hash of the proposed position
@param position The extended position (includes entropy fields)
@param previousLedger The hash of the ledger the proposal is based upon
@param proposeSeq Sequence number of the proposal
@param closeTime Close time of the proposal
@@ -134,7 +285,7 @@ private:
*/
uint256
proposalUniqueId(
uint256 const& proposeHash,
ExtendedPosition const& position,
uint256 const& previousLedger,
std::uint32_t proposeSeq,
NetClock::time_point closeTime,

View File

@@ -36,6 +36,21 @@
namespace ripple {
/** Sub-states for pipelined consensus with RNG entropy support.
The establish phase is divided into sub-states to support commit-reveal
for consensus-derived randomness while maintaining low latency through
pipelining.
@note Data collection (commits, reveals) happens continuously via proposal
leaves. Sub-states are checkpoints, not serial waits.
*/
enum class EstablishState {
ConvergingTx, ///< Normal txset convergence + harvesting commits
ConvergingCommit, ///< Confirming commitSet agreement (near-instant)
ConvergingReveal ///< Collecting reveals + confirming entropySet
};
/** Determines whether the current ledger should close at this time.
This function should be called when a ledger is open and there is no close
@@ -289,10 +304,11 @@ class Consensus
using NodeID_t = typename Adaptor::NodeID_t;
using Tx_t = typename TxSet_t::Tx;
using PeerPosition_t = typename Adaptor::PeerPosition_t;
// Use Adaptor::Position_t for RNG support (ExtendedPosition)
using Proposal_t = ConsensusProposal<
NodeID_t,
typename Ledger_t::ID,
typename TxSet_t::ID>;
typename Adaptor::Position_t>;
using Result = ConsensusResult<Adaptor>;
@@ -542,6 +558,7 @@ private:
Adaptor& adaptor_;
ConsensusPhase phase_{ConsensusPhase::accepted};
EstablishState estState_{EstablishState::ConvergingTx};
MonitoredMode mode_{ConsensusMode::observing};
bool firstRound_ = true;
bool haveCloseTimeConsensus_ = false;
@@ -1515,7 +1532,21 @@ Consensus<Adaptor>::updateOurPositions()
<< consensusCloseTime.time_since_epoch().count()
<< ", tx " << newID;
result_->position.changePosition(newID, consensusCloseTime, now_);
// Preserve sidecar data (RNG fields), only update txSetHash
// Use type traits to conditionally handle ExtendedPosition vs simple ID
if constexpr (requires(typename Adaptor::Position_t p) {
p.updateTxSet(newID);
})
{
auto currentPos = result_->position.position();
currentPos.updateTxSet(newID);
result_->position.changePosition(
currentPos, consensusCloseTime, now_);
}
else
{
result_->position.changePosition(newID, consensusCloseTime, now_);
}
// Share our new transaction set and update disputes
// if we haven't already received it

View File

@@ -205,16 +205,20 @@ struct ConsensusResult
using NodeID_t = typename Traits::NodeID_t;
using Tx_t = typename TxSet_t::Tx;
// Use Traits::Position_t for RNG support (defaults to TxSet_t::ID)
using Proposal_t = ConsensusProposal<
NodeID_t,
typename Ledger_t::ID,
typename TxSet_t::ID>;
typename Traits::Position_t>;
using Dispute_t = DisputedTx<Tx_t, NodeID_t>;
ConsensusResult(TxSet_t&& s, Proposal_t&& p)
: txns{std::move(s)}, position{std::move(p)}
{
assert(txns.id() == position.position());
// Use implicit conversion to uint256 for ExtendedPosition
assert(
txns.id() ==
static_cast<typename TxSet_t::ID>(position.position()));
}
//! The set of transactions consensus agrees go in the ledger

View File

@@ -1961,7 +1961,7 @@ PeerImp::onMessage(std::shared_ptr<protocol::TMProposeSet> const& m)
NetClock::time_point const closeTime{NetClock::duration{set.closetime()}};
uint256 const suppression = proposalUniqueId(
proposeHash,
ExtendedPosition{proposeHash},
prevLedger,
set.proposeseq(),
closeTime,
@@ -2008,7 +2008,7 @@ PeerImp::onMessage(std::shared_ptr<protocol::TMProposeSet> const& m)
RCLCxPeerPos::Proposal{
prevLedger,
set.proposeseq(),
proposeHash,
ExtendedPosition{proposeHash},
closeTime,
app_.timeKeeper().closeTime(),
calcNodeID(app_.validatorManifests().getMasterKey(publicKey))});

View File

@@ -145,6 +145,7 @@ public:
using namespace csf;
using namespace std::chrono;
//@@start peers-agree
ConsensusParms const parms{};
Sim sim;
PeerGroup peers = sim.createGroup(5);
@@ -174,6 +175,7 @@ public:
BEAST_EXPECT(lcl.txs().find(Tx{i}) != lcl.txs().end());
}
}
//@@end peers-agree
}
void
@@ -186,6 +188,7 @@ public:
// that have significantly longer network delays to the rest of the
// network
//@@start slow-peer-scenario
// Test when a slow peer doesn't delay a consensus quorum (4/5 agree)
{
ConsensusParms const parms{};
@@ -224,16 +227,18 @@ public:
BEAST_EXPECT(
peer->prevRoundTime == network[0]->prevRoundTime);
// Slow peer's transaction (Tx{0}) didn't make it in time
BEAST_EXPECT(lcl.txs().find(Tx{0}) == lcl.txs().end());
for (std::uint32_t i = 2; i < network.size(); ++i)
BEAST_EXPECT(lcl.txs().find(Tx{i}) != lcl.txs().end());
// Tx 0 didn't make it
// Tx 0 is still in the open transaction set for next round
BEAST_EXPECT(
peer->openTxs.find(Tx{0}) != peer->openTxs.end());
}
}
}
//@@end slow-peer-scenario
// Test when the slow peers delay a consensus quorum (4/6 agree)
{
@@ -421,6 +426,7 @@ public:
// the wrong LCL at different phases of consensus
for (auto validationDelay : {0ms, parms.ledgerMIN_CLOSE})
{
//@@start wrong-lcl-scenario
// Consider 10 peers:
// 0 1 2 3 4 5 6 7 8 9
// minority majorityA majorityB
@@ -441,6 +447,7 @@ public:
// This topology can potentially fork with the above trust relations
// but that is intended for this test.
//@@end wrong-lcl-scenario
Sim sim;
@@ -724,6 +731,7 @@ public:
}
sim.run(1);
//@@start fork-threshold
// Fork should not happen for 40% or greater overlap
// Since the overlapped nodes have a UNL that is the union of the
// two cliques, the maximum sized UNL list is the number of peers
@@ -735,6 +743,7 @@ public:
// One for cliqueA, one for cliqueB and one for nodes in both
BEAST_EXPECT(sim.branches() <= 3);
}
//@@end fork-threshold
}
}

View File

@@ -159,6 +159,8 @@ 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 Result = ConsensusResult<Peer>;
using NodeKey = Validation::NodeKey;