From bb33e7cf64d802f53171bb82f4f50c639395fdbe Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Thu, 5 Feb 2026 16:20:54 +0700 Subject: [PATCH] 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. --- src/ripple/app/consensus/RCLConsensus.cpp | 10 +- src/ripple/app/consensus/RCLConsensus.h | 1 + src/ripple/app/consensus/RCLCxPeerPos.cpp | 22 ++- src/ripple/app/consensus/RCLCxPeerPos.h | 161 +++++++++++++++++++++- src/ripple/consensus/Consensus.h | 35 ++++- src/ripple/consensus/ConsensusTypes.h | 8 +- src/ripple/overlay/impl/PeerImp.cpp | 4 +- src/test/consensus/Consensus_test.cpp | 11 +- src/test/csf/Peer.h | 2 + 9 files changed, 233 insertions(+), 21 deletions(-) diff --git a/src/ripple/app/consensus/RCLConsensus.cpp b/src/ripple/app/consensus/RCLConsensus.cpp index 4fb0c5275..2b835832e 100644 --- a/src/ripple/app/consensus/RCLConsensus.cpp +++ b/src/ripple/app/consensus/RCLConsensus.cpp @@ -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}}; diff --git a/src/ripple/app/consensus/RCLConsensus.h b/src/ripple/app/consensus/RCLConsensus.h index f8c01e93c..f4ec1edf8 100644 --- a/src/ripple/app/consensus/RCLConsensus.h +++ b/src/ripple/app/consensus/RCLConsensus.h @@ -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; diff --git a/src/ripple/app/consensus/RCLCxPeerPos.cpp b/src/ripple/app/consensus/RCLCxPeerPos.cpp index ee5a45b94..24c6b81aa 100644 --- a/src/ripple/app/consensus/RCLCxPeerPos.cpp +++ b/src/ripple/app/consensus/RCLCxPeerPos.cpp @@ -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); diff --git a/src/ripple/app/consensus/RCLCxPeerPos.h b/src/ripple/app/consensus/RCLCxPeerPos.h index e82a85d42..e36d5d6b8 100644 --- a/src/ripple/app/consensus/RCLCxPeerPos.h +++ b/src/ripple/app/consensus/RCLCxPeerPos.h @@ -28,13 +28,160 @@ #include #include #include +#include #include #include #include +#include +#include #include 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 commitSetHash; + std::optional entropySetHash; + + // === Pipelined Leaves (No Agreement Required) === + std::optional myCommitment; + std::optional 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 +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; + //< The type of the proposed position (uses ExtendedPosition for RNG + //support) + using Proposal = ConsensusProposal; /** 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, diff --git a/src/ripple/consensus/Consensus.h b/src/ripple/consensus/Consensus.h index df5ec01ce..a829244ff 100644 --- a/src/ripple/consensus/Consensus.h +++ b/src/ripple/consensus/Consensus.h @@ -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; @@ -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::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 diff --git a/src/ripple/consensus/ConsensusTypes.h b/src/ripple/consensus/ConsensusTypes.h index 05d03c8a9..db9e88f97 100644 --- a/src/ripple/consensus/ConsensusTypes.h +++ b/src/ripple/consensus/ConsensusTypes.h @@ -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; 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(position.position())); } //! The set of transactions consensus agrees go in the ledger diff --git a/src/ripple/overlay/impl/PeerImp.cpp b/src/ripple/overlay/impl/PeerImp.cpp index adb05ddf6..dbb347cbf 100644 --- a/src/ripple/overlay/impl/PeerImp.cpp +++ b/src/ripple/overlay/impl/PeerImp.cpp @@ -1961,7 +1961,7 @@ PeerImp::onMessage(std::shared_ptr 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 const& m) RCLCxPeerPos::Proposal{ prevLedger, set.proposeseq(), - proposeHash, + ExtendedPosition{proposeHash}, closeTime, app_.timeKeeper().closeTime(), calcNodeID(app_.validatorManifests().getMasterKey(publicKey))}); diff --git a/src/test/consensus/Consensus_test.cpp b/src/test/consensus/Consensus_test.cpp index 1c19ff070..5ef52626a 100644 --- a/src/test/consensus/Consensus_test.cpp +++ b/src/test/consensus/Consensus_test.cpp @@ -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 } } diff --git a/src/test/csf/Peer.h b/src/test/csf/Peer.h index 6d3008f73..616f7fd22 100644 --- a/src/test/csf/Peer.h +++ b/src/test/csf/Peer.h @@ -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; using NodeKey = Validation::NodeKey;