mirror of
https://github.com/Xahau/xahaud.git
synced 2026-06-07 18:56:36 +00:00
feat(export): add ttEXPORT user transaction and extract ExportLedgerOps
Rename the existing ttEXPORT pseudo-tx to ttEXPORT_FINALIZE (type 90) to make room for a user-submittable ttEXPORT (type 91). ttEXPORT allows non-hook users to submit export transactions directly, creating the same ltEXPORTED_TXN entries that the hook xport() API creates inline. Extract shared logic into ExportLedgerOps.h: - createExportedTxn(): creates ltEXPORTED_TXN, enforces directory cap - validateNetworkID(): self-target and unconfigured guards - validateExportAccount(): account ownership check Both the hook API (HookAPI.cpp) and the Export transactor now call into ExportLedgerOps, eliminating duplicated validation and ledger mutation code.
This commit is contained in:
@@ -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
|
||||
"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -685,7 +685,7 @@ isPseudoTx(STObject const& tx)
|
||||
auto tt = safe_cast<TxType>(*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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <xrpld/app/hook/HookAPI.h>
|
||||
#include <xrpld/app/ledger/OpenLedger.h>
|
||||
#include <xrpld/app/ledger/TransactionMaster.h>
|
||||
#include <xrpld/app/tx/detail/ExportLedgerOps.h>
|
||||
#include <xrpld/app/tx/detail/Import.h>
|
||||
#include <xrpl/protocol/STParsedJSON.h>
|
||||
#include <cfenv>
|
||||
@@ -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<Transaction>(stpTrans, reason, app);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <xrpld/app/misc/NetworkOPs.h>
|
||||
#include <xrpld/app/misc/Transaction.h>
|
||||
#include <xrpld/app/misc/TxQ.h>
|
||||
#include <xrpld/app/tx/detail/ExportLedgerOps.h>
|
||||
#include <xrpld/app/tx/detail/Import.h>
|
||||
#include <xrpld/app/tx/detail/NFTokenUtils.h>
|
||||
#include <xrpld/ledger/View.h>
|
||||
@@ -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<const ripple::STTx> 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<SLE const> 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<SLE>(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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<STObject>(
|
||||
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<ripple::Serializer>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
78
src/xrpld/app/tx/detail/Export.cpp
Normal file
78
src/xrpld/app/tx/detail/Export.cpp
Normal file
@@ -0,0 +1,78 @@
|
||||
#include <xrpld/app/tx/detail/Export.h>
|
||||
#include <xrpld/app/tx/detail/ExportLedgerOps.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/TxFlags.h>
|
||||
|
||||
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<STTx&>(ctx.tx)
|
||||
.peekAtField(sfExportedTxn)
|
||||
.downcast<STObject>();
|
||||
|
||||
Serializer s;
|
||||
exportedObj.add(s);
|
||||
SerialIter sit(s.slice());
|
||||
|
||||
std::shared_ptr<STTx const> stpTrans;
|
||||
try
|
||||
{
|
||||
stpTrans = std::make_shared<STTx const>(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<STObject>();
|
||||
|
||||
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
|
||||
33
src/xrpld/app/tx/detail/Export.h
Normal file
33
src/xrpld/app/tx/detail/Export.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#ifndef RIPPLE_TX_EXPORT_H_INCLUDED
|
||||
#define RIPPLE_TX_EXPORT_H_INCLUDED
|
||||
|
||||
#include <xrpld/app/tx/detail/Transactor.h>
|
||||
|
||||
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
|
||||
165
src/xrpld/app/tx/detail/ExportLedgerOps.h
Normal file
165
src/xrpld/app/tx/detail/ExportLedgerOps.h
Normal file
@@ -0,0 +1,165 @@
|
||||
#ifndef RIPPLE_TX_EXPORTLEDGEROPS_H_INCLUDED
|
||||
#define RIPPLE_TX_EXPORTLEDGEROPS_H_INCLUDED
|
||||
|
||||
#include <xrpld/app/main/Application.h>
|
||||
#include <xrpld/app/misc/HashRouter.h>
|
||||
#include <xrpld/ledger/ApplyView.h>
|
||||
#include <xrpld/ledger/View.h>
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/protocol/ExportLimits.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/STTx.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
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<SLE const> 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<SLE>(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
|
||||
@@ -43,6 +43,7 @@
|
||||
#include <xrpld/app/tx/detail/DeleteOracle.h>
|
||||
#include <xrpld/app/tx/detail/DepositPreauth.h>
|
||||
#include <xrpld/app/tx/detail/Escrow.h>
|
||||
#include <xrpld/app/tx/detail/Export.h>
|
||||
#include <xrpld/app/tx/detail/GenesisMint.h>
|
||||
#include <xrpld/app/tx/detail/Import.h>
|
||||
#include <xrpld/app/tx/detail/Invoke.h>
|
||||
|
||||
Reference in New Issue
Block a user