From a3b1e45f4d9b83fce75cd2b399ca6a9171dace73 Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Tue, 28 Apr 2026 11:15:03 +0700 Subject: [PATCH] fix(consensus): bound export sidecar observation When a candidate set contains ttEXPORT but a node has no local verified export sig material yet, give tx-converged peers one bounded opportunity to advertise an exportSigSetHash before closed-ledger apply. This is a safety coordination window, not a wait-for-Export-success mechanism. If no advertised sidecar arrives or fetched material cannot be merged by the deadline, Export convergence is marked failed and the transaction retries or expires through normal rules. Add CSF coverage for a peer that can only succeed by fetching peer-advertised export sidecars, plus a direct ConsensusExtensionsTick test for the pre-advertisement observation window. Document the consensus-extension priority order: safe, fast, works. --- .../consensus/ConsensusExtensions_test.cpp | 36 ++++++++++++++++ src/test/consensus/ConsensusRng_test.cpp | 42 +++++++++++++++++++ src/test/csf/Peer.h | 22 ++++++++-- .../app/consensus/ConsensusExtensions.cpp | 6 +++ src/xrpld/app/consensus/ConsensusExtensions.h | 3 ++ .../consensus/ConsensusExtensionsDesign.md | 16 +++++++ src/xrpld/consensus/ConsensusExtensionsTick.h | 33 ++++++++++++--- 7 files changed, 150 insertions(+), 8 deletions(-) diff --git a/src/test/consensus/ConsensusExtensions_test.cpp b/src/test/consensus/ConsensusExtensions_test.cpp index e1575ceae..d88b955d0 100644 --- a/src/test/consensus/ConsensusExtensions_test.cpp +++ b/src/test/consensus/ConsensusExtensions_test.cpp @@ -114,6 +114,7 @@ struct FakeExtensions std::chrono::steady_clock::time_point exportSigGateStart_{}; bool exportSigConvergenceFailed_{false}; bool localExportSigs{true}; + bool consensusExportTxns{false}; bool exportOn{true}; std::size_t exportQuorum{4}; uint256 exportHash{makeHash("local-export-sig-set")}; @@ -233,6 +234,12 @@ struct FakeExtensions return localExportSigs; } + bool + hasConsensusExportTxns() const + { + return consensusExportTxns; + } + uint256 buildExportSigSet(LedgerIndex) { @@ -462,6 +469,34 @@ class ConsensusExtensions_test : public beast::unit_test::suite BEAST_EXPECT(ext.exportSigConvergenceFailed_); } + void + testExportSigGateBoundsCandidateObservationWindow() + { + testcase("Export sig gate bounds candidate observation window"); + + FakeExtensions ext; + ext.localExportSigs = false; + ext.consensusExportTxns = true; + ExportTickHarness harness; + + auto result = harness.tick(ext); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(ext.exportSigGateStarted_); + BEAST_EXPECT(!harness.position.exportSigSetHash); + BEAST_EXPECT(ext.fetchedExportSets.empty()); + BEAST_EXPECT(!ext.exportSigConvergenceFailed_); + + 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 testExportSigGateSkipsWhenExportDisabled() { @@ -558,6 +593,7 @@ public: testExportSigGateRequiresQuorumAlignment(); testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict(); testExportSigGateFetchesAdvertisedPeerSets(); + testExportSigGateBoundsCandidateObservationWindow(); testExportSigGateSkipsWhenExportDisabled(); testExportDisabledRoundClearsCollector(); testReplayedProposalHarvestsExportSigs(); diff --git a/src/test/consensus/ConsensusRng_test.cpp b/src/test/consensus/ConsensusRng_test.cpp index 006c64ed1..d822b0423 100644 --- a/src/test/consensus/ConsensusRng_test.cpp +++ b/src/test/consensus/ConsensusRng_test.cpp @@ -1011,6 +1011,47 @@ public: BEAST_EXPECT(!peers[0]->ce().lastExportSucceeded_); } + void + testExportOnlyFetchesPeerAdvertisedSigSet() + { + using namespace csf; + using namespace std::chrono; + + testcase("Export-only fetches peer-advertised sig set"); + + ConsensusParms const parms{}; + Sim sim; + PeerGroup peers = sim.createGroup(5); + + for (Peer* peer : peers) + peer->ce().enableExportConsensus_ = true; + + // Peer 0 remains an active validator/proposer, but starts with no + // local export signature material. It drops proposal-carried export + // signatures from peers, so only sidecar fetch/merge can populate its + // local export set. + peers[0]->ce().suppressOwnExportSig_ = true; + for (std::size_t i = 1; i < peers.size(); ++i) + peers[0]->ce().dropExportSigFrom_.insert(peers[i]->id); + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + sim.run(3); + + BEAST_EXPECT(sim.branches(peers) == 1); + BEAST_EXPECT(sim.synchronized(peers)); + BEAST_EXPECT(peers[0]->ce().exportSigFetchMerges_ >= 4); + BEAST_EXPECT(peers[0]->ce().lastExportSucceeded_); + BEAST_EXPECT(!peers[0]->ce().lastExportRetried_); + + for (std::size_t i = 1; i < peers.size(); ++i) + { + BEAST_EXPECT(peers[i]->ce().lastExportSucceeded_); + BEAST_EXPECT(!peers[i]->ce().lastExportRetried_); + } + } + void testExportSigSetQuorumAlignmentIgnoresMinorityConflict() { @@ -1109,6 +1150,7 @@ public: RUN(testExportOnlySteadyStateSucceeds); RUN(testExportOnlyQuorumIgnoresMinorityConflict); + RUN(testExportOnlyFetchesPeerAdvertisedSigSet); RUN(testExportSigSetQuorumAlignmentIgnoresMinorityConflict); RUN(testExportSigSetConflictWithoutQuorumRetries); diff --git a/src/test/csf/Peer.h b/src/test/csf/Peer.h index cd83aadf3..b6813d75d 100644 --- a/src/test/csf/Peer.h +++ b/src/test/csf/Peer.h @@ -350,6 +350,7 @@ struct Peer bool lastEntropyWasFallback_ = true; bool lastExportSucceeded_ = false; bool lastExportRetried_ = false; + std::size_t exportSigFetchMerges_ = 0; // Optional test hook: force a specific commit-set hash std::optional forcedCommitSetHash_; @@ -363,6 +364,9 @@ struct Peer hash_set dropRevealFrom_; // Optional test hook: drop proposal-carried export signatures. hash_set dropExportSigFrom_; + // Optional test hook: stay an active proposer but do not originate an + // export signature, so tests can force sidecar-fetch-only convergence. + bool suppressOwnExportSig_ = false; explicit Extensions(Peer& p) : peer(p), j_(p.j) { @@ -556,7 +560,11 @@ struct Peer return pendingCommits_; }(); for (auto const& [nodeId, digest] : fetched->entries) - target.emplace(nodeId, digest); + { + auto const [_, inserted] = target.emplace(nodeId, digest); + if (fetched->type == SidecarStore::Type::exportSig && inserted) + ++exportSigFetchMerges_; + } } void @@ -853,8 +861,11 @@ struct Peer static_cast(peer.id), peer.key.second, seq); - pos.myExportSignature = sig; - pendingExportSigs_[peer.id] = sig; + if (!suppressOwnExportSig_) + { + pos.myExportSignature = sig; + pendingExportSigs_[peer.id] = sig; + } nodeKeys_.insert_or_assign(peer.id, peer.key); } @@ -893,6 +904,11 @@ struct Peer { return enableExportConsensus_ && !pendingExportSigs_.empty(); } + bool + hasConsensusExportTxns() const + { + return enableExportConsensus_; + } void setExportSigConvergenceFailed() { diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index f6960681d..861d1f392 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -738,6 +738,12 @@ ConsensusExtensions::hasPendingExportSigs() const return false; } +bool +ConsensusExtensions::hasConsensusExportTxns() const +{ + return !consensusExportTxns_.empty(); +} + void ConsensusExtensions::setExportSigConvergenceFailed() { diff --git a/src/xrpld/app/consensus/ConsensusExtensions.h b/src/xrpld/app/consensus/ConsensusExtensions.h index 60306f1c3..3dce8a12d 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.h +++ b/src/xrpld/app/consensus/ConsensusExtensions.h @@ -235,6 +235,9 @@ public: bool hasPendingExportSigs() const; + bool + hasConsensusExportTxns() const; + void setExportSigConvergenceFailed(); diff --git a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md index 6e8c80a4d..30787a5a4 100644 --- a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md +++ b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md @@ -10,6 +10,14 @@ but it must not redefine ordinary transaction-set consensus. When extension state cannot be made safe in time, the extension degrades deterministically and the ledger still closes. +The priority order for consensus extensions is: safe, fast, works. Safety means +extension timing must not create divergent closed-ledger effects when a bounded +coordination step can avoid it. Fast means those coordination steps stay short +and conditional, never becoming an open-ended wait for an extension feature to +succeed. Works means missed or late extension material follows that feature's +deterministic fallback, such as zero entropy for RNG or normal Export +retry/expiry, rather than blocking core consensus. + ## Core Invariants 1. Core consensus remains keyed by the transaction set. @@ -188,6 +196,14 @@ against the candidate transaction, and promoted into `ExportSigCollector`. Closed-ledger apply snapshots that collector, so the sidecar convergence state and the signer set used by `ttEXPORT` stay on the same path. +If the consensus candidate contains a `ttEXPORT` but the node has no eligible +local export signatures yet, the export sidecar gate opens only a bounded +safety window for tx-converged peers to advertise `exportSigSetHash`. This is +not a wait-for-Export-success mechanism; it is a short opportunity to avoid +closing a minority ledger while sidecar convergence is already reachable. If no +advertised sidecar appears by the deadline, the gate stops waiting and the +export retries or expires through normal transaction rules. + Export success requires quorum alignment on `exportSigSetHash`, not merely a local collector quorum. If a quorum of tx-converged participants advertises the same export signature sidecar hash, that hash is aligned and below-quorum diff --git a/src/xrpld/consensus/ConsensusExtensionsTick.h b/src/xrpld/consensus/ConsensusExtensionsTick.h index 36fff1d2b..6d340519d 100644 --- a/src/xrpld/consensus/ConsensusExtensionsTick.h +++ b/src/xrpld/consensus/ConsensusExtensionsTick.h @@ -881,8 +881,8 @@ extensionsTick(Ext& ext, Ctx const& ctx) //@@start export-sig-convergence-gate // 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. + // an exportSigSetHash we may need to fetch. This is a bounded safety + // coordination window, not a wait-for-Export-success mechanism. if constexpr (requires { ctx.getPosition().exportSigSetHash; }) { if (!ext.exportEnabled()) @@ -929,7 +929,8 @@ extensionsTick(Ext& ext, Ctx const& ctx) if (elapsed <= deadline) { JLOG(ext.j_.debug()) - << "Export: waiting for advertised exportSigSet " + << "Export: bounded wait for advertised " + "exportSigSet " "fetch/merge" << " peerSets=" << peerSets; return {}; @@ -938,11 +939,33 @@ extensionsTick(Ext& ext, Ctx const& ctx) ext.setExportSigConvergenceFailed(); JLOG(ext.j_.warn()) << "Export: advertised exportSigSet did not converge " - "locally within deadline; exports will retry or " - "expire" + "locally within bounded safety window; exports " + "will retry or expire" << " peerSets=" << peerSets; } } + else if (ext.hasConsensusExportTxns()) + { + // A candidate ttEXPORT with no local sig material gets one + // short observation window so an already-reachable peer + // exportSigSetHash can be fetched before apply. If nothing + // appears in time, apply takes the retry/expire path. + startExportSigGate(); + auto const elapsed = ctx.nowSteady - ext.exportSigGateStart_; + auto const deadline = ctx.parms.rngREVEAL_TIMEOUT * 2; + if (elapsed <= deadline) + { + JLOG(ext.j_.debug()) + << "Export: bounded wait for exportSigSet " + "advertisement"; + return {}; + } + + ext.setExportSigConvergenceFailed(); + JLOG(ext.j_.warn()) + << "Export: no exportSigSet advertisement within bounded " + "safety window; exports will retry or expire"; + } } if (hasLocalExportSigs)