From 526b60bf3d1b6d1bcba61b3b7b4f9344ef961023 Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Mon, 8 Jun 2026 14:37:58 +0700 Subject: [PATCH] refactor(hook): extract xport wrapper builder --- src/test/app/XportWrapperBuilder_test.cpp | 283 ++++++++++++++++++ src/xrpld/app/hook/detail/HookAPI.cpp | 123 ++------ .../app/hook/detail/XportWrapperBuilder.cpp | 108 +++++++ .../app/hook/detail/XportWrapperBuilder.h | 54 ++++ 4 files changed, 468 insertions(+), 100 deletions(-) create mode 100644 src/test/app/XportWrapperBuilder_test.cpp create mode 100644 src/xrpld/app/hook/detail/XportWrapperBuilder.cpp create mode 100644 src/xrpld/app/hook/detail/XportWrapperBuilder.h diff --git a/src/test/app/XportWrapperBuilder_test.cpp b/src/test/app/XportWrapperBuilder_test.cpp new file mode 100644 index 000000000..6dae4abbf --- /dev/null +++ b/src/test/app/XportWrapperBuilder_test.cpp @@ -0,0 +1,283 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ripple { +namespace test { +namespace { + +uint256 +makeHash(char const* label) +{ + return sha512Half(Slice(label, std::strlen(label))); +} + +STTx +makeSTTx(STObject const& obj) +{ + Serializer s; + obj.add(s); + SerialIter sit{s.slice()}; + return STTx{std::ref(sit)}; +} + +Blob +serialize(STTx const& tx) +{ + Serializer s; + tx.add(s); + return {s.begin(), s.end()}; +} + +STTx +makeExportedPayment( + AccountID const& src, + AccountID const& dst, + std::optional networkID = std::nullopt) +{ + STObject obj(sfExportedTxn); + obj.setFieldU16(sfTransactionType, ttPAYMENT); + obj.setFieldU32(sfFlags, tfFullyCanonicalSig); + obj.setFieldU32(sfSequence, 0); + obj.setFieldU32(sfTicketSequence, 1); + obj.setFieldU32(sfFirstLedgerSequence, 2); + obj.setFieldU32(sfLastLedgerSequence, 6); + obj.setFieldAmount(sfAmount, XRPAmount{1000000}); + obj.setFieldAmount(sfFee, XRPAmount{10}); + obj.setFieldVL(sfSigningPubKey, Blob{}); + obj.setAccountID(sfAccount, src); + obj.setAccountID(sfDestination, dst); + if (networkID) + obj.setFieldU32(sfNetworkID, *networkID); + return makeSTTx(obj); +} + +beast::Journal +nullJournal() +{ + return beast::Journal{beast::Journal::getNullSink()}; +} + +hook::XportWrapperBuilder::Input +makeInput( + Slice innerTxBlob, + AccountID const& exporter, + std::uint32_t networkID = 21337, + hook::XportWrapperBuilder::NonceGenerator generateNonce = + [] { + return Expected{ + makeHash("nonce")}; + }, + hook::XportWrapperBuilder::FeeCalculator calculateFee = + [](Slice const&) { + return Expected{12345}; + }) +{ + return hook::XportWrapperBuilder::Input{ + innerTxBlob, + exporter, + networkID, + 10, + makeHash("parent-tx"), + makeHash("hook-hash"), + true, + 3, + 7, + std::move(generateNonce), + std::move(calculateFee), + nullJournal()}; +} + +} // namespace + +class XportWrapperBuilder_test : public beast::unit_test::suite +{ +public: + void + testBuildsWrapper() + { + testcase("builds xport wrapper"); + + auto const exporter = randomKeyPair(KeyType::secp256k1); + auto const dst = randomKeyPair(KeyType::secp256k1); + auto const innerTx = makeExportedPayment( + calcAccountID(exporter.first), calcAccountID(dst.first)); + 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& wrapper = result->wrapperTx; + BEAST_EXPECT(result->innerTxHash == innerTx.getTransactionID()); + BEAST_EXPECT(wrapper.getTxnType() == ttEXPORT); + BEAST_EXPECT( + wrapper.getAccountID(sfAccount) == calcAccountID(exporter.first)); + BEAST_EXPECT(wrapper.getFieldU32(sfSequence) == 0); + BEAST_EXPECT(wrapper.getFieldU32(sfFirstLedgerSequence) == 11); + BEAST_EXPECT(wrapper.getFieldU32(sfLastLedgerSequence) == 15); + BEAST_EXPECT(wrapper.getFieldAmount(sfFee) == STAmount{12345}); + BEAST_EXPECT(wrapper.getFieldVL(sfSigningPubKey).empty()); + + auto const& exported = + wrapper.peekAtField(sfExportedTxn).downcast(); + Serializer exportedSer; + exported.add(exportedSer); + STTx parsedInner{SerialIter{exportedSer.slice()}}; + BEAST_EXPECT( + parsedInner.getTransactionID() == innerTx.getTransactionID()); + + auto const& emitDetails = + wrapper.peekAtField(sfEmitDetails).downcast(); + BEAST_EXPECT(emitDetails.getFieldU32(sfEmitGeneration) == 3); + BEAST_EXPECT(emitDetails.getFieldU64(sfEmitBurden) == 7); + BEAST_EXPECT( + emitDetails.getFieldH256(sfEmitParentTxnID) == + makeHash("parent-tx")); + BEAST_EXPECT( + emitDetails.getFieldH256(sfEmitNonce) == makeHash("nonce")); + BEAST_EXPECT( + emitDetails.getFieldH256(sfEmitHookHash) == makeHash("hook-hash")); + BEAST_EXPECT( + emitDetails.getAccountID(sfEmitCallback) == + calcAccountID(exporter.first)); + } + + void + testRejectsInvalidInputs() + { + testcase("rejects invalid inputs"); + + auto const exporter = randomKeyPair(KeyType::secp256k1); + auto const other = randomKeyPair(KeyType::secp256k1); + auto const dst = randomKeyPair(KeyType::secp256k1); + auto const innerTx = makeExportedPayment( + calcAccountID(exporter.first), calcAccountID(dst.first)); + auto const serialized = serialize(innerTx); + + { + Blob malformed{1, 2, 3}; + bool nonceCalled = false; + auto const result = hook::XportWrapperBuilder::build(makeInput( + Slice(malformed.data(), malformed.size()), + calcAccountID(exporter.first), + 21337, + [&nonceCalled] { + nonceCalled = true; + return Expected{ + makeHash("nonce")}; + })); + BEAST_EXPECT(!result); + BEAST_EXPECT(result.error() == hook_api::EXPORT_FAILURE); + BEAST_EXPECT(!nonceCalled); + } + + { + bool nonceCalled = false; + auto const result = hook::XportWrapperBuilder::build(makeInput( + Slice(serialized.data(), serialized.size()), + calcAccountID(other.first), + 21337, + [&nonceCalled] { + nonceCalled = true; + return Expected{ + makeHash("nonce")}; + })); + BEAST_EXPECT(!result); + BEAST_EXPECT(result.error() == hook_api::EXPORT_FAILURE); + BEAST_EXPECT(!nonceCalled); + } + + { + auto const networkTx = makeExportedPayment( + calcAccountID(exporter.first), calcAccountID(dst.first), 21337); + auto const serializedNetwork = serialize(networkTx); + bool nonceCalled = false; + auto const result = hook::XportWrapperBuilder::build(makeInput( + Slice(serializedNetwork.data(), serializedNetwork.size()), + calcAccountID(exporter.first), + 21337, + [&nonceCalled] { + nonceCalled = true; + return Expected{ + makeHash("nonce")}; + })); + BEAST_EXPECT(!result); + BEAST_EXPECT(result.error() == hook_api::EXPORT_FAILURE); + BEAST_EXPECT(!nonceCalled); + } + } + + void + testRejectsFeeFailure() + { + testcase("rejects fee failure"); + + auto const exporter = randomKeyPair(KeyType::secp256k1); + auto const dst = randomKeyPair(KeyType::secp256k1); + auto const innerTx = makeExportedPayment( + calcAccountID(exporter.first), calcAccountID(dst.first)); + auto const serialized = serialize(innerTx); + + auto const result = hook::XportWrapperBuilder::build(makeInput( + Slice(serialized.data(), serialized.size()), + calcAccountID(exporter.first), + 21337, + [] { + return Expected{ + makeHash("nonce")}; + }, + [](Slice const&) { + return Expected{ + Unexpected(hook_api::EXPORT_FAILURE)}; + })); + + BEAST_EXPECT(!result); + BEAST_EXPECT(result.error() == hook_api::EXPORT_FAILURE); + } + + void + run() override + { + testBuildsWrapper(); + testRejectsInvalidInputs(); + testRejectsFeeFailure(); + } +}; + +BEAST_DEFINE_TESTSUITE(XportWrapperBuilder, app, ripple); + +} // namespace test +} // namespace ripple diff --git a/src/xrpld/app/hook/detail/HookAPI.cpp b/src/xrpld/app/hook/detail/HookAPI.cpp index e3e05a235..0ce8fd4f0 100644 --- a/src/xrpld/app/hook/detail/HookAPI.cpp +++ b/src/xrpld/app/hook/detail/HookAPI.cpp @@ -1,6 +1,7 @@ // Implementation of decoupled Hook APIs for emit and related helpers. #include +#include #include #include #include @@ -1261,106 +1262,28 @@ HookAPI::xport(Slice const& txBlob) const if (hookCtx.export_count >= hookCtx.expected_export_count) return Unexpected(TOO_MANY_EXPORTED_TXN); - // Parse and validate the inner (cross-chain) transaction. - std::shared_ptr innerTx; - try - { - SerialIter sit(txBlob); - innerTx = std::make_shared(sit); - } - catch (std::exception const& e) - { - JLOG(j.trace()) << "HookExport[" << HC_ACC() << "]: Failed " - << e.what(); - return Unexpected(EXPORT_FAILURE); - } + auto const burdenResult = etxn_burden(); + auto built = XportWrapperBuilder::build(XportWrapperBuilder::Input{ + txBlob, + hookCtx.result.account, + app.config().NETWORK_ID, + view.info().seq, + applyCtx.tx.getTransactionID(), + hookCtx.result.hookHash, + hookCtx.result.hasCallback, + static_cast(etxn_generation()), + burdenResult ? static_cast(*burdenResult) : 1ULL, + [this]() { return etxn_nonce(); }, + [this](Slice const& serializedWrapper) { + return etxn_fee_base(serializedWrapper); + }, + j}); + if (!built) + return Unexpected(built.error()); - if (auto ter = ExportLedgerOps::validateExportAccount( - *innerTx, hookCtx.result.account, j); - !isTesSuccess(ter)) - return Unexpected(EXPORT_FAILURE); - - if (auto ter = ExportLedgerOps::validateNetworkID( - *innerTx, app.config().NETWORK_ID, j); - !isTesSuccess(ter)) - return Unexpected(EXPORT_FAILURE); - - if (auto ter = ExportLedgerOps::validateTicketSequence(*innerTx, j); - !isTesSuccess(ter)) - return Unexpected(EXPORT_FAILURE); - - // Construct a ttEXPORT wrapping the inner tx, with EmitDetails, - // and push onto the emitted txn queue. This flows through the - // normal emitted txn path (emitted dir → TxQ injection → open - // ledger → retriable Export transactor). - uint32_t const ledgerSeq = view.info().seq; - - // Generate a nonce for the emitted ttEXPORT wrapper. - auto nonce = etxn_nonce(); - if (!nonce.has_value()) - return Unexpected(INTERNAL_ERROR); - - // Serialize inner tx as sfExportedTxn object. - Serializer innerSer; - innerTx->add(innerSer); - - // Build the ttEXPORT wrapper as an STObject first so we can - // compute the fee, set it, then construct the STTx from the - // final serialised bytes. This avoids mutating the STTx after - // construction (which would leave a stale cached txid — see - // the tefNONDIR_EMIT check in Transactor::preclaim). - // - // The fee field is a fixed 9 bytes regardless of value, so - // patching it on the STObject doesn't change the serialised size. - STObject exportObj(sfGeneric); - { - exportObj.setFieldU16(sfTransactionType, ttEXPORT); - exportObj[sfAccount] = hookCtx.result.account; - exportObj[sfSequence] = 0u; - exportObj.setFieldVL(sfSigningPubKey, Blob{}); - exportObj[sfFirstLedgerSequence] = ledgerSeq + 1; - exportObj[sfLastLedgerSequence] = ledgerSeq + 5; - exportObj[sfFee] = STAmount{0}; - - // sfExportedTxn inner object - SerialIter sit(innerSer.slice()); - exportObj.set(std::make_unique(sit, sfExportedTxn)); - - // sfEmitDetails - STObject emitDetails(sfEmitDetails); - emitDetails.setFieldU32( - sfEmitGeneration, static_cast(etxn_generation())); - { - auto const burdenResult = etxn_burden(); - emitDetails.setFieldU64( - sfEmitBurden, - burdenResult ? static_cast(*burdenResult) : 1ULL); - } - emitDetails.setFieldH256( - sfEmitParentTxnID, applyCtx.tx.getTransactionID()); - emitDetails.setFieldH256(sfEmitNonce, *nonce); - emitDetails.setFieldH256(sfEmitHookHash, hookCtx.result.hookHash); - if (hookCtx.result.hasCallback) - emitDetails.setAccountID(sfEmitCallback, hookCtx.result.account); - exportObj.set(std::move(emitDetails)); - - // Compute fee from serialised size and patch it in. - Serializer feeSer; - exportObj.add(feeSer); - auto feeResult = etxn_fee_base(feeSer.slice()); - if (!feeResult) - { - JLOG(j.trace()) << "HookExport[" << HC_ACC() - << "]: Fee calculation failed for ttEXPORT wrapper"; - return Unexpected(EXPORT_FAILURE); - } - exportObj[sfFee] = STAmount{static_cast(*feeResult)}; - } - - // Construct the STTx from the finalised STObject bytes. - Serializer exportSer; - exportObj.add(exportSer); - STTx exportStx(SerialIter{exportSer.slice()}); + auto builtValue = std::move(built.value()); + auto innerTxHash = builtValue.innerTxHash; + auto exportStx = std::move(builtValue.wrapperTx); // Preflight the wrapper. auto preflightResult = ripple::preflight( @@ -1393,7 +1316,7 @@ HookAPI::xport(Slice const& txBlob) const // Return the inner tx hash — this is what the hook author cares // about (the cross-chain transaction they built). - return innerTx->getTransactionID(); + return innerTxHash; } Expected diff --git a/src/xrpld/app/hook/detail/XportWrapperBuilder.cpp b/src/xrpld/app/hook/detail/XportWrapperBuilder.cpp new file mode 100644 index 000000000..d83755c2f --- /dev/null +++ b/src/xrpld/app/hook/detail/XportWrapperBuilder.cpp @@ -0,0 +1,108 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace hook { +namespace XportWrapperBuilder { + +using namespace ripple; + +Expected +build(Input const& input) +{ + std::shared_ptr innerTx; + try + { + SerialIter sit(input.innerTxBlob); + innerTx = std::make_shared(sit); + } + catch (std::exception const& e) + { + JLOG(input.j.trace()) << "HookExport: Failed " << e.what(); + return Unexpected(hook_api::EXPORT_FAILURE); + } + + if (auto ter = ExportLedgerOps::validateExportAccount( + *innerTx, input.exporter, input.j); + !isTesSuccess(ter)) + return Unexpected(hook_api::EXPORT_FAILURE); + + if (auto ter = ExportLedgerOps::validateNetworkID( + *innerTx, input.networkID, input.j); + !isTesSuccess(ter)) + return Unexpected(hook_api::EXPORT_FAILURE); + + if (auto ter = ExportLedgerOps::validateTicketSequence(*innerTx, input.j); + !isTesSuccess(ter)) + return Unexpected(hook_api::EXPORT_FAILURE); + + if (!input.generateNonce) + { + JLOG(input.j.trace()) + << "HookExport: Nonce callback missing for ttEXPORT wrapper"; + return Unexpected(hook_api::INTERNAL_ERROR); + } + + auto nonce = input.generateNonce(); + if (!nonce) + return Unexpected(nonce.error()); + + Serializer innerSer; + innerTx->add(innerSer); + + STObject exportObj(sfGeneric); + exportObj.setFieldU16(sfTransactionType, ttEXPORT); + exportObj[sfAccount] = input.exporter; + exportObj[sfSequence] = 0u; + exportObj.setFieldVL(sfSigningPubKey, Blob{}); + exportObj[sfFirstLedgerSequence] = input.ledgerSeq + 1; + exportObj[sfLastLedgerSequence] = input.ledgerSeq + 5; + exportObj[sfFee] = STAmount{0}; + + SerialIter sit(innerSer.slice()); + exportObj.set(std::make_unique(sit, sfExportedTxn)); + + STObject emitDetails(sfEmitDetails); + emitDetails.setFieldU32(sfEmitGeneration, input.emitGeneration); + emitDetails.setFieldU64(sfEmitBurden, input.emitBurden); + emitDetails.setFieldH256(sfEmitParentTxnID, input.parentTxnID); + emitDetails.setFieldH256(sfEmitNonce, *nonce); + emitDetails.setFieldH256(sfEmitHookHash, input.hookHash); + if (input.hasCallback) + emitDetails.setAccountID(sfEmitCallback, input.exporter); + exportObj.set(std::move(emitDetails)); + + if (!input.calculateFee) + { + JLOG(input.j.trace()) << "HookExport: Fee calculation callback missing " + "for ttEXPORT wrapper"; + return Unexpected(hook_api::EXPORT_FAILURE); + } + + Serializer feeSer; + exportObj.add(feeSer); + auto feeResult = input.calculateFee(feeSer.slice()); + if (!feeResult) + { + JLOG(input.j.trace()) + << "HookExport: Fee calculation failed for ttEXPORT wrapper"; + return Unexpected(hook_api::EXPORT_FAILURE); + } + exportObj[sfFee] = STAmount{static_cast(*feeResult)}; + + Serializer exportSer; + exportObj.add(exportSer); + STTx wrapperTx(SerialIter{exportSer.slice()}); + + return Result{std::move(wrapperTx), innerTx->getTransactionID()}; +} + +} // namespace XportWrapperBuilder +} // namespace hook diff --git a/src/xrpld/app/hook/detail/XportWrapperBuilder.h b/src/xrpld/app/hook/detail/XportWrapperBuilder.h new file mode 100644 index 000000000..7e90db5b1 --- /dev/null +++ b/src/xrpld/app/hook/detail/XportWrapperBuilder.h @@ -0,0 +1,54 @@ +#ifndef RIPPLE_HOOK_XPORTWRAPPERBUILDER_H_INCLUDED +#define RIPPLE_HOOK_XPORTWRAPPERBUILDER_H_INCLUDED + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace hook { +namespace XportWrapperBuilder { + +using HookReturnCode = hook_api::hook_return_code; +using FeeCalculator = + std::function( + ripple::Slice)>; +using NonceGenerator = + std::function()>; + +struct Input +{ + ripple::Slice innerTxBlob; + ripple::AccountID exporter; + std::uint32_t networkID = 0; + ripple::LedgerIndex ledgerSeq = 0; + ripple::uint256 parentTxnID; + ripple::uint256 hookHash; + bool hasCallback = false; + std::uint32_t emitGeneration = 0; + std::uint64_t emitBurden = 1; + NonceGenerator generateNonce; + FeeCalculator calculateFee; + beast::Journal j; +}; + +struct Result +{ + ripple::STTx wrapperTx; + ripple::uint256 innerTxHash; +}; + +ripple::Expected +build(Input const& input); + +} // namespace XportWrapperBuilder +} // namespace hook + +#endif