From 2218bdd7f39af24f0b4cc03e21531b2b2c7e4e8b Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Mon, 27 Apr 2026 17:36:06 +0700 Subject: [PATCH] fix(consensus): require export sigset quorum alignment --- .../consensus/ConsensusExtensions_test.cpp | 367 ++++++++++++++++++ src/test/consensus/ConsensusRng_test.cpp | 175 +++++++++ src/test/csf/Peer.h | 187 +++++++-- src/test/csf/Proposal.h | 4 + .../app/consensus/ConsensusExtensions.cpp | 42 +- src/xrpld/app/consensus/ConsensusExtensions.h | 29 +- .../consensus/ConsensusExtensionsDesign.md | 25 +- src/xrpld/app/consensus/RCLConsensus.cpp | 5 +- src/xrpld/app/tx/detail/Export.cpp | 35 +- src/xrpld/consensus/ConsensusExtensionsTick.h | 231 ++++++++--- 10 files changed, 992 insertions(+), 108 deletions(-) diff --git a/src/test/consensus/ConsensusExtensions_test.cpp b/src/test/consensus/ConsensusExtensions_test.cpp index 417031357..8b8671793 100644 --- a/src/test/consensus/ConsensusExtensions_test.cpp +++ b/src/test/consensus/ConsensusExtensions_test.cpp @@ -19,14 +19,279 @@ #include #include #include +#include +#include #include #include #include #include +#include +#include namespace ripple { namespace test { +namespace { + +uint256 +makeHash(char const* label) +{ + return sha512Half(Slice(label, std::strlen(label))); +} + +NodeID +makeNode(std::uint8_t id) +{ + NodeID node; + node.zero(); + node.data()[NodeID::size() - 1] = id; + return node; +} + +struct FakeTxSet +{ + using ID = uint256; + + uint256 hash; + + uint256 + id() const + { + return hash; + } +}; + +class FakePeerPosition +{ +public: + using Proposal = ConsensusProposal; + + FakePeerPosition(NodeID const& nodeId, ExtendedPosition const& position) + : proposal_( + uint256{}, + Proposal::seqJoin, + position, + NetClock::time_point{}, + NetClock::time_point{}, + nodeId) + { + } + + Proposal const& + proposal() const + { + return proposal_; + } + +private: + Proposal proposal_; +}; + +struct FakeExtensions +{ + enum class SidecarKind : uint8_t { commit, reveal, exportSig }; + + beast::Journal j_{beast::Journal::getNullSink()}; + EstablishState estState_{EstablishState::ConvergingTx}; + std::chrono::steady_clock::time_point revealPhaseStart_{}; + std::chrono::steady_clock::time_point commitHashConflictStart_{}; + bool explicitFinalProposalSent_{false}; + bool entropySetPublished_{false}; + std::chrono::steady_clock::time_point entropyPublishStart_{}; + bool exportSigGateStarted_{false}; + std::chrono::steady_clock::time_point exportSigGateStart_{}; + bool exportSigConvergenceFailed_{false}; + bool localExportSigs{true}; + bool exportOn{true}; + std::size_t exportQuorum{4}; + uint256 exportHash{makeHash("local-export-sig-set")}; + std::vector fetchedExportSets; + int exportBuilds = 0; + + bool + rngEnabled() const + { + return false; + } + + bool + exportEnabled() const + { + return exportOn; + } + + std::size_t + quorumThreshold() const + { + return exportQuorum; + } + + std::size_t + exportSigQuorumThreshold() const + { + return exportQuorum; + } + + std::size_t + pendingCommitCount() const + { + return 0; + } + + std::size_t + pendingRevealCount() const + { + return 0; + } + + std::size_t + expectedProposerCount() const + { + return 0; + } + + bool + hasQuorumOfCommits() const + { + return false; + } + + bool + hasMinimumReveals() const + { + return false; + } + + bool + hasAnyReveals() const + { + return false; + } + + uint256 + buildCommitSet(LedgerIndex) + { + return makeHash("commit-set"); + } + + uint256 + buildEntropySet(LedgerIndex) + { + return makeHash("entropy-set"); + } + + uint256 + getEntropySecret() const + { + return makeHash("entropy-secret"); + } + + void + selfSeedReveal() + { + } + + void + setEntropyFailed() + { + } + + void + fetchRngSetIfNeeded(std::optional const& hash, SidecarKind kind) + { + if (kind == SidecarKind::exportSig && hash) + fetchedExportSets.push_back(*hash); + } + + bool + shouldSendExplicitFinalProposal() const + { + return false; + } + + std::optional + buildExplicitFinalProposalTxSet(FakeTxSet const&, LedgerIndex) + { + return std::nullopt; + } + + bool + hasPendingExportSigs() const + { + return localExportSigs; + } + + uint256 + buildExportSigSet(LedgerIndex) + { + ++exportBuilds; + return exportHash; + } + + void + setExportSigConvergenceFailed() + { + exportSigConvergenceFailed_ = true; + } +}; + +struct ExportTickHarness +{ + ExtendedPosition position{makeHash("tx-set")}; + FakeTxSet txns{position.txSetHash}; + hash_map peers; + ConsensusParms parms; + NetClock::time_point netNow{NetClock::duration{123}}; + std::chrono::steady_clock::time_point start{}; + int updates = 0; + int proposes = 0; + + void + addPeer( + std::uint8_t id, + std::optional exportSigSetHash, + uint256 txSetHash = makeHash("tx-set")) + { + ExtendedPosition peerPosition{txSetHash}; + peerPosition.exportSigSetHash = exportSigSetHash; + peers.emplace( + makeNode(id), FakePeerPosition{makeNode(id), peerPosition}); + } + + ExtensionTickResult + tick(FakeExtensions& ext, std::chrono::milliseconds elapsed = {}) + { + ConsensusTick ctx{ + .buildSeq = 2, + .now = netNow, + .nowSteady = start + elapsed, + .roundTime = elapsed, + .mode = ConsensusMode::proposing, + .prevProposers = 0, + .peerPositions = peers, + .parms = parms, + .haveCloseTimeConsensus = true, + .convergePercent = 100, + .j = beast::Journal{beast::Journal::getNullSink()}, + .getPosition = [&]() -> ExtendedPosition const& { + return position; + }, + .updatePosition = + [&](ExtendedPosition const& newPosition) { + position = newPosition; + ++updates; + }, + .propose = [&]() { ++proposes; }, + .haveConsensus = []() { return true; }, + .cacheAndShareTxSet = [](FakeTxSet const&) {}, + .getTxns = [&]() -> FakeTxSet const& { return txns; }}; + + return extensionsTick(ext, ctx); + } +}; + +} // namespace + class ConsensusExtensions_test : public beast::unit_test::suite { std::vector @@ -106,11 +371,113 @@ class ConsensusExtensions_test : public beast::unit_test::suite BEAST_EXPECT(view->containsNode(calcNodeID(vlKeys[1]))); } + void + testExportSigGateRequiresQuorumAlignment() + { + testcase("Export sig gate requires quorum alignment"); + + FakeExtensions ext; + ExportTickHarness harness; + auto const localHash = ext.exportHash; + + harness.addPeer(1, localHash); + harness.addPeer(2, localHash); + + auto result = harness.tick(ext); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(harness.position.exportSigSetHash == localHash); + BEAST_EXPECT(ext.exportSigGateStarted_); + + result = harness.tick(ext, std::chrono::milliseconds{100}); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(!ext.exportSigConvergenceFailed_); + + result = harness.tick( + ext, + harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1}); + BEAST_EXPECT(result.readyForAccept); + BEAST_EXPECT(ext.exportSigConvergenceFailed_); + } + + void + testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict() + { + testcase("Export sig gate ignores minority conflict after quorum"); + + FakeExtensions ext; + ExportTickHarness harness; + auto const localHash = ext.exportHash; + auto const conflictHash = makeHash("conflicting-export-sig-set"); + + harness.addPeer(1, localHash); + harness.addPeer(2, localHash); + harness.addPeer(3, localHash); + harness.addPeer(4, conflictHash); + + auto result = harness.tick(ext); + BEAST_EXPECT(!result.readyForAccept); + + result = harness.tick(ext, std::chrono::milliseconds{100}); + BEAST_EXPECT(result.readyForAccept); + BEAST_EXPECT(!ext.exportSigConvergenceFailed_); + BEAST_EXPECT(ext.fetchedExportSets.size() == 1); + BEAST_EXPECT(ext.fetchedExportSets.front() == conflictHash); + } + + void + testExportSigGateFetchesAdvertisedPeerSets() + { + testcase("Export sig gate fetches advertised peer sets"); + + FakeExtensions ext; + ext.localExportSigs = false; + ExportTickHarness harness; + auto const peerHash = makeHash("peer-export-sig-set"); + + harness.addPeer(1, peerHash); + + auto result = harness.tick(ext); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(ext.exportSigGateStarted_); + BEAST_EXPECT(!harness.position.exportSigSetHash); + BEAST_EXPECT(ext.fetchedExportSets.size() == 1); + BEAST_EXPECT(ext.fetchedExportSets.front() == peerHash); + + result = harness.tick( + ext, + harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1}); + BEAST_EXPECT(result.readyForAccept); + BEAST_EXPECT(ext.exportSigConvergenceFailed_); + } + + void + testExportSigGateSkipsWhenExportDisabled() + { + testcase("Export sig gate skips when Export disabled"); + + FakeExtensions ext; + ext.exportOn = false; + ExportTickHarness harness; + + harness.addPeer(1, ext.exportHash); + + auto result = harness.tick(ext); + BEAST_EXPECT(result.readyForAccept); + BEAST_EXPECT(!ext.exportSigGateStarted_); + BEAST_EXPECT(!harness.position.exportSigSetHash); + BEAST_EXPECT(ext.exportBuilds == 0); + BEAST_EXPECT(ext.fetchedExportSets.empty()); + } + public: void run() override { testActiveValidatorViewAppliesNegativeUNL(); + testExportSigGateRequiresQuorumAlignment(); + testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict(); + testExportSigGateFetchesAdvertisedPeerSets(); + testExportSigGateSkipsWhenExportDisabled(); } }; diff --git a/src/test/consensus/ConsensusRng_test.cpp b/src/test/consensus/ConsensusRng_test.cpp index fce7b0034..d6f84d89f 100644 --- a/src/test/consensus/ConsensusRng_test.cpp +++ b/src/test/consensus/ConsensusRng_test.cpp @@ -937,5 +937,180 @@ public: }; BEAST_DEFINE_TESTSUITE(ConsensusRng, consensus, ripple); + +class ConsensusExport_test : public beast::unit_test::suite +{ + SuiteJournal journal_; + +public: + ConsensusExport_test() : journal_("ConsensusExport_test", *this) + { + } + + void + testExportOnlySteadyStateSucceeds() + { + using namespace csf; + using namespace std::chrono; + + testcase("Export-only sig set converges"); + + ConsensusParms const parms{}; + Sim sim; + PeerGroup peers = sim.createGroup(5); + + for (Peer* peer : peers) + peer->ce().enableExportConsensus_ = true; + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + sim.run(2); + + BEAST_EXPECT(sim.synchronized(peers)); + for (Peer const* peer : peers) + { + BEAST_EXPECT(peer->ce().lastExportSucceeded_); + BEAST_EXPECT(!peer->ce().lastExportRetried_); + } + } + + void + testExportOnlyRequiresUnanimousAlignment() + { + using namespace csf; + using namespace std::chrono; + + testcase("Export-only sig set requires unanimous alignment"); + + ConsensusParms const parms{}; + Sim sim; + PeerGroup peers = sim.createGroup(5); + + for (Peer* peer : peers) + peer->ce().enableExportConsensus_ = true; + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + peers[0]->ce().forcedExportSigSetHash_ = + sha512Half(std::string("forced-export-only")); + + sim.run(3); + + BEAST_EXPECT(sim.branches(peers) == 1); + for (Peer const* peer : peers) + { + BEAST_EXPECT(!peer->ce().lastExportSucceeded_); + BEAST_EXPECT(peer->ce().lastExportRetried_); + } + } + + void + testExportSigSetQuorumAlignmentIgnoresMinorityConflict() + { + using namespace csf; + using namespace std::chrono; + + testcase("Export sig set quorum ignores minority conflict"); + + ConsensusParms const parms{}; + Sim sim; + PeerGroup peers = sim.createGroup(5); + + for (Peer* peer : peers) + { + peer->ce().enableRngConsensus_ = true; + peer->ce().enableExportConsensus_ = true; + } + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + // Warmup: populate prevProposers so the RNG path does not bootstrap + // skip the extension tick scenario. + sim.run(1); + BEAST_EXPECT(sim.synchronized(peers)); + + peers[0]->ce().forcedExportSigSetHash_ = + sha512Half(std::string("forced-export-minority")); + + sim.run(3); + + PeerGroup honest{ + std::vector{peers[1], peers[2], peers[3], peers[4]}}; + BEAST_EXPECT(sim.branches(honest) == 1); + BEAST_EXPECT(sim.synchronized(honest)); + + for (Peer const* peer : honest) + { + BEAST_EXPECT(peer->ce().lastExportSucceeded_); + BEAST_EXPECT(!peer->ce().lastExportRetried_); + } + BEAST_EXPECT(!peers[0]->ce().lastExportSucceeded_); + } + + void + testExportSigSetConflictWithoutQuorumRetries() + { + using namespace csf; + using namespace std::chrono; + + testcase("Export sig set conflict without quorum retries"); + + ConsensusParms const parms{}; + Sim sim; + PeerGroup peers = sim.createGroup(5); + + for (Peer* peer : peers) + { + peer->ce().enableRngConsensus_ = true; + peer->ce().enableExportConsensus_ = true; + } + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + sim.run(1); + BEAST_EXPECT(sim.synchronized(peers)); + + peers[0]->ce().forcedExportSigSetHash_ = + sha512Half(std::string("forced-export-conflict-a")); + peers[1]->ce().forcedExportSigSetHash_ = + sha512Half(std::string("forced-export-conflict-b")); + + sim.run(3); + + BEAST_EXPECT(sim.branches(peers) == 1); + for (Peer const* peer : peers) + { + BEAST_EXPECT(!peer->ce().lastExportSucceeded_); + BEAST_EXPECT(peer->ce().lastExportRetried_); + } + } + + void + run() override + { + auto const* filter = std::getenv("XAHAU_EXPORT_TEST"); + std::string f = filter ? filter : ""; + +#define RUN(method) \ + do \ + { \ + if (f.empty() || std::string(#method).find(f) != std::string::npos) \ + method(); \ + } while (false) + + RUN(testExportOnlySteadyStateSucceeds); + RUN(testExportOnlyRequiresUnanimousAlignment); + RUN(testExportSigSetQuorumAlignmentIgnoresMinorityConflict); + RUN(testExportSigSetConflictWithoutQuorumRetries); + +#undef RUN + } +}; + +BEAST_DEFINE_TESTSUITE(ConsensusExport, consensus, ripple); } // namespace test } // namespace ripple diff --git a/src/test/csf/Peer.h b/src/test/csf/Peer.h index 7c3d43647..ab86d4d10 100644 --- a/src/test/csf/Peer.h +++ b/src/test/csf/Peer.h @@ -63,7 +63,7 @@ namespace bc = boost::container; /// into the correct local set without content-sniffing heuristics. struct SidecarStore { - enum class Type { commit, reveal }; + enum class Type { commit, reveal, exportSig }; using EntrySet = hash_map; @@ -328,13 +328,18 @@ struct Peer bool explicitFinalProposalSent_{false}; bool entropySetPublished_{false}; std::chrono::steady_clock::time_point entropyPublishStart_{}; + bool exportSigGateStarted_{false}; + std::chrono::steady_clock::time_point exportSigGateStart_{}; + bool exportSigConvergenceFailed_{false}; // RNG state bool enableRngConsensus_ = false; + bool enableExportConsensus_ = false; hash_set unlNodes_; hash_set likelyParticipants_; hash_map pendingCommits_; hash_map pendingReveals_; + hash_map pendingExportSigs_; hash_map nodeKeys_; uint256 myEntropySecret_; bool entropyFailed_ = false; @@ -343,15 +348,21 @@ struct Peer uint256 lastEntropyDigest_; std::uint16_t lastEntropyCount_ = 0; bool lastEntropyWasFallback_ = true; + bool lastExportSucceeded_ = false; + bool lastExportRetried_ = false; // Optional test hook: force a specific commit-set hash std::optional forcedCommitSetHash_; // Optional test hook: force a specific entropy-set hash std::optional forcedEntropySetHash_; + // Optional test hook: force a specific export sig-set hash + std::optional forcedExportSigSetHash_; // Optional test hook: drop reveals from specific peers // (simulates asymmetric reveal delivery / packet loss) hash_set dropRevealFrom_; + // Optional test hook: drop proposal-carried export signatures. + hash_set dropExportSigFrom_; explicit Extensions(Peer& p) : peer(p), j_(p.j) { @@ -365,6 +376,12 @@ struct Peer return enableRngConsensus_; } + bool + exportEnabled() const + { + return enableExportConsensus_; + } + std::size_t quorumThreshold() const { @@ -374,6 +391,16 @@ struct Peer return calculateQuorumThreshold(base == 0 ? 1 : base); } + std::size_t + exportSigQuorumThreshold() const + { + if (!enableExportConsensus_) + return (std::numeric_limits::max)() / 4; + auto const base = + unlNodes_.empty() ? std::size_t{1} : unlNodes_.size(); + return enableRngConsensus_ ? calculateQuorumThreshold(base) : base; + } + std::size_t pendingCommitCount() const { @@ -450,6 +477,17 @@ struct Peer return hash; } + uint256 + buildExportSigSet(Ledger::Seq seq) + { + if (forcedExportSigSetHash_) + return *forcedExportSigSetHash_; + auto const hash = hashRngSet(pendingExportSigs_, seq, "export-sig"); + peer.sidecarStore.publish( + hash, SidecarStore::Type::exportSig, pendingExportSigs_); + return hash; + } + void generateEntropySecret() { @@ -505,17 +543,28 @@ struct Peer if (!fetched) return; // Union merge into the correct local set based on type. - auto& target = (fetched->type == SidecarStore::Type::commit) - ? pendingCommits_ - : pendingReveals_; + auto& target = [&]() -> hash_map& { + switch (fetched->type) + { + case SidecarStore::Type::commit: + return pendingCommits_; + case SidecarStore::Type::reveal: + return pendingReveals_; + case SidecarStore::Type::exportSig: + return pendingExportSigs_; + } + return pendingCommits_; + }(); for (auto const& [nodeId, digest] : fetched->entries) target.emplace(nodeId, digest); } void - fetchSidecarsIfNeeded(ProposalPosition const&) + fetchSidecarsIfNeeded(ProposalPosition const& pos) { - // CSF does not model SHAMap acquisition + fetchRngSetIfNeeded(pos.commitSetHash, SidecarKind::commit); + fetchRngSetIfNeeded(pos.entropySetHash, SidecarKind::reveal); + fetchRngSetIfNeeded(pos.exportSigSetHash, SidecarKind::exportSig); } void @@ -523,10 +572,14 @@ struct Peer { pendingCommits_.clear(); pendingReveals_.clear(); + pendingExportSigs_.clear(); nodeKeys_.clear(); likelyParticipants_.clear(); myEntropySecret_.zero(); entropyFailed_ = false; + exportSigGateStarted_ = false; + exportSigGateStart_ = {}; + exportSigConvergenceFailed_ = false; } void @@ -577,14 +630,14 @@ struct Peer Ledger::ID const& prevLedger, std::uint64_t) { - if (!enableRngConsensus_) + if (!enableRngConsensus_ && !enableExportConsensus_) return; if (!isUNLReportMember(nodeId)) return; nodeKeys_.insert_or_assign(nodeId, publicKey); - if (position.myCommitment) + if (enableRngConsensus_ && position.myCommitment) { auto [it, inserted] = pendingCommits_.emplace(nodeId, *position.myCommitment); @@ -595,32 +648,40 @@ struct Peer } } - if (!position.myReveal) + if (!enableRngConsensus_ || !position.myReveal) + { + if (enableExportConsensus_ && position.myExportSignature && + dropExportSigFrom_.count(nodeId) == 0) + pendingExportSigs_[nodeId] = *position.myExportSignature; return; + } // Test hook: drop reveals from specific peers - if (dropRevealFrom_.count(nodeId) > 0) - return; + if (dropRevealFrom_.count(nodeId) == 0) + { + auto const commitIt = pendingCommits_.find(nodeId); + if (commitIt != pendingCommits_.end()) + { + auto const prevIt = peer.ledgers.find(prevLedger); + if (prevIt != peer.ledgers.end()) + { + auto const seq = + static_cast(prevIt->second.seq()) + + 1; + auto const expected = sha512Half( + *position.myReveal, + static_cast(publicKey.first), + publicKey.second, + seq); + if (expected == commitIt->second) + pendingReveals_[nodeId] = *position.myReveal; + } + } + } - auto const commitIt = pendingCommits_.find(nodeId); - if (commitIt == pendingCommits_.end()) - return; - - auto const prevIt = peer.ledgers.find(prevLedger); - if (prevIt == peer.ledgers.end()) - return; - - auto const seq = - static_cast(prevIt->second.seq()) + 1; - auto const expected = sha512Half( - *position.myReveal, - static_cast(publicKey.first), - publicKey.second, - seq); - if (expected != commitIt->second) - return; - - pendingReveals_[nodeId] = *position.myReveal; + if (enableExportConsensus_ && position.myExportSignature && + dropExportSigFrom_.count(nodeId) == 0) + pendingExportSigs_[nodeId] = *position.myExportSignature; } bool @@ -692,6 +753,28 @@ struct Peer lastEntropyWasFallback_ = false; } + void + finalizeRoundExport() + { + if (!enableExportConsensus_) + { + lastExportSucceeded_ = false; + lastExportRetried_ = false; + return; + } + + auto const activeSigCount = std::count_if( + pendingExportSigs_.begin(), + pendingExportSigs_.end(), + [&](auto const& entry) { + return isUNLReportMember(entry.first); + }); + lastExportSucceeded_ = !exportSigConvergenceFailed_ && + static_cast(activeSigCount) >= + exportSigQuorumThreshold(); + lastExportRetried_ = !lastExportSucceeded_; + } + // --- Lifecycle hooks (matching design doc) --- template @@ -738,6 +821,8 @@ struct Peer Ledger_t const& prevLedger, bool proposing) { + decorateExportPosition(pos, prevLedger, proposing); + if (!enableRngConsensus_ || !proposing || !peer.runAsValidator) return; generateEntropySecret(); @@ -752,6 +837,27 @@ struct Peer nodeKeys_.insert_or_assign(peer.id, peer.key); } + template + void + decorateExportPosition( + ProposalPosition& pos, + Ledger_t const& prevLedger, + bool proposing) + { + if (!enableExportConsensus_ || !proposing || !peer.runAsValidator) + return; + + auto const seq = static_cast(prevLedger.seq()) + 1; + auto const sig = sha512Half( + std::string("csf-export-sig"), + static_cast(peer.id), + peer.key.second, + seq); + pos.myExportSignature = sig; + pendingExportSigs_[peer.id] = sig; + nodeKeys_.insert_or_assign(peer.id, peer.key); + } + void appendJson(Json::Value&) const { @@ -782,22 +888,25 @@ struct Peer { return std::nullopt; } - uint256 - buildExportSigSet(Ledger::Seq) - { - return uint256{}; - } bool hasPendingExportSigs() const { - return false; + return enableExportConsensus_ && !pendingExportSigs_.empty(); + } + void + setExportSigConvergenceFailed() + { + if (enableExportConsensus_) + exportSigConvergenceFailed_ = true; } // --- Sub-state accessors --- bool extensionsBusy() const { - return estState_ != EstablishState::ConvergingTx; + return estState_ != EstablishState::ConvergingTx || + (exportEnabled() && + (exportSigGateStarted_ || hasPendingExportSigs())); } EstablishState estState() const @@ -813,6 +922,9 @@ struct Peer explicitFinalProposalSent_ = false; entropySetPublished_ = false; entropyPublishStart_ = {}; + exportSigGateStarted_ = false; + exportSigGateStart_ = {}; + exportSigConvergenceFailed_ = false; } /// Defined in test/csf/PeerTick.h (keeps xrpld/app dependency @@ -1167,6 +1279,7 @@ struct Peer auto const seq = static_cast(prevLedger.seq()) + 1; ce().finalizeRoundEntropy(seq); + ce().finalizeRoundExport(); TxSet const acceptedTxs = injectTxs(prevLedger, result.txns); Ledger const newLedger = oracle.accept( diff --git a/src/test/csf/Proposal.h b/src/test/csf/Proposal.h index e4c5eecb3..f8e215179 100644 --- a/src/test/csf/Proposal.h +++ b/src/test/csf/Proposal.h @@ -43,8 +43,10 @@ struct RngPosition TxSet::ID txSetHash{}; std::optional commitSetHash; std::optional entropySetHash; + std::optional exportSigSetHash; std::optional myCommitment; std::optional myReveal; + std::optional myExportSignature; RngPosition() = default; explicit RngPosition(TxSet::ID txSet) : txSetHash(txSet) @@ -125,8 +127,10 @@ hash_append(Hasher& h, RngPosition const& pos) hash_append(h, pos.txSetHash); appendOpt(pos.commitSetHash); appendOpt(pos.entropySetHash); + appendOpt(pos.exportSigSetHash); appendOpt(pos.myCommitment); appendOpt(pos.myReveal); + appendOpt(pos.myExportSignature); } /** Proposal is a position taken in the consensus process. diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index fcd24791a..e326aa427 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -134,6 +134,19 @@ ConsensusExtensions::quorumThreshold() const return calculateQuorumThreshold(base); } +std::size_t +ConsensusExtensions::exportSigQuorumThreshold() const +{ + auto const base = activeValidatorView()->size(); + if (base == 0) + return 1; + + // Export can operate without ConsensusEntropy. In that mode it uses the + // original unanimity rule, but still relies on the same sidecar alignment + // gate so all nodes make the same accept-time decision. + return rngEnabled() ? calculateQuorumThreshold(base) : base; +} + void ConsensusExtensions::setExpectedProposers(hash_set proposers) { @@ -287,6 +300,12 @@ ConsensusExtensions::rngEnabled() const return rngEnabledThisRound_; } +bool +ConsensusExtensions::exportEnabled() const +{ + return exportEnabledThisRound_; +} + bool ConsensusExtensions::bootstrapFastStartEnabled() const { @@ -607,6 +626,18 @@ ConsensusExtensions::hasPendingExportSigs() const return !allSigs.empty(); } +void +ConsensusExtensions::setExportSigConvergenceFailed() +{ + exportSigConvergenceFailed_ = true; +} + +bool +ConsensusExtensions::exportSigConvergenceFailed() const +{ + return exportSigConvergenceFailed_; +} + void ConsensusExtensions::generateEntropySecret() { @@ -658,14 +689,17 @@ ConsensusExtensions::clearRngState() exportSigSetMap_.reset(); rngRoundSeq_.reset(); pendingRngFetches_.clear(); + exportSigGateStarted_ = false; + exportSigGateStart_ = {}; + exportSigConvergenceFailed_ = false; likelyParticipants_.clear(); commitProofs_.clear(); proposalProofs_.clear(); //@@end round-stop-rng-reset - // Keep the round-level enable latch intact here. Consensus::startRound() - // calls preStartRound() first to snapshot whether RNG is enabled for the - // upcoming round, then immediately clears per-round working state. - // Resetting rngEnabledThisRound_ here would wipe that snapshot before + // Keep the round-level enable latches intact here. Consensus::startRound() + // calls preStartRound() first to snapshot which extensions are enabled for + // the upcoming round, then immediately clears per-round working state. + // Resetting these latches here would wipe that snapshot before // phaseEstablish() can consult it. } //@@end clear-rng-state diff --git a/src/xrpld/app/consensus/ConsensusExtensions.h b/src/xrpld/app/consensus/ConsensusExtensions.h index 43a25b72d..6d6439aa9 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.h +++ b/src/xrpld/app/consensus/ConsensusExtensions.h @@ -95,6 +95,7 @@ private: uint256 myEntropySecret_; bool entropyFailed_ = false; bool rngEnabledThisRound_ = false; + bool exportEnabledThisRound_ = false; // Real SHAMaps for the current round (unbacked, ephemeral) std::shared_ptr commitSetMap_; @@ -126,6 +127,9 @@ public: bool explicitFinalProposalSent_{false}; bool entropySetPublished_{false}; std::chrono::steady_clock::time_point entropyPublishStart_{}; + bool exportSigGateStarted_{false}; + std::chrono::steady_clock::time_point exportSigGateStart_{}; + bool exportSigConvergenceFailed_{false}; /** 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. */ @@ -172,6 +176,9 @@ public: std::size_t quorumThreshold() const; + std::size_t + exportSigQuorumThreshold() const; + void setExpectedProposers(hash_set proposers); @@ -199,6 +206,9 @@ public: bool rngEnabled() const; + bool + exportEnabled() const; + bool bootstrapFastStartEnabled() const; @@ -220,6 +230,12 @@ public: bool hasPendingExportSigs() const; + void + setExportSigConvergenceFailed(); + + bool + exportSigConvergenceFailed() const; + bool isSidecarSet(uint256 const& hash) const; @@ -378,10 +394,18 @@ public: rngEnabledThisRound_ = v; } + void + setExportEnabledThisRound(bool v) + { + exportEnabledThisRound_ = v; + } + bool extensionsBusy() const { - return estState_ != EstablishState::ConvergingTx; + return estState_ != EstablishState::ConvergingTx || + (exportEnabled() && + (exportSigGateStarted_ || hasPendingExportSigs())); } EstablishState @@ -399,6 +423,9 @@ public: explicitFinalProposalSent_ = false; entropySetPublished_ = false; entropyPublishStart_ = {}; + exportSigGateStarted_ = false; + exportSigGateStart_ = {}; + exportSigConvergenceFailed_ = false; } }; diff --git a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md index 6cb33ad58..cf8292e11 100644 --- a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md +++ b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md @@ -157,23 +157,40 @@ conflicts and produce asymmetric zero/non-zero outcomes. gated. Export can run without ConsensusEntropy, but then it uses a conservative -ephemeral mode. With ConsensusEntropy active, export can use the shared -`ExtendedPosition` and sidecar convergence machinery to converge a verified -export signature set and use the normal 80% threshold. +ephemeral mode: verified export signature sidecars still converge through +`ExtendedPosition`, but success requires unanimity of the active validator +view. With ConsensusEntropy active, the same sidecar machinery uses the normal +80% threshold. The extended proposal machinery is enabled when either feature needs signed sidecar fields. Do not make Export depend on RNG availability just because RNG was the first consumer of `ExtendedPosition`. +When `featureExport` is disabled, the export sidecar gate is disabled too. Stale +collector entries must not keep a stopped amendment active. + Only verified export signatures count toward quorum or enter export sidecar SHAMaps. Proposal-ingress signatures are sender-bound to the trusted proposal validator and may be stored as unverified until the matching export transaction is available for cryptographic verification. +Export success requires quorum alignment on `exportSigSetHash`, not merely a +local collector quorum. If the verified signature set cannot align by the +bounded deadline, the export retries or expires according to normal transaction +rules. + +Closed-ledger apply must not promote unverified proposal-carried signatures into +current-round quorum material. It may verify and retain them for a future retry, +where they can be published in a sidecar set and converged before use. + Export sig convergence runs in parallel with RNG. An export-side convergence failure must not change RNG semantics; an RNG fallback must not make export unsafe. Each feature has its own gate and fallback. +CSF consensus tests model the export sidecar gate directly. Testnet scenarios +under `.testnet/scenarios/export/` cover live-node Export+CE behavior and the +Export-only unanimity mode. + ## Review Checklist When changing consensus extension code, check these questions: @@ -189,4 +206,6 @@ When changing consensus extension code, check these questions: - Are proposal-visible or validation-visible sidecar fields covered by the relevant signature and duplicate/replay identity? - Are export signatures verified before they count? +- Does export success require `exportSigSetHash` alignment, not just local + collector quorum? - Are CE and Export still independently gated and independently stoppable? diff --git a/src/xrpld/app/consensus/RCLConsensus.cpp b/src/xrpld/app/consensus/RCLConsensus.cpp index e62ca31e4..064e36aa7 100644 --- a/src/xrpld/app/consensus/RCLConsensus.cpp +++ b/src/xrpld/app/consensus/RCLConsensus.cpp @@ -1083,9 +1083,12 @@ RCLConsensus::Adaptor::preStartRound( { ce().setRngEnabledThisRound( prevLgr.ledger_->rules().enabled(featureConsensusEntropy)); + ce().setExportEnabledThisRound( + prevLgr.ledger_->rules().enabled(featureExport)); JLOG(j_.trace()) << "RNGGATE: preStartRound prevSeq=" << prevLgr.seq() - << " rulesEnabled=" << ce().rngEnabled(); + << " rulesEnabled=" << ce().rngEnabled() + << " exportEnabled=" << ce().exportEnabled(); // We have a key, we do not want out of sync validations after a restart // and are not amendment blocked. diff --git a/src/xrpld/app/tx/detail/Export.cpp b/src/xrpld/app/tx/detail/Export.cpp index d253cfede..32c4bed55 100644 --- a/src/xrpld/app/tx/detail/Export.cpp +++ b/src/xrpld/app/tx/detail/Export.cpp @@ -142,13 +142,13 @@ Export::doApply() STTx innerTx(std::ref(sit)); - // Upgrade pass: verify any unverified sigs in the collector. - // We always have the inner tx here (it's ctx_.tx), so we can - // verify sigs that couldn't be checked at proposal ingestion - // time due to relay ordering. This upgrades them to verified - // so they count toward quorum. - if (!ctx_.app.config().standalone()) - { + auto upgradeUnverifiedForNextRound = [&]() { + if (ctx_.app.config().standalone()) + return; + + // Closed-ledger apply must not create new current-round quorum + // material. These upgrades are retained for a retrying export, where + // the sidecar alignment gate can publish and converge them first. auto& collector = consensusExtensions.exportSigCollector(); auto const unverified = collector.unverifiedSignatures(txId); for (auto const& [valPK, sigBuf] : unverified) @@ -173,7 +173,7 @@ Export::doApply() << txId << " — removing invalid sig"; } } - } + }; // Atomic quorum check + snapshot for network mode. // Only verified signatures count toward quorum and appear @@ -183,18 +183,22 @@ Export::doApply() if (!ctx_.app.config().standalone()) { std::size_t threshold; + bool const ceEnabled = view().rules().enabled(featureConsensusEntropy); if (unlSize == 0) threshold = 1; - else if (view().rules().enabled(featureConsensusEntropy)) + else if (ceEnabled) threshold = calculateQuorumThreshold(unlSize); else threshold = unlSize; // The collector may contain old trusted signatures; quorum counts only // signatures whose keys resolve into the same frozen active view. - collectedSigs = - consensusExtensions.exportSigCollector().checkQuorumAndSnapshot( - txId, threshold, isActiveSigner); + if (!consensusExtensions.exportSigConvergenceFailed()) + { + collectedSigs = + consensusExtensions.exportSigCollector().checkQuorumAndSnapshot( + txId, threshold, isActiveSigner); + } if (!collectedSigs) { @@ -231,10 +235,15 @@ Export::doApply() } } + upgradeUnverifiedForNextRound(); + JLOG(j_.info()) << "Export: not enough sigs at ledger " << currentSeq << " sigs=" << sigCount << " threshold=" << threshold - << " unlSize=" << unlSize << " -> terRETRY_EXPORT"; + << " unlSize=" << unlSize << " exportSigConvergenceFailed=" + << (consensusExtensions.exportSigConvergenceFailed() ? "yes" + : "no") + << " -> terRETRY_EXPORT"; return terRETRY_EXPORT; } } diff --git a/src/xrpld/consensus/ConsensusExtensionsTick.h b/src/xrpld/consensus/ConsensusExtensionsTick.h index 459411e40..36fff1d2b 100644 --- a/src/xrpld/consensus/ConsensusExtensionsTick.h +++ b/src/xrpld/consensus/ConsensusExtensionsTick.h @@ -879,78 +879,211 @@ extensionsTick(Ext& ext, Ctx const& ctx) //@@end rng-phase-establish-substates //@@start export-sig-convergence-gate - // Export sig convergence gate: runs after RNG sub-states, only when - // both CE and Export are enabled. Builds/publishes exportSigSetHash - // and waits for peer agreement before accepting. + // Export sig convergence gate: runs after RNG sub-states when Export has + // verified signatures to converge, or when a tx-converged peer advertises + // an exportSigSetHash we may need to fetch. Builds/publishes + // exportSigSetHash and waits for quorum peer agreement before accepting. if constexpr (requires { ctx.getPosition().exportSigSetHash; }) { - // Only run when CE is active (provides ExtendedPosition infra) - // and there are export sigs to converge. - if (isRngEnabled) - { - if (ext.hasPendingExportSigs()) + if (!ext.exportEnabled()) + return {.readyForAccept = true}; + + auto startExportSigGate = [&]() -> bool { + if (ext.exportSigGateStarted_) + return false; + ext.exportSigGateStarted_ = true; + ext.exportSigGateStart_ = ctx.nowSteady; + return true; + }; + + auto fetchPeerExportSigSets = [&](auto const& pos) { + std::size_t peerSets = 0; + for (auto const& [_, peerPos] : ctx.peerPositions) { - //@@start export-publish-sigset-hash - auto const buildSeqExport = ctx.buildSeq; - auto const exportHash = ext.buildExportSigSet(buildSeqExport); + auto const& pp = peerPos.proposal().position(); + if (!(pp == pos)) + continue; // not tx-converged + if (!pp.exportSigSetHash) + continue; - auto currentPos = ctx.getPosition(); - if (!currentPos.exportSigSetHash || - *currentPos.exportSigSetHash != exportHash) + ++peerSets; + ext.fetchRngSetIfNeeded( + pp.exportSigSetHash, Ext::SidecarKind::exportSig); + } + return peerSets; + }; + + bool hasLocalExportSigs = ext.hasPendingExportSigs(); + if (!hasLocalExportSigs) + { + auto const peerSets = fetchPeerExportSigSets(ctx.getPosition()); + if (peerSets > 0) + { + startExportSigGate(); + hasLocalExportSigs = ext.hasPendingExportSigs(); + if (!hasLocalExportSigs) { - currentPos.exportSigSetHash = exportHash; - ctx.updatePosition(currentPos); + auto const elapsed = + ctx.nowSteady - ext.exportSigGateStart_; + auto const deadline = ctx.parms.rngREVEAL_TIMEOUT * 2; + if (elapsed <= deadline) + { + JLOG(ext.j_.debug()) + << "Export: waiting for advertised exportSigSet " + "fetch/merge" + << " peerSets=" << peerSets; + return {}; + } - if (ctx.mode == ConsensusMode::proposing) - ctx.propose(); - - JLOG(ext.j_.debug()) - << "Export: published exportSigSetHash=" << exportHash; + ext.setExportSigConvergenceFailed(); + JLOG(ext.j_.warn()) + << "Export: advertised exportSigSet did not converge " + "locally within deadline; exports will retry or " + "expire" + << " peerSets=" << peerSets; } - //@@end export-publish-sigset-hash + } + } - //@@start export-sigset-conflict-wait - // Check peer agreement on exportSigSetHash. - // If any tx-converged peer has a different non-empty hash, - // wait briefly for fetch/merge to resolve it. + if (hasLocalExportSigs) + { + //@@start export-publish-sigset-hash + auto const buildSeqExport = ctx.buildSeq; + auto const exportHash = ext.buildExportSigSet(buildSeqExport); + + auto currentPos = ctx.getPosition(); + bool const publishedNewHash = !currentPos.exportSigSetHash || + *currentPos.exportSigSetHash != exportHash; + if (publishedNewHash) + { + currentPos.exportSigSetHash = exportHash; + ctx.updatePosition(currentPos); + + if (ctx.mode == ConsensusMode::proposing) + ctx.propose(); + + JLOG(ext.j_.debug()) + << "Export: published exportSigSetHash=" << exportHash; + } + //@@end export-publish-sigset-hash + + //@@start export-sigset-conflict-wait + // Check quorum agreement on exportSigSetHash. Like RNG entropy, + // Export success is an accept-time derived effect outside tx-set + // equality. A local-only quorum must not succeed unless enough + // tx-converged peers advertise the same export sig sidecar hash. + { + if (startExportSigGate() || publishedNewHash) + { + JLOG(ext.j_.debug()) + << "Export: exportSigSet published, waiting for peer " + "observation"; + return {}; + } + + struct ExportPeerState { bool conflict = false; + std::size_t aligned = 0; + std::size_t peersSeen = 0; + }; + + auto inspectExportPeers = + [&](auto const& pos, + bool fetchMismatches) -> ExportPeerState { + ExportPeerState state; + if (!pos.exportSigSetHash) + return state; + for (auto const& [_, peerPos] : ctx.peerPositions) { auto const& pp = peerPos.proposal().position(); + if (!(pp == pos)) + continue; // not tx-converged if (!pp.exportSigSetHash) - continue; - if (*pp.exportSigSetHash != exportHash) + continue; // peer hasn't published yet + ++state.peersSeen; + if (*pp.exportSigSetHash == *pos.exportSigSetHash) { - conflict = true; + ++state.aligned; + continue; + } - // Trigger fetch for the differing set + state.conflict = true; + if (fetchMismatches) ext.fetchRngSetIfNeeded( pp.exportSigSetHash, Ext::SidecarKind::exportSig); - break; - } + } + return state; + }; + + auto exportState = inspectExportPeers(ctx.getPosition(), true); + auto const exportQuorum = ext.exportSigQuorumThreshold(); + auto quorumAligned = [&] { + return exportState.aligned + 1 >= exportQuorum; + }; + + if (exportState.conflict && !quorumAligned()) + { + auto const refreshedHash = + ext.buildExportSigSet(buildSeqExport); + auto current = ctx.getPosition(); + if (!current.exportSigSetHash || + *current.exportSigSetHash != refreshedHash) + { + current.exportSigSetHash = refreshedHash; + ctx.updatePosition(current); + if (ctx.mode == ConsensusMode::proposing) + ctx.propose(); + JLOG(ext.j_.debug()) + << "Export: refreshed exportSigSetHash after merge " + "to " + << refreshedHash; } - if (conflict) - { - // Don't block indefinitely — use the same pipeline - // timeout as RNG. - bool const timeout = - ctx.roundTime > ctx.parms.rngPIPELINE_TIMEOUT; - if (!timeout) - { - JLOG(ext.j_.debug()) - << "Export: exportSigSetHash conflict, waiting"; - return {}; - } - JLOG(ext.j_.info()) - << "Export: exportSigSetHash conflict timed out, " - "proceeding (exports will retry next round)"; - } + exportState = inspectExportPeers(ctx.getPosition(), true); + } + + if (exportState.conflict && quorumAligned()) + { + JLOG(ext.j_.info()) + << "Export: exportSigSetHash conflict ignored after " + "quorum alignment" + << " alignedParticipants=" << (exportState.aligned + 1) + << " quorum=" << exportQuorum; + } + else if (exportState.conflict || !quorumAligned()) + { + auto const elapsed = + ctx.nowSteady - ext.exportSigGateStart_; + auto const deadline = ctx.parms.rngREVEAL_TIMEOUT * 2; + if (elapsed <= deadline) + { + JLOG(ext.j_.debug()) + << "Export: waiting for exportSigSet quorum " + "alignment" + << " alignedParticipants=" + << (exportState.aligned + 1) + << " quorum=" << exportQuorum + << " peersSeen=" << exportState.peersSeen + << " conflict=" + << (exportState.conflict ? "yes" : "no"); + return {}; + } + + ext.setExportSigConvergenceFailed(); + JLOG(ext.j_.warn()) + << "Export: exportSigSet quorum alignment missing " + "within deadline; exports will retry or expire" + << " alignedParticipants=" << (exportState.aligned + 1) + << " quorum=" << exportQuorum + << " peersSeen=" << exportState.peersSeen + << " conflict=" + << (exportState.conflict ? "yes" : "no"); } - //@@end export-sigset-conflict-wait } + //@@end export-sigset-conflict-wait } } //@@end export-sig-convergence-gate