refactor(hook): extract xport wrapper builder

This commit is contained in:
Nicholas Dudfield
2026-06-08 14:37:58 +07:00
parent 9347b47639
commit 526b60bf3d
4 changed files with 468 additions and 100 deletions

View File

@@ -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 <xrpld/app/hook/detail/XportWrapperBuilder.h>
#include <xrpl/basics/Expected.h>
#include <xrpl/beast/unit_test.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/digest.h>
#include <cstring>
#include <optional>
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<std::uint32_t> 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<uint256, hook_api::hook_return_code>{
makeHash("nonce")};
},
hook::XportWrapperBuilder::FeeCalculator calculateFee =
[](Slice const&) {
return Expected<std::uint64_t, hook_api::hook_return_code>{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<STObject>();
Serializer exportedSer;
exported.add(exportedSer);
STTx parsedInner{SerialIter{exportedSer.slice()}};
BEAST_EXPECT(
parsedInner.getTransactionID() == innerTx.getTransactionID());
auto const& emitDetails =
wrapper.peekAtField(sfEmitDetails).downcast<STObject>();
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<uint256, hook_api::hook_return_code>{
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<uint256, hook_api::hook_return_code>{
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<uint256, hook_api::hook_return_code>{
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<uint256, hook_api::hook_return_code>{
makeHash("nonce")};
},
[](Slice const&) {
return Expected<std::uint64_t, hook_api::hook_return_code>{
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

View File

@@ -1,6 +1,7 @@
// Implementation of decoupled Hook APIs for emit and related helpers.
#include <xrpld/app/hook/HookAPI.h>
#include <xrpld/app/hook/detail/XportWrapperBuilder.h>
#include <xrpld/app/ledger/OpenLedger.h>
#include <xrpld/app/ledger/TransactionMaster.h>
#include <xrpld/app/tx/detail/ExportLedgerOps.h>
@@ -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<STTx const> innerTx;
try
{
SerialIter sit(txBlob);
innerTx = std::make_shared<STTx const>(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<uint32_t>(etxn_generation()),
burdenResult ? static_cast<uint64_t>(*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<STObject>(sit, sfExportedTxn));
// sfEmitDetails
STObject emitDetails(sfEmitDetails);
emitDetails.setFieldU32(
sfEmitGeneration, static_cast<uint32_t>(etxn_generation()));
{
auto const burdenResult = etxn_burden();
emitDetails.setFieldU64(
sfEmitBurden,
burdenResult ? static_cast<uint64_t>(*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<uint64_t>(*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<uint64_t, HookReturnCode>

View File

@@ -0,0 +1,108 @@
#include <xrpld/app/hook/detail/XportWrapperBuilder.h>
#include <xrpld/app/tx/detail/ExportLedgerOps.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <memory>
namespace hook {
namespace XportWrapperBuilder {
using namespace ripple;
Expected<Result, HookReturnCode>
build(Input const& input)
{
std::shared_ptr<STTx const> innerTx;
try
{
SerialIter sit(input.innerTxBlob);
innerTx = std::make_shared<STTx const>(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<STObject>(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<std::uint64_t>(*feeResult)};
Serializer exportSer;
exportObj.add(exportSer);
STTx wrapperTx(SerialIter{exportSer.slice()});
return Result{std::move(wrapperTx), innerTx->getTransactionID()};
}
} // namespace XportWrapperBuilder
} // namespace hook

View File

@@ -0,0 +1,54 @@
#ifndef RIPPLE_HOOK_XPORTWRAPPERBUILDER_H_INCLUDED
#define RIPPLE_HOOK_XPORTWRAPPERBUILDER_H_INCLUDED
#include <xrpl/basics/Expected.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/hook/Enum.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STTx.h>
#include <cstdint>
#include <functional>
namespace hook {
namespace XportWrapperBuilder {
using HookReturnCode = hook_api::hook_return_code;
using FeeCalculator =
std::function<ripple::Expected<std::uint64_t, HookReturnCode>(
ripple::Slice)>;
using NonceGenerator =
std::function<ripple::Expected<ripple::uint256, HookReturnCode>()>;
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<Result, HookReturnCode>
build(Input const& input);
} // namespace XportWrapperBuilder
} // namespace hook
#endif