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)