From ed3c0f07a75bf154562ca0556ef8145fb2917739 Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Fri, 26 Jun 2026 19:14:10 +0700 Subject: [PATCH] fix(export): return duplicate for live shadow tickets A second pending export for the same shadow-ticket key is a ledger-object conflict, not an internal failure. Return tecDUPLICATE and cover the live-latch case in Export_test. --- src/test/app/Export_test.cpp | 12 ++++++++---- src/xrpld/app/tx/detail/ExportLedgerOps.h | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index e28454098..7bc7b2c72 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -1053,7 +1053,9 @@ struct Export_test : public beast::unit_test::suite env.fund(XRP(10000), alice, carol); env.close(); - auto submitClosedExport = [&](std::uint32_t ticketSeq, TER expected) { + auto submitClosedExport = [&](std::uint32_t ticketSeq, + TER expected, + bool expectShadow) { auto const seq = env.current()->seq(); auto innerObj = buildExportedPayment( alice.id(), carol.id(), seq + 1, seq + 50, ticketSeq); @@ -1072,14 +1074,16 @@ struct Export_test : public beast::unit_test::suite auto const shadow = env.le(keylet::shadowTicket(alice.id(), ticketSeq)); - BEAST_EXPECT((expected == tesSUCCESS) == static_cast(shadow)); + BEAST_EXPECT(expectShadow == static_cast(shadow)); env.close(); }; for (std::uint32_t i = 1; i <= ExportLimits::maxPendingExports; ++i) - submitClosedExport(i, tesSUCCESS); + submitClosedExport(i, tesSUCCESS, true); - submitClosedExport(ExportLimits::maxPendingExports + 1, tecDIR_FULL); + submitClosedExport(1, tecDUPLICATE, true); + submitClosedExport( + ExportLimits::maxPendingExports + 1, tecDIR_FULL, false); } void diff --git a/src/xrpld/app/tx/detail/ExportLedgerOps.h b/src/xrpld/app/tx/detail/ExportLedgerOps.h index 68f92143f..b6eff353f 100644 --- a/src/xrpld/app/tx/detail/ExportLedgerOps.h +++ b/src/xrpld/app/tx/detail/ExportLedgerOps.h @@ -180,7 +180,7 @@ validateTicketSequence(STTx const& stx, beast::Journal j) /// @param stx The exported transaction (checked for sfTicketSequence) /// @param txnId Hash of the exported transaction /// @param j Journal for logging -/// @return tesSUCCESS, tecDIR_FULL, or tefINTERNAL +/// @return tesSUCCESS, tecDUPLICATE, tecDIR_FULL, or tefINTERNAL inline TER createShadowTicket( ApplyView& view, @@ -211,7 +211,7 @@ createShadowTicket( { JLOG(j.warn()) << "ExportLedgerOps: shadow ticket already exists for " << account << " seq=" << ticketSeq; - return tefINTERNAL; + return tecDUPLICATE; } auto const pending = shadowTicketCount(view, account);