diff --git a/.testnet/scenarios/export/steady_state_export.py b/.testnet/scenarios/export/steady_state_export.py index f393349cd..f4646664b 100644 --- a/.testnet/scenarios/export/steady_state_export.py +++ b/.testnet/scenarios/export/steady_state_export.py @@ -1,10 +1,10 @@ -""":descr: install xport hook, trigger export, verify ttEXPORT lifecycle completes +""":descr: install xport hook, trigger export, verify ttEXPORT_FINALIZE lifecycle completes Mirrors the C++ Export_test.cpp::testXportPaymentWithValidator flow: 1. Fund alice (hook holder), bob (trigger), carol (export destination) 2. Install xport hook on alice 3. bob pays alice with DST=carol → hook calls xport() - 4. Wait for validator signature collection + ttEXPORT application + 4. Wait for validator signature collection + ttEXPORT_FINALIZE application 5. Verify Export transaction appears in a subsequent ledger """ diff --git a/hook/tts.h b/hook/tts.h index f62b4caff..6157f6160 100644 --- a/hook/tts.h +++ b/hook/tts.h @@ -61,7 +61,7 @@ #define ttNFTOKEN_MODIFY 70 #define ttPERMISSIONED_DOMAIN_SET 71 #define ttPERMISSIONED_DOMAIN_DELETE 72 -#define ttEXPORT 90 +#define ttEXPORT_FINALIZE 90 #define ttCRON 92 #define ttCRON_SET 93 #define ttREMARKS_SET 94 diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 7972a27e2..7163cc2bf 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -500,12 +500,17 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 72, PermissionedDomainDelete, ({ {sfDomainID, soeREQUIRED}, })) -TRANSACTION(ttEXPORT, 90, Export, ({ +TRANSACTION(ttEXPORT_FINALIZE, 90, ExportFinalize, ({ {sfTransactionHash, soeREQUIRED}, {sfExportedTxn, soeREQUIRED}, {sfLedgerSequence, soeREQUIRED}, })) +/* User-submittable export: create ltEXPORTED_TXN for validator signing */ +TRANSACTION(ttEXPORT, 91, Export, ({ + {sfExportedTxn, soeREQUIRED}, +})) + /* A pseudo-txn alarm signal for invoking a hook, emitted by validators after alarm set conditions are met */ TRANSACTION(ttCRON, 92, Cron, ({ {sfOwner, soeREQUIRED}, diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index 8c93499fe..b51c1bd92 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -685,7 +685,7 @@ isPseudoTx(STObject const& tx) auto tt = safe_cast(*t); return tt == ttAMENDMENT || tt == ttFEE || tt == ttUNL_MODIFY || tt == ttEMIT_FAILURE || tt == ttUNL_REPORT || tt == ttCRON || - tt == ttEXPORT || tt == ttCONSENSUS_ENTROPY; + tt == ttEXPORT_FINALIZE || tt == ttCONSENSUS_ENTROPY; } } // namespace ripple diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index 03c2bfb9d..3d5c05f2b 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -224,7 +224,7 @@ struct Export_test : public beast::unit_test::suite // Helper: run xport test with given config // Returns true if the exported directory is empty after the flow - // (meaning ttEXPORT cleaned up the entry) + // (meaning ttEXPORT_FINALIZE cleaned up the entry) void runXportTest( FeatureBitset features, @@ -282,8 +282,8 @@ struct Export_test : public beast::unit_test::suite // Close additional ledgers for signing flow env.close(); // N+1: validators sign via TMValidation - env.close(); // N+2: ttEXPORT created (rawTxInsert) - env.close(); // N+3: does ttEXPORT get applied here? + env.close(); // N+2: ttEXPORT_FINALIZE created (rawTxInsert) + env.close(); // N+3: does ttEXPORT_FINALIZE get applied here? // Check if cleanup happened { @@ -303,7 +303,7 @@ struct Export_test : public beast::unit_test::suite // With validator config, full flow should work: // N: xport creates entry // N+1: validator signs - // N+2: ttEXPORT cleans up + // N+2: ttEXPORT_FINALIZE cleans up runXportTest(features, exportTestConfig, true); } @@ -538,6 +538,61 @@ struct Export_test : public beast::unit_test::suite BEAST_EXPECT(dirIsEmpty(*env.current(), exportedDirKey)); } + // Build a minimal unsigned Payment STObject suitable for sfExportedTxn. + static STObject + buildExportedPayment( + AccountID const& src, + AccountID const& dst, + std::uint32_t fls, + std::uint32_t lls) + { + STObject obj(sfExportedTxn); + obj.setFieldU16(sfTransactionType, ttPAYMENT); + obj.setFieldU32(sfFlags, tfFullyCanonicalSig); + obj.setFieldU32(sfSequence, 0); + obj.setFieldU32(sfFirstLedgerSequence, fls); + obj.setFieldU32(sfLastLedgerSequence, lls); + obj.setFieldAmount(sfAmount, XRPAmount{1000000}); + obj.setFieldAmount(sfFee, XRPAmount{10}); + obj.setFieldVL(sfSigningPubKey, Blob{}); + obj.setAccountID(sfAccount, src); + obj.setAccountID(sfDestination, dst); + return obj; + } + + void + testExportTxn(FeatureBitset features) + { + testcase("ttEXPORT_USER creates ltEXPORTED_TXN"); + + 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 seq = env.current()->seq(); + auto innerObj = + buildExportedPayment(alice.id(), carol.id(), seq + 1, seq + 5); + + // Submit ttEXPORT_USER with inner payment as STObject + Json::Value jv; + jv[jss::TransactionType] = jss::Export; + jv[jss::Account] = alice.human(); + jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none); + + env(jv, fee(XRP(1)), ter(tesSUCCESS)); + env.close(); + + // Verify ltEXPORTED_TXN was created + auto const exportedDirKey = keylet::exportedDir(); + BEAST_EXPECT(!dirIsEmpty(*env.current(), exportedDirKey)); + } + void testStaleSignatureCleanup(FeatureBitset features) { @@ -579,6 +634,7 @@ struct Export_test : public beast::unit_test::suite testXportPaymentWithValidator(allWithExport); testXportRejectsLocalNetworkID(allWithExport); testXportRejectsUnconfiguredNetworkID(allWithExport); + testExportTxn(allWithExport); testStaleSignatureCleanup(allWithExport); } }; diff --git a/src/xrpld/app/hook/detail/HookAPI.cpp b/src/xrpld/app/hook/detail/HookAPI.cpp index 82980d615..be2a28036 100644 --- a/src/xrpld/app/hook/detail/HookAPI.cpp +++ b/src/xrpld/app/hook/detail/HookAPI.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -1265,42 +1266,15 @@ HookAPI::xport(Slice const& txBlob) const return Unexpected(EXPORT_FAILURE); } - if (!stpTrans->isFieldPresent(sfAccount) || - stpTrans->getAccountID(sfAccount) != hookCtx.result.account) - { - JLOG(j.trace()) << "HookExport[" << HC_ACC() - << "]: Attempted to export a txn that's not for this " - "Hook's Account ID."; + if (auto ter = ExportLedgerOps::validateExportAccount( + *stpTrans, hookCtx.result.account, j); + !isTesSuccess(ter)) return Unexpected(EXPORT_FAILURE); - } - // Reject exports that could target the local network. - // An exported txn re-executing on its origin chain could cause exploits. - // - // Per XRPL rules (Transactor.cpp): - // - Networks <= 1024: sfNetworkID must NOT be present - // - Networks > 1024: sfNetworkID is REQUIRED and must match - // - // So: if the exported tx has sfNetworkID matching local → self-target. - // if local NETWORK_ID is 0 (unconfigured) → can't safely distinguish - // self-targeting from cross-chain, reject unless tx has an explicit - // non-zero NetworkID. - if (stpTrans->isFieldPresent(sfNetworkID) && - stpTrans->getFieldU32(sfNetworkID) == app.config().NETWORK_ID) - { - JLOG(j.warn()) << "HookExport[" << HC_ACC() - << "]: Rejected export with local NetworkID (" - << app.config().NETWORK_ID << ")."; + if (auto ter = ExportLedgerOps::validateNetworkID( + *stpTrans, app.config().NETWORK_ID, j); + !isTesSuccess(ter)) return Unexpected(EXPORT_FAILURE); - } - - if (app.config().NETWORK_ID == 0 && !stpTrans->isFieldPresent(sfNetworkID)) - { - JLOG(j.warn()) << "HookExport[" << HC_ACC() - << "]: Rejected export with unconfigured NETWORK_ID. " - "Node must have a non-zero NETWORK_ID to export."; - return Unexpected(EXPORT_FAILURE); - } std::string reason; auto tpTrans = std::make_shared(stpTrans, reason, app); diff --git a/src/xrpld/app/hook/detail/applyHook.cpp b/src/xrpld/app/hook/detail/applyHook.cpp index 8ac1fcb08..526e4c9bf 100644 --- a/src/xrpld/app/hook/detail/applyHook.cpp +++ b/src/xrpld/app/hook/detail/applyHook.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -587,6 +588,7 @@ getTransactionalStakeHolders(STTx const& tx, ReadView const& rv) case ttUNL_MODIFY: case ttEMIT_FAILURE: case ttUNL_REPORT: + case ttEXPORT_FINALIZE: case ttEXPORT: case ttCONSENSUS_ENTROPY: { break; @@ -1725,89 +1727,16 @@ hook::finalizeHookResult( auto& id = tpTrans->getID(); JLOG(j.trace()) << "HookExport[" << HR_ACC() << "]: " << id; - // exported txns must be marked bad by the hash router to ensure - // under no circumstances they will enter consensus on *this* chain. - applyCtx.app.getHashRouter().setFlags(id, SF_BAD); - std::shared_ptr ptr = tpTrans->getSTransaction(); - auto exportedId = keylet::exportedTxn(id); - auto sleExported = applyCtx.view().peek(exportedId); + TER const ter = ExportLedgerOps::createExportedTxn( + applyCtx.view(), applyCtx.app, *ptr, id, j); - if (!sleExported) - { - // Enforce maxPendingExports on the exported directory. - // Each pending export costs validator signing + broadcast - // work every round, so this is the root DoS constraint. - { - Keylet const expDirKey{keylet::exportedDir()}; - std::size_t dirSize = 0; - std::shared_ptr sleDirNode; - unsigned int uDirEntry{0}; - uint256 dirEntry{beast::zero}; - if (cdirFirst( - applyCtx.view(), - expDirKey.key, - sleDirNode, - uDirEntry, - dirEntry)) - { - do - { - ++dirSize; - } while (cdirNext( - applyCtx.view(), - expDirKey.key, - sleDirNode, - uDirEntry, - dirEntry)); - } + if (!isTesSuccess(ter)) + return ter; - if (dirSize >= ExportLimits::maxPendingExports) - { - JLOG(j.warn()) << "HookError[" << HR_ACC() << "]: " - << "Export directory at cap (" - << ExportLimits::maxPendingExports - << "), rejecting export " << id; - return tecDIR_FULL; - } - } - - exported_txnid.emplace_back(id); - - sleExported = std::make_shared(exportedId); - - // RH TODO: add a new constructor to STObject to avoid this - // serder thing - ripple::Serializer s; - ptr->add(s); - SerialIter sit(s.slice()); - - sleExported->emplace_back(ripple::STObject(sit, sfExportedTxn)); - auto page = applyCtx.view().dirInsert( - keylet::exportedDir(), exportedId, [&](SLE::ref sle) { - (*sle)[sfFlags] = lsfEmittedDir; - }); - - if (page) - { - (*sleExported)[sfOwnerNode] = *page; - (*sleExported)[sfLedgerSequence] = - applyCtx.view().info().seq; - (*sleExported)[sfTransactionHash] = id; - applyCtx.view().insert(sleExported); - JLOG(j.debug()) - << "Export: created ltEXPORTED_TXN for " << id; - } - else - { - JLOG(j.warn()) - << "HookError[" << HR_ACC() << "]: " - << "Export Directory full when trying to insert " << id; - return tecDIR_FULL; - } - } + exported_txnid.emplace_back(id); } } diff --git a/src/xrpld/app/misc/ExportSignatureCollector.h b/src/xrpld/app/misc/ExportSignatureCollector.h index 2d15d5ba6..487e5bbf1 100644 --- a/src/xrpld/app/misc/ExportSignatureCollector.h +++ b/src/xrpld/app/misc/ExportSignatureCollector.h @@ -70,7 +70,8 @@ getExportUNLSize(ReadView const& view, Application& app); occurs when accumulating signatures in ledger entries. The collector stores signatures in memory until quorum (80% UNL) is reached, - at which point a ttEXPORT transaction can be created with all signatures. + at which point a ttEXPORT_FINALIZE transaction can be created with all + signatures. Continuous broadcasting: ======================== @@ -84,8 +85,9 @@ getExportUNLSize(ReadView const& view, Application& app); - Node restarts recover (re-sign from ledger state, ltEXPORTED_TXN exists) The ltEXPORTED_TXN in the ledger is the gatekeeper - once deleted (after - ttEXPORT processed or export expired), signatures naturally stop being - broadcast. The collector clears its cache when ttEXPORT is applied. + ttEXPORT_FINALIZE processed or export expired), signatures naturally stop + being broadcast. The collector clears its cache when ttEXPORT_FINALIZE is + applied. Thread safety: All public methods are thread-safe. */ @@ -183,7 +185,7 @@ public: /** Clear signatures for a completed export. - Called after ttEXPORT is applied to clean up memory. + Called after ttEXPORT_FINALIZE is applied to clean up memory. @param txnHash The hash of the completed export */ diff --git a/src/xrpld/app/misc/detail/TxQ.cpp b/src/xrpld/app/misc/detail/TxQ.cpp index 7a7550f8a..548f16a3f 100644 --- a/src/xrpld/app/misc/detail/TxQ.cpp +++ b/src/xrpld/app/misc/detail/TxQ.cpp @@ -1683,7 +1683,7 @@ TxQ::accept(Application& app, OpenView& view) stpTrans->add(exportedSer); SerialIter exportedSit(exportedSer.slice()); - STTx exportTx(ttEXPORT, [&](auto& obj) { + STTx exportTx(ttEXPORT_FINALIZE, [&](auto& obj) { obj[sfAccount] = AccountID(); obj.set(std::make_unique( exportedSit, sfExportedTxn)); @@ -1694,7 +1694,7 @@ TxQ::accept(Application& app, OpenView& view) uint256 txID = exportTx.getTransactionID(); JLOG(j_.debug()) - << "Export: injecting ttEXPORT txID=" << txID + << "Export: injecting ttEXPORT_FINALIZE txID=" << txID << " with " << signers.size() << " signatures"; auto txBlob = std::make_shared(); diff --git a/src/xrpld/app/tx/detail/Change.cpp b/src/xrpld/app/tx/detail/Change.cpp index 498f27bac..119284c0e 100644 --- a/src/xrpld/app/tx/detail/Change.cpp +++ b/src/xrpld/app/tx/detail/Change.cpp @@ -103,7 +103,8 @@ Change::preflight(PreflightContext const& ctx) } } - if (ctx.tx.getTxnType() == ttEXPORT && !ctx.rules.enabled(featureExport)) + if (ctx.tx.getTxnType() == ttEXPORT_FINALIZE && + !ctx.rules.enabled(featureExport)) { JLOG(ctx.j.warn()) << "Change: Export not enabled"; return temDISABLED; @@ -182,7 +183,7 @@ Change::preclaim(PreclaimContext const& ctx) case ttAMENDMENT: case ttUNL_MODIFY: case ttEMIT_FAILURE: - case ttEXPORT: + case ttEXPORT_FINALIZE: case ttCONSENSUS_ENTROPY: return tesSUCCESS; case ttUNL_REPORT: { @@ -239,8 +240,8 @@ Change::doApply() return applyEmitFailure(); case ttUNL_REPORT: return applyUNLReport(); - case ttEXPORT: - return applyExport(); + case ttEXPORT_FINALIZE: + return applyExportFinalize(); case ttCONSENSUS_ENTROPY: return applyConsensusEntropy(); default: @@ -1146,13 +1147,14 @@ Change::applyEmitFailure() } TER -Change::applyExport() +Change::applyExportFinalize() { uint256 txnID(ctx_.tx.getFieldH256(sfTransactionHash)); do { - JLOG(j_.debug()) << "Export: processing ttEXPORT for " << txnID; + JLOG(j_.debug()) << "Export: processing ttEXPORT_FINALIZE for " + << txnID; // Last-line-of-defense safety check: // Require >= 80% (ceil) cryptographically verified signatures from @@ -1260,9 +1262,9 @@ Change::applyExport() { // most likely explanation is that this was somehow a double-up, so // just ignore - JLOG(j_.warn()) - << "Export: ttEXPORT could not find ltEXPORTED_TXN for " - << txnID; + JLOG(j_.warn()) << "Export: ttEXPORT_FINALIZE could not find " + "ltEXPORTED_TXN for " + << txnID; break; } @@ -1272,9 +1274,9 @@ Change::applyExport() key, false)) { - JLOG(j_.fatal()) - << "Export: ttEXPORT failed to remove directory entry for " - << txnID; + JLOG(j_.fatal()) << "Export: ttEXPORT_FINALIZE failed to remove " + "directory entry for " + << txnID; return tefBAD_LEDGER; } diff --git a/src/xrpld/app/tx/detail/Change.h b/src/xrpld/app/tx/detail/Change.h index 2688ccbfa..2ed076ef5 100644 --- a/src/xrpld/app/tx/detail/Change.h +++ b/src/xrpld/app/tx/detail/Change.h @@ -75,7 +75,7 @@ private: applyEmitFailure(); TER - applyExport(); + applyExportFinalize(); TER applyUNLReport(); @@ -89,7 +89,7 @@ using SetFee = Change; using UNLModify = Change; using EmitFailure = Change; using UNLReport = Change; -using Export = Change; +using ExportFinalize = Change; using ConsensusEntropy = Change; } // namespace ripple diff --git a/src/xrpld/app/tx/detail/Export.cpp b/src/xrpld/app/tx/detail/Export.cpp new file mode 100644 index 000000000..d0c0aa589 --- /dev/null +++ b/src/xrpld/app/tx/detail/Export.cpp @@ -0,0 +1,78 @@ +#include +#include +#include +#include + +namespace ripple { + +NotTEC +Export::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureExport)) + return temDISABLED; + + auto const ret = preflight1(ctx); + if (!isTesSuccess(ret)) + return ret; + + if (!ctx.tx.isFieldPresent(sfExportedTxn)) + return temMALFORMED; + + return preflight2(ctx); +} + +TER +Export::preclaim(PreclaimContext const& ctx) +{ + // Parse the inner exported transaction. + auto const& exportedObj = const_cast(ctx.tx) + .peekAtField(sfExportedTxn) + .downcast(); + + Serializer s; + exportedObj.add(s); + SerialIter sit(s.slice()); + + std::shared_ptr stpTrans; + try + { + stpTrans = std::make_shared(sit); + } + catch (std::exception const&) + { + return temMALFORMED; + } + + // Shared validation: account must match submitter. + if (auto ter = ExportLedgerOps::validateExportAccount( + *stpTrans, ctx.tx.getAccountID(sfAccount), ctx.j); + !isTesSuccess(ter)) + return ter; + + // Shared validation: NetworkID self-target guard. + if (auto ter = ExportLedgerOps::validateNetworkID( + *stpTrans, ctx.app.config().NETWORK_ID, ctx.j); + !isTesSuccess(ter)) + return ter; + + return tesSUCCESS; +} + +TER +Export::doApply() +{ + auto const& exportedObj = + ctx_.tx.peekAtField(sfExportedTxn).downcast(); + + Serializer s; + exportedObj.add(s); + SerialIter sit(s.slice()); + + STTx exportedTx(std::ref(sit)); + uint256 const txnId = exportedTx.getTransactionID(); + + return ExportLedgerOps::createExportedTxn( + view(), ctx_.app, exportedTx, txnId, j_); +} + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/Export.h b/src/xrpld/app/tx/detail/Export.h new file mode 100644 index 000000000..fd63e8996 --- /dev/null +++ b/src/xrpld/app/tx/detail/Export.h @@ -0,0 +1,33 @@ +#ifndef RIPPLE_TX_EXPORT_H_INCLUDED +#define RIPPLE_TX_EXPORT_H_INCLUDED + +#include + +namespace ripple { + +/// User-submittable export transaction. +/// Creates an ltEXPORTED_TXN entry for validator signing. +/// This is the transaction-based entry point for non-hook users; +/// hooks use the xport() API which creates the same ledger state inline. +class Export : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit Export(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/ExportLedgerOps.h b/src/xrpld/app/tx/detail/ExportLedgerOps.h new file mode 100644 index 000000000..8445fb111 --- /dev/null +++ b/src/xrpld/app/tx/detail/ExportLedgerOps.h @@ -0,0 +1,165 @@ +#ifndef RIPPLE_TX_EXPORTLEDGEROPS_H_INCLUDED +#define RIPPLE_TX_EXPORTLEDGEROPS_H_INCLUDED + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ripple { + +/// Shared ledger operations and validation for the export system. +/// Used by both the hook xport() API (inline path) and the +/// Export transactor (user-submitted ttEXPORT path). +namespace ExportLedgerOps { + +/// Validate that the exported transaction's NetworkID doesn't target +/// the local network. Returns tesSUCCESS if OK, or a TER error code. +/// +/// Rules (per upstream rippled Transactor.cpp): +/// - Networks <= 1024: sfNetworkID must NOT be present on txns +/// - Networks > 1024: sfNetworkID is REQUIRED and must match +/// +/// So: if exported tx has sfNetworkID matching local → self-target. +/// if local NETWORK_ID is 0 (unconfigured) and tx has no +/// sfNetworkID → can't distinguish self from cross-chain, reject. +inline TER +validateNetworkID( + STTx const& stx, + std::uint32_t localNetworkID, + beast::Journal j) +{ + if (stx.isFieldPresent(sfNetworkID) && + stx.getFieldU32(sfNetworkID) == localNetworkID) + { + JLOG(j.warn()) << "ExportLedgerOps: rejected export targeting " + "local NetworkID (" + << localNetworkID << ")"; + return temMALFORMED; + } + + if (localNetworkID == 0 && !stx.isFieldPresent(sfNetworkID)) + { + JLOG(j.warn()) << "ExportLedgerOps: rejected export with " + "unconfigured NETWORK_ID"; + return temMALFORMED; + } + + return tesSUCCESS; +} + +/// Validate that the exported transaction's Account matches the +/// expected exporting account. +inline TER +validateExportAccount( + STTx const& stx, + AccountID const& expectedAccount, + beast::Journal j) +{ + if (!stx.isFieldPresent(sfAccount) || + stx.getAccountID(sfAccount) != expectedAccount) + { + JLOG(j.warn()) + << "ExportLedgerOps: exported txn account doesn't match exporter"; + return temMALFORMED; + } + + return tesSUCCESS; +} + +/// Create an ltEXPORTED_TXN entry in the global exportedDir(). +/// Enforces maxPendingExports directory cap. +/// Marks the txn hash as SF_BAD in the hash router so it cannot +/// enter consensus on this chain. +/// +/// @param view The apply view to modify +/// @param app Application reference (for hash router) +/// @param stx The serialized transaction to export +/// @param txnId Hash of the exported transaction +/// @param j Journal for logging +/// @return tesSUCCESS or tecDIR_FULL +inline TER +createExportedTxn( + ApplyView& view, + Application& app, + STTx const& stx, + uint256 const& txnId, + beast::Journal j) +{ + // Mark as SF_BAD so this txn never enters consensus on this chain. + app.getHashRouter().setFlags(txnId, SF_BAD); + + auto exportedId = keylet::exportedTxn(txnId); + auto sleExported = view.peek(exportedId); + + if (sleExported) + { + // Already exists — duplicate export, skip. + JLOG(j.debug()) << "ExportLedgerOps: ltEXPORTED_TXN already exists for " + << txnId; + return tesSUCCESS; + } + + // Enforce maxPendingExports on the exported directory. + { + Keylet const expDirKey{keylet::exportedDir()}; + std::size_t dirSize = 0; + std::shared_ptr sleDirNode; + unsigned int uDirEntry{0}; + uint256 dirEntry{beast::zero}; + if (cdirFirst(view, expDirKey.key, sleDirNode, uDirEntry, dirEntry)) + { + do + { + ++dirSize; + } while ( + cdirNext(view, expDirKey.key, sleDirNode, uDirEntry, dirEntry)); + } + + if (dirSize >= ExportLimits::maxPendingExports) + { + JLOG(j.warn()) << "ExportLedgerOps: export directory at cap (" + << ExportLimits::maxPendingExports + << "), rejecting export " << txnId; + return tecDIR_FULL; + } + } + + sleExported = std::make_shared(exportedId); + + // Serialize the STTx into an sfExportedTxn inner object. + ripple::Serializer s; + stx.add(s); + SerialIter sit(s.slice()); + sleExported->emplace_back(ripple::STObject(sit, sfExportedTxn)); + + auto page = + view.dirInsert(keylet::exportedDir(), exportedId, [&](SLE::ref sle) { + (*sle)[sfFlags] = lsfEmittedDir; + }); + + if (page) + { + (*sleExported)[sfOwnerNode] = *page; + (*sleExported)[sfLedgerSequence] = view.info().seq; + (*sleExported)[sfTransactionHash] = txnId; + view.insert(sleExported); + JLOG(j.debug()) << "ExportLedgerOps: created ltEXPORTED_TXN for " + << txnId; + return tesSUCCESS; + } + + JLOG(j.warn()) << "ExportLedgerOps: directory full when inserting " + << txnId; + return tecDIR_FULL; +} + +} // namespace ExportLedgerOps +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/applySteps.cpp b/src/xrpld/app/tx/detail/applySteps.cpp index 8c9c1ef54..68b8a0969 100644 --- a/src/xrpld/app/tx/detail/applySteps.cpp +++ b/src/xrpld/app/tx/detail/applySteps.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include