feat(export): validate canonical release memo capacity

This commit is contained in:
Nicholas Dudfield
2026-07-14 12:53:04 +07:00
parent 2da84ef6f7
commit 5c01906a4c
5 changed files with 144 additions and 0 deletions

View File

@@ -946,6 +946,49 @@ struct Export_test : public beast::unit_test::suite
innerTx, maxNetworkIDWithoutTxField + 1, j) == tesSUCCESS);
}
void
testExportRejectsInvalidOriginMemoProjection()
{
testcase("Export validates future origin Memo projection");
jtx::Account const alice{"alice"};
jtx::Account const carol{"carol"};
auto const j = beast::Journal{beast::Journal::getNullSink()};
auto const sourceNetworkID = maxNetworkIDWithoutTxField + 1;
auto innerObj = buildExportedPayment(alice.id(), carol.id(), 2, 6);
BEAST_EXPECT(
ExportLedgerOps::validateOriginMemoProjection(
makeSTTx(innerObj), sourceNetworkID, j) == tesSUCCESS);
STArray memos{sfMemos};
STObject reservedMemo{sfMemo};
reservedMemo.setFieldVL(
sfMemoType,
Blob(
ExportOriginMemo::memoType.begin(),
ExportOriginMemo::memoType.end()));
reservedMemo.setFieldVL(
sfMemoData, Blob(ExportOriginMemo::identityBytes));
memos.emplace_back(std::move(reservedMemo));
innerObj.setFieldArray(sfMemos, memos);
BEAST_EXPECT(
ExportLedgerOps::validateOriginMemoProjection(
makeSTTx(innerObj), sourceNetworkID, j) == temMALFORMED);
memos.clear();
STObject fullMemo{sfMemo};
fullMemo.setFieldVL(sfMemoType, Blob{'u', 's', 'e', 'r'});
fullMemo.setFieldVL(sfMemoData, Blob(930, 0x5A));
memos.emplace_back(std::move(fullMemo));
innerObj.setFieldArray(sfMemos, memos);
std::string reason;
BEAST_EXPECT(passesLocalChecks(makeSTTx(innerObj), reason));
BEAST_EXPECT(
ExportLedgerOps::validateOriginMemoProjection(
makeSTTx(innerObj), sourceNetworkID, j) == temMALFORMED);
}
void
testXportEmissionLimit(FeatureBitset features)
{
@@ -2359,6 +2402,7 @@ struct Export_test : public beast::unit_test::suite
testXportRejectsLocalNetworkID(allWithExport);
testXportRejectsUnconfiguredNetworkID(allWithExport);
testExportRejectsAmbiguousAbsentNetworkID();
testExportRejectsInvalidOriginMemoProjection();
testXportEmissionLimit(allWithExport);
// ttEXPORT transactor tests

View File

@@ -20,6 +20,7 @@
#include <xrpl/basics/Expected.h>
#include <xrpl/beast/unit_test.h>
#include <xrpl/protocol/ExportLimits.h>
#include <xrpl/protocol/ExportOriginMemo.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STArray.h>
#include <xrpl/protocol/STObject.h>
@@ -85,6 +86,19 @@ makeExportedPayment(
return makeSTTx(obj);
}
STTx
withMemo(STTx tx, Blob type, Blob data)
{
STArray memos = tx.isFieldPresent(sfMemos) ? tx.getFieldArray(sfMemos)
: STArray{sfMemos};
STObject memo{sfMemo};
memo.setFieldVL(sfMemoType, std::move(type));
memo.setFieldVL(sfMemoData, std::move(data));
memos.emplace_back(std::move(memo));
tx.setFieldArray(sfMemos, memos);
return tx;
}
beast::Journal
nullJournal()
{
@@ -208,6 +222,37 @@ public:
BEAST_EXPECT(!emitDetails.isFieldPresent(sfEmitCallback));
}
void
testPreservesUserMemos()
{
testcase("preserves user memos");
auto const exporter = randomKeyPair(KeyType::secp256k1);
auto const dst = randomKeyPair(KeyType::secp256k1);
auto const innerTx = withMemo(
makeExportedPayment(
calcAccountID(exporter.first), calcAccountID(dst.first)),
Blob{'u', 's', 'e', 'r'},
Blob{1, 2, 3, 4});
auto const serialized = serialize(innerTx);
auto const result = hook::XportWrapperBuilder::build(makeInput(
Slice(serialized.data(), serialized.size()),
calcAccountID(exporter.first)));
BEAST_EXPECT(result);
if (!result)
return;
auto const& exported =
result->wrapperTx.peekAtField(sfExportedTxn).downcast<STObject>();
Serializer exportedSer;
exported.add(exportedSer);
STTx parsedInner{SerialIter{exportedSer.slice()}};
BEAST_EXPECT(serialize(parsedInner) == serialized);
BEAST_EXPECT(!ExportOriginMemo::hasReservedMemo(parsedInner));
}
void
testRejectsInvalidInputs()
{
@@ -382,6 +427,23 @@ public:
result.error() == ::hook_api::hook_return_code::EXPORT_FAILURE);
BEAST_EXPECT(!nonceCalled);
}
{
auto const reservedType = Blob(
ExportOriginMemo::memoType.begin(),
ExportOriginMemo::memoType.end());
auto const reservedMemoTx = withMemo(
innerTx, reservedType, Blob(ExportOriginMemo::identityBytes));
expectSigningEnvelopeRejected(reservedMemoTx);
}
{
auto const fullMemoTx =
withMemo(innerTx, Blob{'u', 's', 'e', 'r'}, Blob(930, 0x5A));
std::string reason;
BEAST_EXPECT(passesLocalChecks(fullMemoTx, reason));
expectSigningEnvelopeRejected(fullMemoTx);
}
}
void
@@ -479,6 +541,7 @@ public:
{
testBuildsWrapper();
testBuildsWrapperWithoutCallback();
testPreservesUserMemos();
testRejectsInvalidInputs();
testRejectsMissingCallbacks();
testMapsNonceFailureToInternalError();

View File

@@ -45,6 +45,11 @@ build(Input const& input)
!isTesSuccess(ter))
return Unexpected(::hook_api::hook_return_code::EXPORT_FAILURE);
if (auto ter = ExportLedgerOps::validateOriginMemoProjection(
*innerTx, input.networkID, input.j);
!isTesSuccess(ter))
return Unexpected(::hook_api::hook_return_code::EXPORT_FAILURE);
if (auto ter = ExportLedgerOps::validateTicketSequence(*innerTx, input.j);
!isTesSuccess(ter))
return Unexpected(::hook_api::hook_return_code::EXPORT_FAILURE);

