fix(export): cap hook export backlog

Enforce the pending export cap for hook-emitted ttEXPORT work before commit. Replace the non-present sfEmittedTxn template field when building ltEMITTED_TXN entries so in-flight ledger checks see the emitted wrapper.

Overflowing xport emission now returns tecDIR_FULL and leaves the emitted backlog capped at ExportLimits::maxPendingExports.
This commit is contained in:
Nicholas Dudfield
2026-04-27 22:55:23 +07:00
parent 0c2c59d258
commit c58da3da58
7 changed files with 197 additions and 6 deletions

View File

@@ -23,6 +23,7 @@
#include <test/jtx/import.h>
#include <test/jtx/xpop.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/tx/detail/ExportLedgerOps.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/ExportLimits.h>
#include <xrpl/protocol/Feature.h>
@@ -526,6 +527,64 @@ struct Export_test : public beast::unit_test::suite
BEAST_EXPECT(dirIsEmpty(*env.current(), emittedDirKey));
}
void
testXportEmissionLimit(FeatureBitset features)
{
testcase("Xport emitted export limit");
using namespace jtx;
Env env{*this, exportTestConfig(), features};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, bob, carol);
env.close();
env(ripple::test::jtx::hook(alice, {{hso(xport_wasm)}}, 0),
HSFEE,
ter(tesSUCCESS));
env.close();
auto params = makeDstParams(carol.id());
for (std::uint32_t i = 0; i <= ExportLimits::maxPendingExports; ++i)
{
env(pay(bob, alice, XRP(1)),
fee(XRP(1)),
json(jss::HookParameters, params),
ter(tesSUCCESS));
}
env.close();
std::uint32_t accepted = 0;
std::uint32_t capped = 0;
for (auto const& [stx, meta] : env.closed()->txs)
{
if (stx->getTxnType() != ttPAYMENT ||
stx->getAccountID(sfAccount) != bob.id())
{
continue;
}
if ((*meta)[sfTransactionResult] ==
static_cast<std::uint8_t>(TERtoInt(tesSUCCESS)))
++accepted;
else if (
(*meta)[sfTransactionResult] ==
static_cast<std::uint8_t>(TERtoInt(tecDIR_FULL)))
++capped;
}
BEAST_EXPECT(accepted == ExportLimits::maxPendingExports);
BEAST_EXPECT(capped == 1);
BEAST_EXPECT(
ExportLedgerOps::pendingExportEmissionCount(*env.current()) ==
ExportLimits::maxPendingExports);
}
void
testExportTxnOpenLedger(FeatureBitset features)
{
@@ -997,6 +1056,7 @@ struct Export_test : public beast::unit_test::suite
testXportPayment(allWithExport);
testXportRejectsLocalNetworkID(allWithExport);
testXportRejectsUnconfiguredNetworkID(allWithExport);
testXportEmissionLimit(allWithExport);
// ttEXPORT transactor tests
testExportTxnOpenLedger(allWithExport);

View File

@@ -1662,6 +1662,8 @@ hook::finalizeHookResult(
std::vector<std::pair<uint256 /* txnid */, uint256 /* emit nonce */>>
emission_txnid;
std::vector<uint256 /* txnid */> exported_txnid;
std::size_t pendingExportEmissions =
ExportLedgerOps::pendingExportEmissionCount(applyCtx.view());
if (doEmit)
{
@@ -1676,12 +1678,29 @@ hook::finalizeHookResult(
std::shared_ptr<const ripple::STTx> ptr =
tpTrans->getSTransaction();
bool const isExportEmission =
ExportLedgerOps::isPendingExportWorkTxn(*ptr);
auto emittedId = keylet::emittedTxn(id);
auto sleEmitted = applyCtx.view().peek(emittedId);
if (!sleEmitted)
{
if (isExportEmission)
{
if (pendingExportEmissions >=
ExportLimits::maxPendingExports)
{
JLOG(j.warn())
<< "HookExport[" << HR_ACC()
<< "]: export emission limit reached pending="
<< pendingExportEmissions
<< " max=" << +ExportLimits::maxPendingExports;
return tecDIR_FULL;
}
++pendingExportEmissions;
}
auto const& emitDetails = const_cast<ripple::STTx&>(*ptr)
.getField(sfEmitDetails)
.downcast<STObject>();
@@ -1696,7 +1715,8 @@ hook::finalizeHookResult(
ptr->add(s);
SerialIter sit(s.slice());
sleEmitted->emplace_back(ripple::STObject(sit, sfEmittedTxn));
sleEmitted->set(
std::make_unique<ripple::STObject>(sit, sfEmittedTxn));
auto page = applyCtx.view().dirInsert(
keylet::emittedDir(), emittedId, [&](SLE::ref sle) {
(*sle)[sfFlags] = lsfEmittedDir;

View File

@@ -1711,7 +1711,7 @@ TxQ::accept(Application& app, OpenView& view)
// set
if (fls >= view.info().seq)
{
if (ExportLedgerOps::isPendingExportTxn(*stpTrans))
if (ExportLedgerOps::isPendingExportWorkTxn(*stpTrans))
{
auto const pending =
ExportLedgerOps::exportTxnCount(view);

View File

@@ -18,6 +18,7 @@
//==============================================================================
#include <xrpld/app/tx/detail/ApplyContext.h>
#include <xrpld/app/tx/detail/ExportLedgerOps.h>
#include <xrpld/app/tx/detail/InvariantCheck.h>
#include <xrpld/app/tx/detail/Transactor.h>
#include <xrpl/basics/Log.h>
@@ -156,4 +157,48 @@ ApplyContext::checkInvariants(TER const result, XRPAmount const fee)
std::make_index_sequence<std::tuple_size<InvariantChecks>::value>{});
}
TER
ApplyContext::checkExportEmissionLimit(TER const result)
{
if (!isTesSuccess(result))
return result;
bool addedPendingExport = false;
int delta = 0;
visit([&addedPendingExport, &delta](
uint256 const&,
bool,
std::shared_ptr<SLE const> const& before,
std::shared_ptr<SLE const> const& after) {
bool const beforePending =
before && ExportLedgerOps::isPendingExportEmission(*before);
bool const afterPending =
after && ExportLedgerOps::isPendingExportEmission(*after);
if (!beforePending && afterPending)
{
addedPendingExport = true;
++delta;
}
else if (beforePending && !afterPending)
{
--delta;
}
});
if (!addedPendingExport)
return result;
auto const pending = ExportLedgerOps::pendingExportEmissionCount(base_);
auto const projected = static_cast<int>(pending) + delta;
if (projected <= static_cast<int>(ExportLimits::maxPendingExports))
return result;
JLOG(journal.warn()) << "Export emission limit reached pending="
<< projected
<< " max=" << +ExportLimits::maxPendingExports;
return tecDIR_FULL;
}
} // namespace ripple

View File

@@ -122,6 +122,9 @@ public:
TER
checkInvariants(TER const result, XRPAmount const fee);
TER
checkExportEmissionLimit(TER const result);
bool
isEmittedTxn()
{

View File

@@ -28,18 +28,50 @@ isPendingExportTxn(STTx const& stx)
return isExportTxn(stx) && stx.isFieldPresent(sfExportedTxn);
}
inline bool
isPendingExportWorkTxn(STTx const& stx)
{
return isExportTxn(stx) &&
(stx.isFieldPresent(sfExportedTxn) ||
stx.isFieldPresent(sfEmitDetails));
}
inline std::size_t
exportTxnCount(ReadView const& view)
{
std::size_t count = 0;
for (auto const& tx : view.txs)
{
if (tx.first && isPendingExportTxn(*tx.first))
if (tx.first && isPendingExportWorkTxn(*tx.first))
++count;
}
return count;
}
inline bool
isPendingExportEmission(SLE const& sle)
{
if (sle.getType() != ltEMITTED_TXN || !sle.isFieldPresent(sfEmittedTxn))
return false;
auto const& emittedObj = sle.peekAtField(sfEmittedTxn).downcast<STObject>();
return emittedObj.isFieldPresent(sfTransactionType) &&
emittedObj.getFieldU16(sfTransactionType) == ttEXPORT &&
emittedObj.isFieldPresent(sfExportedTxn);
}
inline std::size_t
pendingExportEmissionCount(ReadView const& view)
{
std::size_t count = 0;
forEachItem(
view, keylet::emittedDir(), [&](std::shared_ptr<SLE const> const& sle) {
if (sle && isPendingExportEmission(*sle))
++count;
});
return count;
}
inline std::size_t
shadowTicketCount(ReadView const& view, AccountID const& account)
{

View File

@@ -1972,7 +1972,12 @@ Transactor::operator()()
// write state if all chains executed successfully
if (isTesSuccess(result))
hook::finalizeHookState(stateMap, ctx_, ctx_.tx.getTransactionID());
{
if (auto const ter = hook::finalizeHookState(
stateMap, ctx_, ctx_.tx.getTransactionID());
!isTesSuccess(ter))
result = ter;
}
// write hook results
// this happens irrespective of whether final result was a tesSUCCESS
@@ -1981,8 +1986,12 @@ Transactor::operator()()
for (auto& hookResult : hookResults)
{
hook::finalizeHookResult(hookResult, ctx_, isTesSuccess(result));
if (hookResult.executeAgainAsWeak)
if (auto const ter = hook::finalizeHookResult(
hookResult, ctx_, isTesSuccess(result));
isTesSuccess(result) && !isTesSuccess(ter))
result = ter;
if (isTesSuccess(result) && hookResult.executeAgainAsWeak)
{
if (aawMap.find(hookResult.account) == aawMap.end())
aawMap[hookResult.account] = {hookResult.hookHash};
@@ -2278,6 +2287,28 @@ Transactor::operator()()
result = tecOVERSIZE;
}
if (applied)
{
auto const limitResult = ctx_.checkExportEmissionLimit(result);
if (!isTesSuccess(limitResult))
{
result = limitResult;
auto const resetResult = reset(fee);
if (!isTesSuccess(resetResult.first))
{
result = resetResult.first;
applied = false;
}
else
{
fee = resetResult.second;
result = ctx_.checkInvariants(result, fee);
applied = isTesSuccess(result) || isTecClaim(result);
}
}
}
std::optional<TxMeta> metadata;
if (applied)
{