From 4e313098a640d6a68cb5ffa2d14447412fea5797 Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Thu, 16 Jul 2026 23:09:16 +0700 Subject: [PATCH] fix(export): require anchored view for finalization --- .../consensus/ConsensusExtensions_test.cpp | 29 +++++++++++++++++++ .../app/consensus/ConsensusExtensions.cpp | 18 +++++++++++- src/xrpld/app/consensus/ConsensusExtensions.h | 6 ++++ src/xrpld/consensus/ConsensusExtensionsTick.h | 15 ++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/test/consensus/ConsensusExtensions_test.cpp b/src/test/consensus/ConsensusExtensions_test.cpp index 215289290..b9078ad6b 100644 --- a/src/test/consensus/ConsensusExtensions_test.cpp +++ b/src/test/consensus/ConsensusExtensions_test.cpp @@ -395,6 +395,7 @@ struct FakeExtensions bool localExportSigs{true}; bool livePendingExportLatches{false}; bool exportOn{true}; + bool exportViewAnchored{true}; bool entropyFailed{false}; bool commitFrozen{false}; std::size_t sidecarQuorum{4}; @@ -412,6 +413,7 @@ struct FakeExtensions std::deque entropyHashSequence; int commitBuilds = 0; int exportBuilds = 0; + int acceptedExportClears = 0; int entropyBuilds = 0; int participantDiagnostics = 0; int selfSeeds = 0; @@ -428,6 +430,12 @@ struct FakeExtensions return exportOn; } + bool + exportFinalizationViewAnchored() const + { + return exportViewAnchored; + } + bool testSuppressExportSigSetHash() const { @@ -611,6 +619,7 @@ struct FakeExtensions void clearAcceptedExportSigSet() { + ++acceptedExportClears; } template @@ -4096,6 +4105,25 @@ class ConsensusExtensions_test : public beast::unit_test::suite BEAST_EXPECT(ext.exportBuilds == 0); } + void + testExportSigGateSkipsWithoutAnchoredView() + { + testcase("Export sig gate skips without ledger-anchored view"); + + FakeExtensions ext; + ext.exportViewAnchored = false; + ExtensionTickHarness harness; + + harness.addPeer(1, ext.exportHash); + + auto const 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.acceptedExportClears == 1); + } + void testParticipantDiagnosticsOnlyWhenExtensionEnabled() { @@ -4886,6 +4914,7 @@ public: testExportSigGateRefreshesHashBeforeWaiting(); testExportSigGateBoundsCandidateObservationWindow(); testExportSigGateSkipsWhenExportDisabled(); + testExportSigGateSkipsWithoutAnchoredView(); testParticipantDiagnosticsOnlyWhenExtensionEnabled(); testExportDisabledRoundClearsCollector(); testValidatorKeylessAuthoringNoops(); diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index 84705860d..27ae1ddcc 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -1713,6 +1713,12 @@ ConsensusExtensions::exportEnabled() const return exportEnabledThisRound_.load(std::memory_order_relaxed); } +bool +ConsensusExtensions::exportFinalizationViewAnchored() const +{ + return app_.config().standalone() || activeValidatorView()->fromUNLReport; +} + bool ConsensusExtensions::testSuppressExportSigSetHash() const { @@ -2544,7 +2550,7 @@ ConsensusExtensions::onPreBuild( //@@end rng-inject-pseudotx } - if (exportEnabled()) + if (exportEnabled() && exportFinalizationViewAnchored()) { //@@start export-later-ledger-witness-materialization // Standalone has no peer-position gate to build and accept a sidecar @@ -2656,6 +2662,16 @@ ConsensusExtensions::onPreBuild( } //@@end export-later-ledger-witness-materialization } + else if (exportEnabled()) + { + // Admission currently makes this unreachable for a live latch because + // UNLReport state persists in descendants. Retain the materialization + // guard so a future report-expiry or clearing rule cannot silently + // turn node-local configured trust into ledger-defining authority. + JLOG(j_.warn()) << "Export: skipping witness materialization" + << " reason=no-unl-report" + << " buildSeq=" << seq; + } //@@start accept-time-cleanup-success // Export's ledger-defining signature witnesses are now self-contained diff --git a/src/xrpld/app/consensus/ConsensusExtensions.h b/src/xrpld/app/consensus/ConsensusExtensions.h index b93ef35eb..eabf2f26a 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.h +++ b/src/xrpld/app/consensus/ConsensusExtensions.h @@ -388,6 +388,12 @@ public: bool exportEnabled() const; + /// Whether Export may use the current round's validator view to align and + /// materialize a witness. Standalone is deterministic locally; networked + /// operation requires the view to come from the parent UNLReport. + bool + exportFinalizationViewAnchored() const; + bool testSuppressExportSigSetHash() const; diff --git a/src/xrpld/consensus/ConsensusExtensionsTick.h b/src/xrpld/consensus/ConsensusExtensionsTick.h index 169e053fd..ca2ff9f46 100644 --- a/src/xrpld/consensus/ConsensusExtensionsTick.h +++ b/src/xrpld/consensus/ConsensusExtensionsTick.h @@ -939,6 +939,21 @@ extensionsTick(Ext& ext, Ctx const& ctx) if (!ext.exportEnabled()) return {.readyForAccept = true}; + // Export admission already requires a nonempty parent UNLReport, and + // current ledger rules never erase or empty that report afterward, so + // this should be unreachable while an admitted latch is live. Keep the + // finalization boundary fail-closed in case report expiry or clearing + // semantics are introduced later: a config-derived view is node-local + // and cannot safely align a ledger-defining Export witness. + if (!ext.exportFinalizationViewAnchored()) + { + ext.clearAcceptedExportSigSet(); + JLOG(ext.j_.warn()) << "Export: skipping signature-set alignment" + << " reason=no-unl-report" + << " buildSeq=" << ctx.buildSeq; + return {.readyForAccept = true}; + } + auto startExportSigGate = [&]() -> bool { if (ext.exportSigGateStarted_) return false;