diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index 228c8bd41..51e929259 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -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 diff --git a/src/test/app/XportWrapperBuilder_test.cpp b/src/test/app/XportWrapperBuilder_test.cpp index 6e4937dd0..b26a6abb5 100644 --- a/src/test/app/XportWrapperBuilder_test.cpp +++ b/src/test/app/XportWrapperBuilder_test.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -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(); + 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(); diff --git a/src/xrpld/app/hook/detail/XportWrapperBuilder.cpp b/src/xrpld/app/hook/detail/XportWrapperBuilder.cpp index 42d06d974..b68310ce0 100644 --- a/src/xrpld/app/hook/detail/XportWrapperBuilder.cpp +++ b/src/xrpld/app/hook/detail/XportWrapperBuilder.cpp @@ -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); diff --git a/src/xrpld/app/tx/detail/Export.cpp b/src/xrpld/app/tx/detail/Export.cpp index 07e5f1f40..29e2ee721 100644 --- a/src/xrpld/app/tx/detail/Export.cpp +++ b/src/xrpld/app/tx/detail/Export.cpp @@ -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; diff --git a/src/xrpld/app/tx/detail/ExportLedgerOps.h b/src/xrpld/app/tx/detail/ExportLedgerOps.h index d5620a7c9..af089a4db 100644 --- a/src/xrpld/app/tx/detail/ExportLedgerOps.h +++ b/src/xrpld/app/tx/detail/ExportLedgerOps.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -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(projected.error()); + return temMALFORMED; +} + /// Validate that the exported transaction's Account matches the /// expected exporting account. inline TER