From 8bf1ece0a0083ffe4745474c4a6ea42acb4c98df Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Tue, 23 Jun 2026 16:42:02 +0700 Subject: [PATCH] test(export): pin expiry and no-veto boundaries --- .../scenarios/export/export_degradation.py | 7 +- .testnet/scenarios/export/export_quorum.py | 5 + .../export/export_without_unl_report.py | 7 +- src/test/app/Export_test.cpp | 93 +++++++++++++++++++ src/xrpld/consensus/ConsensusExtensionsTick.h | 4 +- 5 files changed, 111 insertions(+), 5 deletions(-) diff --git a/.testnet/scenarios/export/export_degradation.py b/.testnet/scenarios/export/export_degradation.py index 20e81732d..b8c3d8f0d 100644 --- a/.testnet/scenarios/export/export_degradation.py +++ b/.testnet/scenarios/export/export_degradation.py @@ -70,9 +70,12 @@ async def scenario(ctx, log): "(need 4 for 80% quorum) -- check runtime_config no_export_sig" ) - # Should be tecEXPORT_EXPIRED (LLS reached without quorum) + # Should be tecEXPORT_EXPIRED (LLS reached without quorum). Be exact here: + # any other non-success means the retry/expiry boundary regressed. if engine_result != "tecEXPORT_EXPIRED": - log(f"WARNING: expected tecEXPORT_EXPIRED, got {engine_result}") + raise AssertionError( + f"Expected tecEXPORT_EXPIRED below quorum, got {engine_result}" + ) log(f"Export failed as expected ({engine_result})") diff --git a/.testnet/scenarios/export/export_quorum.py b/.testnet/scenarios/export/export_quorum.py index 8ad518b36..00afc1ffd 100644 --- a/.testnet/scenarios/export/export_quorum.py +++ b/.testnet/scenarios/export/export_quorum.py @@ -84,6 +84,11 @@ async def scenario(ctx, log, expect_success=True): raise AssertionError( "Export should NOT have succeeded below active-view quorum" ) + if engine_result != "tecEXPORT_EXPIRED": + raise AssertionError( + "Expected tecEXPORT_EXPIRED below active-view quorum, " + f"got {engine_result}" + ) log(f"Export failed as expected ({engine_result})") # No shadow ticket should exist diff --git a/.testnet/scenarios/export/export_without_unl_report.py b/.testnet/scenarios/export/export_without_unl_report.py index c034ec701..09ab6180e 100644 --- a/.testnet/scenarios/export/export_without_unl_report.py +++ b/.testnet/scenarios/export/export_without_unl_report.py @@ -58,8 +58,13 @@ async def scenario(ctx, log): "Export should not succeed without a ledger-anchored UNLReport view" ) + # Be exact: without a UNLReport view the export should retry until LLS and + # expire, not fail by some unrelated terminal code. if engine_result != "tecEXPORT_EXPIRED": - log(f"WARNING: expected tecEXPORT_EXPIRED, got {engine_result}") + raise AssertionError( + "Expected tecEXPORT_EXPIRED without UNLReport view, " + f"got {engine_result}" + ) warning_logs = ctx.assert_log( r"Export: retrying without ledger-anchored validator view", diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index e571a7dfc..e556b890f 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -908,6 +908,98 @@ struct Export_test : public beast::unit_test::suite BEAST_EXPECT(!next->read(keylet::shadowTicket(alice.id(), ticketSeq))); } + void + testExportNetworkLastLedgerSequenceBoundary(FeatureBitset features) + { + testcase("ttEXPORT network mode LastLedgerSequence boundary"); + + using namespace jtx; + + auto applyAtLastLedger = [&](bool withQuorum) { + Env env{*this, exportTestConfig(), features}; + + Account const alice{"alice"}; + Account const carol{"carol"}; + + env.fund(XRP(10000), alice, carol); + env.close(); + + auto const& valKeys = env.app().getValidatorKeys(); + BEAST_EXPECT(valKeys.keys); + if (!valKeys.keys) + return; + + auto const& valPK = valKeys.keys->publicKey; + auto const& valSK = valKeys.keys->secretKey; + seedUNLReportLedger(env, {valPK}); + forceNonStandalone(env.app()); + BEAST_EXPECT(!env.app().config().standalone()); + + auto const parent = env.app().getLedgerMaster().getClosedLedger(); + auto const applySeq = parent->seq() + 1; + auto const ticketSeq = + withQuorum ? std::uint32_t{1} : std::uint32_t{2}; + auto innerObj = buildExportedPayment( + alice.id(), carol.id(), applySeq, applySeq, ticketSeq); + auto const innerTx = makeSTTx(innerObj); + auto jt = makeExportJTx(env, alice, innerObj, applySeq); + auto const exportTx = jt.stx; + BEAST_EXPECT(exportTx); + if (!exportTx) + return; + auto const txHash = exportTx->getTransactionID(); + + auto& ce = env.app().getConsensusExtensions(); + ce.setExportEnabledThisRound(true); + ce.cacheUNLReport(parent); + auto const view = ce.activeValidatorView(); + BEAST_EXPECT(view->fromUNLReport); + ce.cacheConsensusTxSet(makeRCLTxSet(env.app(), {exportTx})); + + if (withQuorum) + { + auto const sig = + ExportResultBuilder::signExportedTxn(innerTx, valPK, valSK); + ce.exportSigCollector().addVerifiedSignature( + txHash, valPK, sig, applySeq); + auto const agreedHash = ce.buildExportSigSet(applySeq); + BEAST_EXPECT(ce.isSidecarSet(agreedHash)); + } + + auto next = std::make_shared( + *parent, env.app().timeKeeper().closeTime()); + BEAST_EXPECT(next->seq() == applySeq); + OpenView accum(&*next); + auto const result = ripple::apply( + env.app(), accum, *exportTx, tapNONE, env.journal); + + if (withQuorum) + { + BEAST_EXPECT(result.ter == tesSUCCESS); + BEAST_EXPECT(result.applied); + accum.apply(*next); + BEAST_EXPECT( + next->read(keylet::shadowTicket(alice.id(), ticketSeq))); + } + else + { + BEAST_EXPECT(result.ter == tecEXPORT_EXPIRED); + // tecEXPORT_EXPIRED is a terminal tec result: it applies to + // consume the sequence/fee, but must not create export state. + BEAST_EXPECT(result.applied); + accum.apply(*next); + BEAST_EXPECT( + !next->read(keylet::shadowTicket(alice.id(), ticketSeq))); + } + }; + + // Quorum at ledger == LastLedgerSequence still succeeds; no quorum at + // the same boundary expires cleanly instead of falling through to a + // later tefMAX_LEDGER preclaim. + applyAtLastLedger(true); + applyAtLastLedger(false); + } + void testOpenLedgerExportLimit(FeatureBitset features) { @@ -1358,6 +1450,7 @@ struct Export_test : public beast::unit_test::suite testExportNetworkRetryWithoutQuorum(allWithExport); testExportNetworkApplyUsesAgreedSidecar(allWithExport); testExportNetworkRetryWithoutUNLReport(allWithExport); + testExportNetworkLastLedgerSequenceBoundary(allWithExport); testOpenLedgerExportLimit(allWithExport); testShadowTicketLimit(allWithExport); testShadowTicketLifecycle(allWithExport); diff --git a/src/xrpld/consensus/ConsensusExtensionsTick.h b/src/xrpld/consensus/ConsensusExtensionsTick.h index 2cbf3c435..26e99ba1c 100644 --- a/src/xrpld/consensus/ConsensusExtensionsTick.h +++ b/src/xrpld/consensus/ConsensusExtensionsTick.h @@ -1124,7 +1124,7 @@ extensionsTick(Ext& ext, Ctx const& ctx) exportState = inspectExportPeers(ctx.getPosition(), true); } - //@@start export-no-veto-quorum-branch + //@@start export-no-veto-quorum-branches if (exportState.conflict && quorumAligned()) { // Export sidecar roots are signed through ExtendedPosition @@ -1142,7 +1142,6 @@ extensionsTick(Ext& ext, Ctx const& ctx) << " peersSeen=" << exportState.peersSeen << " txConverged=" << exportState.txConverged; } - //@@end export-no-veto-quorum-branch else if (quorumAligned() && !exportState.fullObservation()) { JLOG(ext.j_.info()) @@ -1156,6 +1155,7 @@ extensionsTick(Ext& ext, Ctx const& ctx) << " peersSeen=" << exportState.peersSeen << " txConverged=" << exportState.txConverged; } + //@@end export-no-veto-quorum-branches else if (exportState.conflict || !quorumAligned()) { auto const elapsed =