From 570cad4c4466bbb0c355042a195064ce2eb3044d Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Mon, 22 Jun 2026 12:14:42 +0700 Subject: [PATCH] test(export): pin network apply regressions --- src/test/app/Export_test.cpp | 232 +++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index 9477e9aad..ee7e99bec 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -21,14 +21,24 @@ #include #include #include +#include #include +#include #include +#include +#include +#include #include +#include +#include #include +#include +#include #include #include #include #include +#include #include #include @@ -58,6 +68,76 @@ struct Export_test : public beast::unit_test::suite const_cast(app.config()).setupControl(true, true, false); } + static STTx + makeSTTx(STObject const& obj) + { + Serializer s; + obj.add(s); + SerialIter sit{s.slice()}; + return STTx{std::ref(sit)}; + } + + static RCLTxSet + makeRCLTxSet( + Application& app, + std::vector> const& txns) + { + auto map = std::make_shared( + SHAMapType::TRANSACTION, app.getNodeFamily()); + map->setUnbacked(); + + for (auto const& tx : txns) + { + Serializer s; + tx->add(s); + map->addItem( + SHAMapNodeType::tnTRANSACTION_NM, + make_shamapitem(tx->getTransactionID(), s.slice())); + } + + return RCLTxSet{map->snapShot(false)}; + } + + void + seedUNLReportLedger(jtx::Env& env, std::vector const& activeKeys) + { + BEAST_EXPECT(!activeKeys.empty()); + + env.app().openLedger().modify( + [&](OpenView& view, beast::Journal) -> bool { + for (auto const& pk : activeKeys) + { + STTx tx = + unl::createUNLReportTx(env.current()->seq(), pk, pk); + auto txID = tx.getTransactionID(); + auto s = std::make_shared(); + tx.add(*s); + env.app().getHashRouter().setFlags(txID, SF_PRIVATE2); + view.rawTxInsert(txID, std::move(s), nullptr); + } + return true; + }); + + BEAST_EXPECT(env.close( + env.now() + std::chrono::seconds{5}, std::chrono::milliseconds{0})); + BEAST_EXPECT(env.le(keylet::UNLReport())); + } + + static jtx::JTx + makeExportJTx( + jtx::Env& env, + jtx::Account const& account, + STObject const& innerObj, + LedgerIndex lls) + { + Json::Value jv; + jv[jss::TransactionType] = jss::Export; + jv[jss::Account] = account.human(); + jv[jss::LastLedgerSequence] = lls; + jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none); + return env.jt(jv, jtx::fee(jtx::XRP(1)), jtx::ter(tesSUCCESS)); + } + // Build a minimal unsigned Payment STObject suitable for sfExportedTxn. static STObject buildExportedPayment( @@ -678,6 +758,156 @@ struct Export_test : public beast::unit_test::suite BEAST_EXPECT(!env.le(keylet::shadowTicket(alice.id(), ticketSeq))); } + void + testExportNetworkApplyUsesAgreedSidecar(FeatureBitset features) + { + testcase("ttEXPORT network apply uses agreed export sidecar"); + + using namespace jtx; + + 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 seq = env.current()->seq(); + auto const ticketSeq = std::uint32_t{1}; + auto const lls = seq + 5; + auto innerObj = buildExportedPayment( + alice.id(), carol.id(), seq + 1, lls, ticketSeq); + auto const innerTx = makeSTTx(innerObj); + auto jt = makeExportJTx(env, alice, innerObj, lls); + 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(env.app().getLedgerMaster().getClosedLedger()); + auto const view = ce.activeValidatorView(); + BEAST_EXPECT(view->fromUNLReport); + ce.cacheConsensusTxSet(makeRCLTxSet(env.app(), {exportTx})); + + auto const originalSig = + ExportResultBuilder::signExportedTxn(innerTx, valPK, valSK); + auto const applySeq = env.closed()->seq() + 1; + ce.exportSigCollector().addVerifiedSignature( + txHash, valPK, originalSig, applySeq); + auto const agreedHash = ce.buildExportSigSet(applySeq); + BEAST_EXPECT(ce.isSidecarSet(agreedHash)); + + // Simulate an asynchronous collector mutation after sidecar agreement. + // A bad revert to the live collector at apply would assemble this late + // signature and write a different shadow-ticket hash. + std::uint8_t const lateBytes[] = {9, 8, 7}; + Buffer const lateSig{lateBytes, sizeof(lateBytes)}; + ce.exportSigCollector().addVerifiedSignature( + txHash, valPK, lateSig, applySeq); + + ExportResultBuilder::SignatureSnapshot expectedSigs; + expectedSigs.emplace(valPK, originalSig); + auto const expectedSignedTxHash = + ExportResultBuilder::assemble( + innerTx, expectedSigs, applySeq, txHash) + .signedTxHash; + + auto const parent = env.app().getLedgerMaster().getClosedLedger(); + auto next = std::make_shared( + *parent, env.app().timeKeeper().closeTime()); + OpenView accum(&*next); + auto const result = + ripple::apply(env.app(), accum, *exportTx, tapNONE, env.journal); + BEAST_EXPECT(result.ter == tesSUCCESS); + BEAST_EXPECT(result.applied); + accum.apply(*next); + + auto const st = next->read(keylet::shadowTicket(alice.id(), ticketSeq)); + BEAST_EXPECT(st); + if (st) + BEAST_EXPECT( + st->getFieldH256(sfTransactionHash) == expectedSignedTxHash); + } + + void + testExportNetworkRetryWithoutUNLReport(FeatureBitset features) + { + testcase("ttEXPORT network mode retries without UNLReport view"); + + using namespace jtx; + + Env env{*this, exportTestConfig(), features}; + + Account const alice{"alice"}; + Account const carol{"carol"}; + + env.fund(XRP(10000), alice, carol); + env.close(); + forceNonStandalone(env.app()); + BEAST_EXPECT(!env.app().config().standalone()); + + 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; + auto const seq = env.current()->seq(); + auto const ticketSeq = std::uint32_t{1}; + auto const lls = seq + 5; + auto innerObj = buildExportedPayment( + alice.id(), carol.id(), seq + 1, lls, ticketSeq); + auto const innerTx = makeSTTx(innerObj); + auto jt = makeExportJTx(env, alice, innerObj, lls); + 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(env.app().getLedgerMaster().getClosedLedger()); + auto const view = ce.activeValidatorView(); + BEAST_EXPECT(!view->fromUNLReport); + ce.cacheConsensusTxSet(makeRCLTxSet(env.app(), {exportTx})); + + auto const sig = + ExportResultBuilder::signExportedTxn(innerTx, valPK, valSK); + auto const applySeq = env.closed()->seq() + 1; + ce.exportSigCollector().addVerifiedSignature( + txHash, valPK, sig, applySeq); + auto const agreedHash = ce.buildExportSigSet(applySeq); + BEAST_EXPECT(ce.isSidecarSet(agreedHash)); + + auto const parent = env.app().getLedgerMaster().getClosedLedger(); + auto next = std::make_shared( + *parent, env.app().timeKeeper().closeTime()); + OpenView accum(&*next); + auto const result = + ripple::apply(env.app(), accum, *exportTx, tapNONE, env.journal); + + BEAST_EXPECT(result.ter == terRETRY_EXPORT); + BEAST_EXPECT(!result.applied); + BEAST_EXPECT(!next->read(keylet::shadowTicket(alice.id(), ticketSeq))); + } + void testOpenLedgerExportLimit(FeatureBitset features) { @@ -1126,6 +1356,8 @@ struct Export_test : public beast::unit_test::suite // ttEXPORT transactor tests testExportTxnOpenLedger(allWithExport); testExportNetworkRetryWithoutQuorum(allWithExport); + testExportNetworkApplyUsesAgreedSidecar(allWithExport); + testExportNetworkRetryWithoutUNLReport(allWithExport); testOpenLedgerExportLimit(allWithExport); testShadowTicketLimit(allWithExport); testShadowTicketLifecycle(allWithExport);