View File

@@ -71,6 +71,11 @@ Export::preclaim(PreclaimContext const& ctx)
!isTesSuccess(ter))
return ter;
if (auto ter = ExportLedgerOps::validateOriginMemoProjection(
*innerTx, ctx.app.config().NETWORK_ID, ctx.j);
!isTesSuccess(ter))
return ter;
if (auto ter = ExportLedgerOps::validateTicketSequence(*innerTx, ctx.j);
!isTesSuccess(ter))
return ter;

View File

@@ -6,6 +6,7 @@
#include <xrpld/ledger/View.h>
#include <xrpl/basics/Log.h>
#include <xrpl/protocol/ExportLimits.h>
#include <xrpl/protocol/ExportOriginMemo.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STObject.h>
@@ -183,6 +184,32 @@ validateNetworkID(
return tesSUCCESS;
}
/// Validate the embedded target transaction and its future canonical release
/// projection before accepting an Export intent. The all-zero origin/anchor
/// values are size-equivalent placeholders; live signing later substitutes
/// the real source transaction and validated-ledger identity.
inline TER
validateOriginMemoProjection(
STTx const& stx,
std::uint32_t sourceNetworkID,
beast::Journal j)
{
auto const targetNetworkID = stx.isFieldPresent(sfNetworkID)
? stx.getFieldU32(sfNetworkID)
: std::uint32_t{0};
auto const projected = ExportOriginMemo::releaseForm(
stx,
ExportOriginMemo::Origin{sourceNetworkID, targetNetworkID, uint256{}},
ExportOriginMemo::Anchor{0, uint256{}});
if (projected)
return tesSUCCESS;
JLOG(j.warn())
<< "ExportLedgerOps: exported tx cannot form canonical origin Memo"
<< " error=" << static_cast<unsigned>(projected.error());
return temMALFORMED;
}
/// Validate that the exported transaction's Account matches the
/// expected exporting account.
inline TER