Files
xahaud/src/test/app/Export_test.cpp
2026-06-28 15:49:06 +07:00

2057 lines
72 KiB
C++

//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL Labs
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 <test/app/Export_test_hooks.h>
#include <test/jtx.h>
#include <test/jtx/hook.h>
#include <test/jtx/import.h>
#include <test/jtx/unl.h>
#include <test/jtx/xpop.h>
#include <xrpld/app/consensus/ConsensusExtensions.h>
#include <xrpld/app/ledger/BuildLedger.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/ledger/LedgerReplay.h>
#include <xrpld/app/ledger/OpenLedger.h>
#include <xrpld/app/misc/CanonicalTXSet.h>
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/misc/RuntimeConfig.h>
#include <xrpld/app/misc/ValidatorKeys.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/detail/ExportLedgerOps.h>
#include <xrpld/app/tx/detail/ExportResultBuilder.h>
#include <xrpld/shamap/SHAMap.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/ExportLimits.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Import.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/jss.h>
#include <map>
#include <set>
namespace ripple {
namespace test {
using TestHook = std::vector<uint8_t> const&;
// Large fee for hook operations
#define HSFEE fee(100'000'000)
struct Export_test : public beast::unit_test::suite
{
static std::unique_ptr<Config>
exportTestConfig()
{
auto cfg = jtx::envconfig(jtx::validator, "");
cfg->NETWORK_ID = 21337;
return cfg;
}
static void
forceNonStandalone(Application& app)
{
const_cast<Config&>(app.config()).setupControl(true, true, false);
}
static STTx
makeSTTx(STObject const& obj)
{
Serializer s;
obj.add(s);
SerialIter sit{s.slice()};
return STTx{std::ref(sit)};
}
static RCLTxSet
makeRCLTxSet(
Application& app,
std::vector<std::shared_ptr<STTx const>> const& txns)
{
auto map = std::make_shared<SHAMap>(
SHAMapType::TRANSACTION, app.getNodeFamily());
map->setUnbacked();
for (auto const& tx : txns)
{
Serializer s;
tx->add(s);
map->addItem(
SHAMapNodeType::tnTRANSACTION_NM,
make_shamapitem(tx->getTransactionID(), s.slice()));
}
return RCLTxSet{map->snapShot(false)};
}
static ExportResultBuilder::SignatureWitnesses
makeExportSignatureWitnesses(
uint256 const& exportTxHash,
ExportResultBuilder::SignatureSnapshot signatures,
LedgerIndex seq)
{
auto witness = ExportResultBuilder::buildSignatureWitness(
exportTxHash, signatures, seq);
auto const witnessHash = witness.getTransactionID();
ExportResultBuilder::SignatureWitnesses witnesses;
witnesses.emplace(
exportTxHash,
ExportResultBuilder::SignatureWitness{
witnessHash, std::move(signatures)});
return witnesses;
}
static std::uint32_t
importVLSequence(jtx::Env const& env, PublicKey const& pk)
{
auto const sle = env.le(keylet::import_vlseq(pk));
if (sle && sle->isFieldPresent(sfImportSequence))
return (*sle)[sfImportSequence];
return 0;
}
struct CallbackXPOP
{
Json::Value xpopJson;
std::uint32_t ticketSeq;
std::optional<std::pair<std::uint32_t, PublicKey>> vlInfo;
};
CallbackXPOP
buildExportCallbackXPOP(
jtx::Env& xahau,
jtx::xpop::TestXPOPContext const& xpopCtx,
jtx::Account const& alice,
jtx::Account const& carol,
std::uint32_t targetNetworkID,
std::uint32_t ticketSeq)
{
using namespace jtx;
STObject innerObj(sfExportedTxn);
innerObj.setFieldU16(sfTransactionType, ttPAYMENT);
innerObj.setFieldU32(sfFlags, tfFullyCanonicalSig);
innerObj.setFieldU32(sfSequence, 0);
innerObj.setFieldU32(sfTicketSequence, ticketSeq);
innerObj.setFieldU32(sfNetworkID, targetNetworkID);
innerObj.setFieldU32(sfLastLedgerSequence, 100);
innerObj.setFieldAmount(sfAmount, XRPAmount{1000000});
innerObj.setFieldAmount(sfFee, XRPAmount{20});
innerObj.setFieldVL(sfSigningPubKey, Blob{});
innerObj.setAccountID(sfAccount, alice.id());
innerObj.setAccountID(sfDestination, carol.id());
Json::Value jvExport;
jvExport[jss::TransactionType] = jss::Export;
jvExport[jss::Account] = alice.human();
jvExport[jss::LastLedgerSequence] = xahau.current()->seq() + 5;
jvExport[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
xahau(jvExport, fee(XRP(1)), ter(tesSUCCESS));
xahau.close();
auto const exportMeta = xahau.meta();
BEAST_EXPECT(exportMeta);
Blob multisignedBlob;
if (exportMeta && exportMeta->isFieldPresent(sfExportResult))
{
auto const& result =
exportMeta->peekAtField(sfExportResult).downcast<STObject>();
BEAST_EXPECT(result.isFieldPresent(sfExportSignatureHash));
if (result.isFieldPresent(sfExportSignatureHash))
{
auto const witnessHash =
result.getFieldH256(sfExportSignatureHash);
auto const witnessRead = xahau.current()->txRead(witnessHash);
auto const& witnessTx = witnessRead.first;
BEAST_EXPECT(witnessTx);
auto signatures = witnessTx
? ExportResultBuilder::signaturesFromWitness(*witnessTx)
: std::nullopt;
BEAST_EXPECT(signatures);
STTx const innerTx = makeSTTx(innerObj);
auto expTxn = ExportResultBuilder::buildMultiSignedExportedTxn(
innerTx,
signatures ? *signatures
: ExportResultBuilder::SignatureSnapshot{});
Serializer s;
expTxn.add(s);
multisignedBlob = s.peekData();
log << "Xahau: ExportResult.ExportSignatureHash = "
<< witnessHash << std::endl;
log << "Xahau: assembled ExportedTxn = "
<< expTxn.getJson(JsonOptions::none).toStyledString()
<< std::endl;
}
}
log << "Xahau: multisigned blob size = " << multisignedBlob.size()
<< std::endl;
BEAST_EXPECT(!multisignedBlob.empty());
BEAST_EXPECT(xahau.current()->exists(
keylet::shadowTicket(alice.id(), ticketSeq)));
Env xrpl{*this, xpopCtx.makeEnvConfig(targetNetworkID)};
xrpl.fund(XRP(10000), alice, carol);
xrpl.close();
std::uint32_t const xrplTicketSeq = xrpl.seq(alice) + 1;
xrpl(ticket::create(alice, 1));
xrpl.close();
BEAST_EXPECT(xrplTicketSeq == ticketSeq);
auto const valPK = xahau.app().getValidationPublicKey();
auto const valSK = xahau.app().getValidationSecretKey();
BEAST_EXPECT(valPK.has_value());
auto const valAccountID = calcAccountID(*valPK);
log << "XRPL: validator AccountID = " << toBase58(valAccountID)
<< std::endl;
log << "XRPL: validator PK = " << strHex(*valPK) << std::endl;
{
Json::Value jvSigners;
jvSigners[jss::TransactionType] = jss::SignerListSet;
jvSigners[jss::Account] = alice.human();
jvSigners[sfSignerQuorum.jsonName] = 1;
Json::Value signerEntry(Json::objectValue);
signerEntry[sfAccount.jsonName] = toBase58(valAccountID);
signerEntry[sfSignerWeight.jsonName] = 1;
Json::Value entryWrapper(Json::objectValue);
entryWrapper[sfSignerEntry.jsonName] = signerEntry;
Json::Value entries(Json::arrayValue);
entries.append(entryWrapper);
jvSigners[sfSignerEntries.jsonName] = entries;
log << "XRPL: SignerListSet JSON = " << jvSigners.toStyledString()
<< std::endl;
xrpl(jvSigners, fee(XRP(1)), ter(tesSUCCESS));
log << "XRPL: SignerListSet result = " << transToken(xrpl.ter())
<< std::endl;
xrpl.close();
}
{
STObject noop(sfGeneric);
noop.setFieldU16(sfTransactionType, ttACCOUNT_SET);
noop.setFieldU32(sfFlags, tfFullyCanonicalSig);
noop.setFieldU32(sfSequence, xrpl.seq(alice));
noop.setFieldU32(sfNetworkID, targetNetworkID);
noop.setFieldAmount(sfFee, XRPAmount{20});
noop.setFieldVL(sfSigningPubKey, Blob{});
noop.setAccountID(sfAccount, alice.id());
auto const sigData = buildMultiSigningData(noop, valAccountID);
auto const sig = ripple::sign(*valPK, valSK, sigData.slice());
STArray signers(sfSigners);
STObject signer(sfSigner);
signer.setAccountID(sfAccount, valAccountID);
signer.setFieldVL(sfSigningPubKey, valPK->slice());
signer.setFieldVL(sfTxnSignature, sig);
signers.push_back(std::move(signer));
noop.setFieldArray(sfSigners, signers);
Serializer ser;
noop.add(ser);
auto const jr = xrpl.rpc("submit", strHex(ser.slice()));
log << "XRPL: smoke test noop result = "
<< jr[jss::result][jss::engine_result].asString() << std::endl;
xrpl.close();
}
{
auto const jr =
xrpl.rpc("submit", strHex(makeSlice(multisignedBlob)));
log << "XRPL: export blob submit result = "
<< jr[jss::result][jss::engine_result].asString() << std::endl;
if (jr[jss::result][jss::engine_result].asString() != "tesSUCCESS")
{
log << "XRPL: full result = " << jr.toStyledString()
<< std::endl;
}
}
xrpl.close();
auto const xrplLcl = xrpl.app().getLedgerMaster().getClosedLedger();
BEAST_EXPECT(xrplLcl);
uint256 xrplMapKey;
xrplLcl->txMap().visitLeaves(
[&](boost::intrusive_ptr<SHAMapItem const> const& item) {
xrplMapKey = item->key();
});
auto const xpopJson = xpopCtx.buildXPOP(*xrplLcl, xrplMapKey);
log << "XPOP null? " << xpopJson.isNull() << std::endl;
BEAST_EXPECT(!xpopJson.isNull());
auto const nullJournal = beast::Journal{beast::Journal::getNullSink()};
auto const vlInfo = getVLInfo(xpopJson, nullJournal);
BEAST_EXPECT(vlInfo);
return CallbackXPOP{xpopJson, ticketSeq, vlInfo};
}
void
ratchetImportVLWithBurnToMint(
jtx::Env& dstEnv,
jtx::xpop::TestXPOPContext const& xpopCtx,
std::uint32_t dstNetworkID,
jtx::Account const& account,
std::uint32_t vlSequence)
{
using namespace jtx;
auto ratchetCtx = xpopCtx;
ratchetCtx.vlData =
ratchetCtx.publisher.buildVLData(ratchetCtx.validators, vlSequence);
Env srcEnv{*this};
Account const sink{"ratchetSink"};
srcEnv.fund(XRP(10000), account, sink);
srcEnv.close();
Json::Value payTx;
payTx[jss::TransactionType] = jss::Payment;
payTx[jss::Account] = account.human();
payTx[jss::Destination] = sink.human();
payTx[jss::Amount] = "100000000";
payTx[sfOperationLimit.jsonName] = dstNetworkID;
srcEnv(payTx, fee(XRP(1)), ter(tesSUCCESS));
srcEnv.close();
auto const srcLcl = srcEnv.app().getLedgerMaster().getClosedLedger();
BEAST_EXPECT(srcLcl);
uint256 paymentHash;
srcLcl->txMap().visitLeaves(
[&](boost::intrusive_ptr<SHAMapItem const> const& item) {
paymentHash = item->key();
});
auto const xpopJson = ratchetCtx.buildXPOP(*srcLcl, paymentHash);
BEAST_EXPECT(!xpopJson.isNull());
dstEnv.fund(XRP(1000), account);
dstEnv.close();
auto const feeDrops = dstEnv.current()->fees().base;
dstEnv(
import::import(account, xpopJson),
fee(feeDrops * 10),
ter(tesSUCCESS));
dstEnv.close();
}
void
seedUNLReportLedger(jtx::Env& env, std::vector<PublicKey> const& activeKeys)
{
BEAST_EXPECT(!activeKeys.empty());
env.app().openLedger().modify(
[&](OpenView& view, beast::Journal) -> bool {
for (auto const& pk : activeKeys)
{
STTx tx =
unl::createUNLReportTx(env.current()->seq(), pk, pk);
auto txID = tx.getTransactionID();
auto s = std::make_shared<Serializer>();
tx.add(*s);
env.app().getHashRouter().setFlags(txID, SF_PRIVATE2);
view.rawTxInsert(txID, std::move(s), nullptr);
}
return true;
});
BEAST_EXPECT(env.close(
env.now() + std::chrono::seconds{5}, std::chrono::milliseconds{0}));
BEAST_EXPECT(env.le(keylet::UNLReport()));
}
static jtx::JTx
makeExportJTx(
jtx::Env& env,
jtx::Account const& account,
STObject const& innerObj,
LedgerIndex lls)
{
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = account.human();
jv[jss::LastLedgerSequence] = lls;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
return env.jt(jv, jtx::fee(jtx::XRP(1)), jtx::ter(tesSUCCESS));
}
// 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,
std::optional<std::uint32_t> ticketSeq = 1)
{
STObject obj(sfExportedTxn);
obj.setFieldU16(sfTransactionType, ttPAYMENT);
obj.setFieldU32(sfFlags, tfFullyCanonicalSig);
obj.setFieldU32(sfSequence, 0);
if (ticketSeq)
obj.setFieldU32(sfTicketSequence, *ticketSeq);
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;
}
// Hook that exports a payment using xport (for cross-chain export)
TestHook xport_wasm = export_test_wasm[R"[test.hook](
#include <stdint.h>
extern int32_t _g(uint32_t id, uint32_t maxiter);
extern int64_t accept(uint32_t read_ptr, uint32_t read_len, int64_t error_code);
extern int64_t rollback(uint32_t read_ptr, uint32_t read_len, int64_t error_code);
extern int64_t xport(uint32_t write_ptr, uint32_t write_len, uint32_t read_ptr, uint32_t read_len);
extern int64_t xport_reserve(uint32_t count);
extern int64_t hook_account(uint32_t write_ptr, uint32_t write_len);
extern int64_t otxn_param(uint32_t write_ptr, uint32_t write_len, uint32_t name_ptr, uint32_t name_len);
extern int64_t otxn_type(void);
extern int64_t ledger_seq(void);
#define SBUF(x) (uint32_t)(x), sizeof(x)
#define ASSERT(x) if (!(x)) rollback((uint32_t)#x, sizeof(#x), __LINE__)
#define ttPAYMENT 0
#define tfCANONICAL 0x80000000UL
#define amAMOUNT 1
#define amFEE 8
#define atACCOUNT 1
#define atDESTINATION 3
#define ENCODE_TT(buf_out, tt) \
buf_out[0] = 0x12U; \
buf_out[1] = (tt >> 8) & 0xFFU; \
buf_out[2] = tt & 0xFFU; \
buf_out += 3;
#define ENCODE_FLAGS(buf_out, flags) \
buf_out[0] = 0x22U; \
buf_out[1] = (flags >> 24) & 0xFFU; \
buf_out[2] = (flags >> 16) & 0xFFU; \
buf_out[3] = (flags >> 8) & 0xFFU; \
buf_out[4] = flags & 0xFFU; \
buf_out += 5;
#define ENCODE_SEQUENCE(buf_out, seq) \
buf_out[0] = 0x24U; \
buf_out[1] = (seq >> 24) & 0xFFU; \
buf_out[2] = (seq >> 16) & 0xFFU; \
buf_out[3] = (seq >> 8) & 0xFFU; \
buf_out[4] = seq & 0xFFU; \
buf_out += 5;
#define ENCODE_FLS(buf_out, fls) \
buf_out[0] = 0x20U; \
buf_out[1] = 0x1AU; \
buf_out[2] = (fls >> 24) & 0xFFU; \
buf_out[3] = (fls >> 16) & 0xFFU; \
buf_out[4] = (fls >> 8) & 0xFFU; \
buf_out[5] = fls & 0xFFU; \
buf_out += 6;
#define ENCODE_LLS(buf_out, lls) \
buf_out[0] = 0x20U; \
buf_out[1] = 0x1BU; \
buf_out[2] = (lls >> 24) & 0xFFU; \
buf_out[3] = (lls >> 16) & 0xFFU; \
buf_out[4] = (lls >> 8) & 0xFFU; \
buf_out[5] = lls & 0xFFU; \
buf_out += 6;
#define ENCODE_DROPS(buf_out, drops, amt_type) \
buf_out[0] = 0x60U + amt_type; \
buf_out[1] = 0x40U + ((drops >> 56) & 0x3FU); \
buf_out[2] = (drops >> 48) & 0xFFU; \
buf_out[3] = (drops >> 40) & 0xFFU; \
buf_out[4] = (drops >> 32) & 0xFFU; \
buf_out[5] = (drops >> 24) & 0xFFU; \
buf_out[6] = (drops >> 16) & 0xFFU; \
buf_out[7] = (drops >> 8) & 0xFFU; \
buf_out[8] = drops & 0xFFU; \
buf_out += 9;
#define ENCODE_SIGNING_PUBKEY_EMPTY(buf_out) \
buf_out[0] = 0x73U; \
buf_out[1] = 0x00U; \
buf_out += 2;
#define ENCODE_ACCOUNT(buf_out, acc, acc_type) \
buf_out[0] = 0x80U + acc_type; \
buf_out[1] = 0x14U; \
for (int i = 0; i < 20; ++i) buf_out[2+i] = acc[i]; \
buf_out += 22;
#define PREPARE_PAYMENT_SIMPLE_SIZE 270U
int64_t hook(uint32_t reserved) {
_g(1, 1);
if (otxn_type() != ttPAYMENT)
return accept(0, 0, 0);
ASSERT(xport_reserve(1) == 1);
uint8_t dst[20];
int64_t dst_len = otxn_param(SBUF(dst), "DST", 3);
ASSERT(dst_len == 20);
uint8_t acc[20];
ASSERT(hook_account(SBUF(acc)) == 20);
uint32_t cls = (uint32_t)ledger_seq();
uint8_t tx[PREPARE_PAYMENT_SIMPLE_SIZE];
uint8_t* buf = tx;
ENCODE_TT(buf, ttPAYMENT);
ENCODE_FLAGS(buf, tfCANONICAL);
ENCODE_SEQUENCE(buf, 0);
ENCODE_FLS(buf, cls + 1);
ENCODE_LLS(buf, cls + 5);
// sfTicketSequence = UINT32 field 41 = 0x20 0x29
buf[0] = 0x20U; buf[1] = 0x29U;
buf[2] = 0; buf[3] = 0; buf[4] = 0; buf[5] = 1;
buf += 6;
uint64_t drops = 1000000;
ENCODE_DROPS(buf, drops, amAMOUNT);
ENCODE_DROPS(buf, 10, amFEE);
ENCODE_SIGNING_PUBKEY_EMPTY(buf);
ENCODE_ACCOUNT(buf, acc, atACCOUNT);
ENCODE_ACCOUNT(buf, dst, atDESTINATION);
uint8_t hash[32];
int64_t xport_result = xport(SBUF(hash), (uint32_t)tx, buf - tx);
ASSERT(xport_result == 32);
return accept(0, 0, 0);
}
)[test.hook]"];
// Hook that exports a payment WITH sfNetworkID matching local network.
// Should be rejected by the NetworkID self-target guard.
TestHook xport_self_target_wasm = export_test_wasm[R"[test.hook](
#include <stdint.h>
extern int32_t _g(uint32_t id, uint32_t maxiter);
extern int64_t accept(uint32_t read_ptr, uint32_t read_len, int64_t error_code);
extern int64_t rollback(uint32_t read_ptr, uint32_t read_len, int64_t error_code);
extern int64_t xport(uint32_t write_ptr, uint32_t write_len, uint32_t read_ptr, uint32_t read_len);
extern int64_t xport_reserve(uint32_t count);
extern int64_t hook_account(uint32_t write_ptr, uint32_t write_len);
extern int64_t otxn_param(uint32_t write_ptr, uint32_t write_len, uint32_t name_ptr, uint32_t name_len);
extern int64_t otxn_type(void);
extern int64_t ledger_seq(void);
#define SBUF(x) (uint32_t)(x), sizeof(x)
#define ASSERT(x) if (!(x)) rollback((uint32_t)#x, sizeof(#x), __LINE__)
#define ttPAYMENT 0
#define tfCANONICAL 0x80000000UL
#define amAMOUNT 1
#define amFEE 8
#define atACCOUNT 1
#define atDESTINATION 3
#define ENCODE_TT(buf_out, tt) \
buf_out[0] = 0x12U; \
buf_out[1] = (tt >> 8) & 0xFFU; \
buf_out[2] = tt & 0xFFU; \
buf_out += 3;
#define ENCODE_FLAGS(buf_out, flags) \
buf_out[0] = 0x22U; \
buf_out[1] = (flags >> 24) & 0xFFU; \
buf_out[2] = (flags >> 16) & 0xFFU; \
buf_out[3] = (flags >> 8) & 0xFFU; \
buf_out[4] = flags & 0xFFU; \
buf_out += 5;
#define ENCODE_SEQUENCE(buf_out, seq) \
buf_out[0] = 0x24U; \
buf_out[1] = (seq >> 24) & 0xFFU; \
buf_out[2] = (seq >> 16) & 0xFFU; \
buf_out[3] = (seq >> 8) & 0xFFU; \
buf_out[4] = seq & 0xFFU; \
buf_out += 5;
// sfNetworkID = UINT32 field 1 = 0x21
#define ENCODE_NETWORK_ID(buf_out, id) \
buf_out[0] = 0x21U; \
buf_out[1] = (id >> 24) & 0xFFU; \
buf_out[2] = (id >> 16) & 0xFFU; \
buf_out[3] = (id >> 8) & 0xFFU; \
buf_out[4] = id & 0xFFU; \
buf_out += 5;
#define ENCODE_FLS(buf_out, fls) \
buf_out[0] = 0x20U; \
buf_out[1] = 0x1AU; \
buf_out[2] = (fls >> 24) & 0xFFU; \
buf_out[3] = (fls >> 16) & 0xFFU; \
buf_out[4] = (fls >> 8) & 0xFFU; \
buf_out[5] = fls & 0xFFU; \
buf_out += 6;
#define ENCODE_LLS(buf_out, lls) \
buf_out[0] = 0x20U; \
buf_out[1] = 0x1BU; \
buf_out[2] = (lls >> 24) & 0xFFU; \
buf_out[3] = (lls >> 16) & 0xFFU; \
buf_out[4] = (lls >> 8) & 0xFFU; \
buf_out[5] = lls & 0xFFU; \
buf_out += 6;
#define ENCODE_DROPS(buf_out, drops, amt_type) \
buf_out[0] = 0x60U + amt_type; \
buf_out[1] = 0x40U + ((drops >> 56) & 0x3FU); \
buf_out[2] = (drops >> 48) & 0xFFU; \
buf_out[3] = (drops >> 40) & 0xFFU; \
buf_out[4] = (drops >> 32) & 0xFFU; \
buf_out[5] = (drops >> 24) & 0xFFU; \
buf_out[6] = (drops >> 16) & 0xFFU; \
buf_out[7] = (drops >> 8) & 0xFFU; \
buf_out[8] = drops & 0xFFU; \
buf_out += 9;
#define ENCODE_SIGNING_PUBKEY_EMPTY(buf_out) \
buf_out[0] = 0x73U; \
buf_out[1] = 0x00U; \
buf_out += 2;
#define ENCODE_ACCOUNT(buf_out, acc, acc_type) \
buf_out[0] = 0x80U + acc_type; \
buf_out[1] = 0x14U; \
for (int i = 0; i < 20; ++i) buf_out[2+i] = acc[i]; \
buf_out += 22;
#define PREPARE_PAYMENT_SIMPLE_SIZE 270U
int64_t hook(uint32_t reserved) {
_g(1, 1);
if (otxn_type() != ttPAYMENT)
return accept(0, 0, 0);
ASSERT(xport_reserve(1) == 1);
uint8_t dst[20];
int64_t dst_len = otxn_param(SBUF(dst), "DST", 3);
ASSERT(dst_len == 20);
uint8_t acc[20];
ASSERT(hook_account(SBUF(acc)) == 20);
uint32_t cls = (uint32_t)ledger_seq();
uint8_t tx[PREPARE_PAYMENT_SIMPLE_SIZE];
uint8_t* buf = tx;
ENCODE_TT(buf, ttPAYMENT);
ENCODE_NETWORK_ID(buf, 21337); // must precede Sequence (canonical order)
ENCODE_FLAGS(buf, tfCANONICAL);
ENCODE_SEQUENCE(buf, 0);
ENCODE_FLS(buf, cls + 1);
ENCODE_LLS(buf, cls + 5);
uint64_t drops = 1000000;
ENCODE_DROPS(buf, drops, amAMOUNT);
ENCODE_DROPS(buf, 10, amFEE);
ENCODE_SIGNING_PUBKEY_EMPTY(buf);
ENCODE_ACCOUNT(buf, acc, atACCOUNT);
ENCODE_ACCOUNT(buf, dst, atDESTINATION);
uint8_t hash[32];
int64_t xport_result = xport(SBUF(hash), (uint32_t)tx, buf - tx);
// xport should return EXPORT_FAILURE (-46), ASSERT will rollback
ASSERT(xport_result == 32);
return accept(0, 0, 0);
}
)[test.hook]"];
// Helper to build hook params with DST
static Json::Value
makeDstParams(AccountID const& dst)
{
Json::Value params(Json::arrayValue);
Json::Value param;
param[jss::HookParameter] = Json::Value(Json::objectValue);
param[jss::HookParameter][jss::HookParameterName] =
strHex(std::string("DST"));
param[jss::HookParameter][jss::HookParameterValue] = strHex(dst);
params.append(param);
return params;
}
void
testXportPayment(FeatureBitset features)
{
testcase("Xport Payment (hook emits ttEXPORT)");
using namespace jtx;
Env env{*this, exportTestConfig(), features};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, bob, carol);
env.close();
// Install xport hook on alice
env(ripple::test::jtx::hook(alice, {{hso(xport_wasm)}}, 0),
HSFEE,
ter(tesSUCCESS));
env.close();
// Trigger hook with payment containing DST parameter
auto params = makeDstParams(carol.id());
env(pay(bob, alice, XRP(100)),
fee(XRP(1)),
json(jss::HookParameters, params),
ter(tesSUCCESS));
// Verify hook fired successfully with exactly 1 export.
{
auto const m = env.meta();
BEAST_EXPECT(m);
BEAST_EXPECT(m->isFieldPresent(sfHookExecutions));
auto const& execs = m->getFieldArray(sfHookExecutions);
BEAST_EXPECT(execs.size() == 1);
// result=3 is ExitType::ACCEPT
BEAST_EXPECT(execs[0].getFieldU8(sfHookResult) == 3);
BEAST_EXPECT(execs[0].getFieldU16(sfHookEmitCount) == 0);
BEAST_EXPECT(execs[0].getFieldU16(sfHookExportCount) == 1);
BEAST_EXPECT(execs[0].getFieldU64(sfHookReturnCode) == 0);
// HookEmissions tracks normal emitted transactions. Export wrappers
// use the emitted directory for scheduling but are counted
// separately as HookExportCount.
BEAST_EXPECT(!m->isFieldPresent(sfHookEmissions));
// The emitted dir should NOT be empty (ttEXPORT is in it).
BEAST_EXPECT(!dirIsEmpty(*env.current(), keylet::emittedDir()));
// Find the emitted ttEXPORT in AffectedNodes.
bool foundEmitted = false;
for (auto const& node : m->getFieldArray(sfAffectedNodes))
{
if (node.getFName() == sfCreatedNode &&
node.getFieldU16(sfLedgerEntryType) == ltEMITTED_TXN)
{
foundEmitted = true;
break;
}
}
BEAST_EXPECT(foundEmitted);
}
// env.meta() above did an implicit close (hook fires, emitted dir
// populated, TxQ rawTxInserts into open ledger). One more close
// applies the emitted ttEXPORT through the transactor.
env.close();
// The emitted ttEXPORT and its per-export signature witness should now
// appear in the closed ledger. The witness is transaction-stream input,
// not metadata decoration, so replay has the same signatures apply saw.
{
auto const ledger = env.closed();
int exportCount = 0;
int witnessCount = 0;
std::optional<uint256> exportHash;
std::optional<uint256> witnessExportHash;
for (auto const& [stx, meta] : ledger->txs)
{
if (stx->getTxnType() == ttEXPORT)
{
BEAST_EXPECT(stx->isFieldPresent(sfEmitDetails));
exportHash = stx->getTransactionID();
++exportCount;
continue;
}
BEAST_EXPECT(stx->getTxnType() == ttEXPORT_SIGNATURES);
BEAST_EXPECT(stx->isFieldPresent(sfTransactionHash));
witnessExportHash = stx->getFieldH256(sfTransactionHash);
auto signatures =
ExportResultBuilder::signaturesFromWitness(*stx);
BEAST_EXPECT(signatures);
++witnessCount;
}
BEAST_EXPECT(exportCount == 1);
BEAST_EXPECT(witnessCount == 1);
BEAST_EXPECT(exportHash && witnessExportHash);
if (exportHash && witnessExportHash)
BEAST_EXPECT(*witnessExportHash == *exportHash);
}
}
void
testXportRejectsLocalNetworkID(FeatureBitset features)
{
testcase("Xport rejects export targeting local NetworkID");
using namespace jtx;
Env env{*this, exportTestConfig(), features};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, bob, carol);
env.close();
// Install hook that exports a tx with NetworkID=21337
env(ripple::test::jtx::hook(alice, {{hso(xport_self_target_wasm)}}, 0),
HSFEE,
ter(tesSUCCESS));
env.close();
// Trigger: xport() should reject because exported tx's NetworkID
// matches the local network → EXPORT_FAILURE → hook rollback
auto params = makeDstParams(carol.id());
env(pay(bob, alice, XRP(100)),
fee(XRP(1)),
json(jss::HookParameters, params),
ter(tecHOOK_REJECTED));
env.close();
// Verify no emitted ttEXPORT was created
auto const emittedDirKey = keylet::emittedDir();
BEAST_EXPECT(dirIsEmpty(*env.current(), emittedDirKey));
}
void
testXportRejectsUnconfiguredNetworkID(FeatureBitset features)
{
testcase("Xport rejects export when NETWORK_ID is unconfigured");
using namespace jtx;
// Default NETWORK_ID=0
Env env{*this, envconfig(jtx::validator, ""), features};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, bob, carol);
env.close();
// Install the normal xport hook (no NetworkID in exported tx)
env(ripple::test::jtx::hook(alice, {{hso(xport_wasm)}}, 0),
HSFEE,
ter(tesSUCCESS));
env.close();
// Trigger: xport() should reject because NETWORK_ID=0 and the
// exported tx has no sfNetworkID → can't verify it's cross-chain
auto params = makeDstParams(carol.id());
env(pay(bob, alice, XRP(100)),
fee(XRP(1)),
json(jss::HookParameters, params),
ter(tecHOOK_REJECTED));
env.close();
// Verify no emitted ttEXPORT was created
auto const emittedDirKey = keylet::emittedDir();
BEAST_EXPECT(dirIsEmpty(*env.current(), emittedDirKey));
}
void
testExportRejectsAmbiguousLowNetworkID()
{
testcase("Export rejects absent NetworkID on low source networks");
using namespace jtx;
Account const alice{"alice"};
Account const carol{"carol"};
auto innerObj = buildExportedPayment(alice.id(), carol.id(), 2, 6);
auto const innerTx = makeSTTx(innerObj);
auto const j = beast::Journal{beast::Journal::getNullSink()};
// Low-ID networks do not encode sfNetworkID on ordinary transactions,
// so an absent destination ID is ambiguous with a self-target.
BEAST_EXPECT(
ExportLedgerOps::validateNetworkID(innerTx, 0, j) == temMALFORMED);
BEAST_EXPECT(
ExportLedgerOps::validateNetworkID(
innerTx, maxNetworkIDWithoutTxField, j) == temMALFORMED);
// A high-ID source chain can still export to a legacy low-ID
// destination whose transactions omit sfNetworkID.
BEAST_EXPECT(
ExportLedgerOps::validateNetworkID(
innerTx, maxNetworkIDWithoutTxField + 1, j) == tesSUCCESS);
}
void
testXportEmissionLimit(FeatureBitset features)
{
testcase("Xport emitted export limit");
using namespace jtx;
Env env{*this, exportTestConfig(), features};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, bob, carol);
env.close();
env(ripple::test::jtx::hook(alice, {{hso(xport_wasm)}}, 0),
HSFEE,
ter(tesSUCCESS));
env.close();
auto params = makeDstParams(carol.id());
for (std::uint32_t i = 0; i <= ExportLimits::maxPendingExports; ++i)
{
env(pay(bob, alice, XRP(1)),
fee(XRP(1)),
json(jss::HookParameters, params),
ter(tesSUCCESS));
}
env.close();
std::uint32_t accepted = 0;
std::uint32_t capped = 0;
for (auto const& [stx, meta] : env.closed()->txs)
{
if (stx->getTxnType() != ttPAYMENT ||
stx->getAccountID(sfAccount) != bob.id())
{
continue;
}
if ((*meta)[sfTransactionResult] ==
static_cast<std::uint8_t>(TERtoInt(tesSUCCESS)))
++accepted;
else if (
(*meta)[sfTransactionResult] ==
static_cast<std::uint8_t>(TERtoInt(tecDIR_FULL)))
++capped;
}
BEAST_EXPECT(accepted == ExportLimits::maxPendingExports);
BEAST_EXPECT(capped == 1);
BEAST_EXPECT(
ExportLedgerOps::pendingExportEmissionCount(*env.current()) ==
ExportLimits::maxPendingExports);
}
void
testExportTxnOpenLedger(FeatureBitset features)
{
testcase("ttEXPORT succeeds on open ledger (provisional)");
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);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = seq + 5;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
env(jv, fee(XRP(1)), ter(tesSUCCESS));
env.close();
}
void
testExportNetworkRetryWithoutQuorum(FeatureBitset features)
{
testcase("ttEXPORT network mode retries without quorum");
using namespace jtx;
Env env{*this, exportTestConfig(), features};
Account const alice{"alice"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, carol);
env.close();
forceNonStandalone(env.app());
BEAST_EXPECT(!env.app().config().standalone());
ConsensusTestConfig cfg;
cfg.noExportSig = true;
env.app().getRuntimeConfig().setGlobalConfig(cfg);
auto const seq = env.current()->seq();
auto const ticketSeq = std::uint32_t{1};
auto const lls = seq + 5;
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), seq + 1, lls, ticketSeq);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = lls;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
env(jv, fee(XRP(1)), ter(tesSUCCESS));
BEAST_EXPECT(env.close(
env.now() + std::chrono::seconds{5}, std::chrono::milliseconds{0}));
std::optional<TER> closedResult;
for (auto const& [stx, meta] : env.closed()->txs)
{
if (!stx || stx->getTxnType() != ttEXPORT ||
stx->getAccountID(sfAccount) != alice.id())
{
continue;
}
auto const& exported =
stx->peekAtField(sfExportedTxn).downcast<STObject>();
if (exported.getFieldU32(sfTicketSequence) != ticketSeq)
continue;
if (meta)
closedResult = TER::fromInt((*meta)[sfTransactionResult]);
}
BEAST_EXPECT(!closedResult);
BEAST_EXPECT(!env.le(keylet::shadowTicket(alice.id(), ticketSeq)));
}
void
testExportNetworkApplyUsesAgreedSidecar(FeatureBitset features)
{
testcase("ttEXPORT network apply uses agreed export sidecar");
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& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& valPK = valKeys.keys->publicKey;
auto const& valSK = valKeys.keys->secretKey;
seedUNLReportLedger(env, {valPK});
forceNonStandalone(env.app());
BEAST_EXPECT(!env.app().config().standalone());
auto const seq = env.current()->seq();
auto const ticketSeq = std::uint32_t{1};
auto const lls = seq + 5;
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), seq + 1, lls, ticketSeq);
auto const innerTx = makeSTTx(innerObj);
auto jt = makeExportJTx(env, alice, innerObj, lls);
auto const exportTx = jt.stx;
BEAST_EXPECT(exportTx);
if (!exportTx)
return;
auto const txHash = exportTx->getTransactionID();
auto& ce = env.app().getConsensusExtensions();
ce.setExportEnabledThisRound(true);
ce.cacheUNLReport(env.app().getLedgerMaster().getClosedLedger());
auto const view = ce.activeValidatorView();
BEAST_EXPECT(view->fromUNLReport);
ce.cacheConsensusTxSet(makeRCLTxSet(env.app(), {exportTx}));
auto const originalSig =
ExportResultBuilder::signExportedTxn(innerTx, valPK, valSK);
auto const applySeq = env.closed()->seq() + 1;
ce.exportSigCollector().addVerifiedSignature(
txHash, valPK, originalSig, applySeq);
auto const agreedHash = ce.buildExportSigSet(applySeq);
BEAST_EXPECT(ce.isSidecarSet(agreedHash));
ce.acceptExportSigSet(agreedHash);
ce.setExportSigConvergenceFailed();
// Simulate an asynchronous collector mutation after sidecar agreement.
// Closed-ledger apply must derive the signature snapshot from the
// agreed sidecar, not from the live collector or a local timeout flag.
std::uint8_t const lateBytes[] = {9, 8, 7};
Buffer const lateSig{lateBytes, sizeof(lateBytes)};
ce.exportSigCollector().addVerifiedSignature(
txHash, valPK, lateSig, applySeq);
ExportResultBuilder::SignatureSnapshot expectedSigs;
expectedSigs.emplace(valPK, originalSig);
auto const exportSignatureWitnesses =
makeExportSignatureWitnesses(txHash, expectedSigs, applySeq);
ApplyOptions const applyOptions{
&exportSignatureWitnesses,
ApplyOptions::ExportWitnessMembership::TrustConsensusMaterialized,
false,
nullptr};
auto const expectedSignedTxHash =
ExportResultBuilder::assembleDirect(
innerTx, expectedSigs, applySeq, txHash)
.signedTxHash;
auto const parent = env.app().getLedgerMaster().getClosedLedger();
auto next = std::make_shared<Ledger>(
*parent, env.app().timeKeeper().closeTime());
OpenView accum(&*next);
auto const result = ripple::apply(
env.app(), accum, *exportTx, tapNONE, env.journal, applyOptions);
BEAST_EXPECT(result.ter == tesSUCCESS);
BEAST_EXPECT(result.applied);
accum.apply(*next);
auto const st = next->read(keylet::shadowTicket(alice.id(), ticketSeq));
BEAST_EXPECT(st);
if (st)
BEAST_EXPECT(
st->getFieldH256(sfTransactionHash) == expectedSignedTxHash);
}
void
testExportShadowTicketInsufficientReserve(FeatureBitset features)
{
testcase("ttEXPORT shadow ticket requires owner reserve");
using namespace jtx;
Env env{*this, exportTestConfig(), features};
Account const alice{"alice"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, carol);
{
uint256 const nftId0{token::getNextID(env, alice, 0u)};
env(token::mint(alice, 0u));
env(token::burn(alice, nftId0));
}
env.close();
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& valPK = valKeys.keys->publicKey;
auto const& valSK = valKeys.keys->secretKey;
seedUNLReportLedger(env, {valPK});
forceNonStandalone(env.app());
auto const parent = env.app().getLedgerMaster().getClosedLedger();
auto const applySeq = parent->seq() + 1;
auto const ticketSeq = std::uint32_t{1};
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), applySeq, applySeq + 5, ticketSeq);
auto const innerTx = makeSTTx(innerObj);
auto jt = makeExportJTx(env, alice, innerObj, applySeq + 5);
auto const exportTx = jt.stx;
BEAST_EXPECT(exportTx);
if (!exportTx)
return;
auto const txHash = exportTx->getTransactionID();
auto const sig =
ExportResultBuilder::signExportedTxn(innerTx, valPK, valSK);
ExportResultBuilder::SignatureSnapshot signatures;
signatures.emplace(valPK, sig);
auto const exportSignatureWitnesses =
makeExportSignatureWitnesses(txHash, signatures, applySeq);
ApplyOptions const applyOptions{
&exportSignatureWitnesses,
ApplyOptions::ExportWitnessMembership::TrustConsensusMaterialized,
false,
nullptr};
auto next = std::make_shared<Ledger>(
*parent, env.app().timeKeeper().closeTime());
auto const account = next->read(keylet::account(alice.id()));
BEAST_EXPECT(account);
if (!account)
return;
auto replacement = std::make_shared<SLE>(*account, account->key());
auto const insufficient = next->fees().accountReserve(
account->getFieldU32(sfOwnerCount) + 1) -
drops(1);
replacement->setFieldAmount(sfBalance, insufficient);
next->rawReplace(replacement);
OpenView accum(&*next);
auto const result = ripple::apply(
env.app(), accum, *exportTx, tapNONE, env.journal, applyOptions);
BEAST_EXPECT(result.ter == tecINSUFFICIENT_RESERVE);
BEAST_EXPECT(!accum.read(keylet::shadowTicket(alice.id(), ticketSeq)));
}
void
testExportHistoricalReplayIgnoresCurrentManifestMap(FeatureBitset features)
{
testcase(
"ttEXPORT historical replay does not require current manifest");
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& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
seedUNLReportLedger(env, {valKeys.keys->publicKey});
forceNonStandalone(env.app());
BEAST_EXPECT(!env.app().config().standalone());
auto const parent = env.app().getLedgerMaster().getClosedLedger();
auto const applySeq = parent->seq() + 1;
auto const ticketSeq = std::uint32_t{1};
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), applySeq, applySeq + 5, ticketSeq);
auto const innerTx = makeSTTx(innerObj);
auto jt = makeExportJTx(env, alice, innerObj, applySeq + 5);
auto const exportTx = jt.stx;
BEAST_EXPECT(exportTx);
if (!exportTx)
return;
auto const txHash = exportTx->getTransactionID();
auto const oldSigningKey = randomKeyPair(KeyType::secp256k1);
auto const sig = ExportResultBuilder::signExportedTxn(
innerTx, oldSigningKey.first, oldSigningKey.second);
ExportResultBuilder::SignatureSnapshot signatures;
signatures.emplace(oldSigningKey.first, sig);
auto const exportSignatureWitnesses =
makeExportSignatureWitnesses(txHash, signatures, applySeq);
ApplyOptions const liveOptions{
&exportSignatureWitnesses,
ApplyOptions::ExportWitnessMembership::FilterLiveManifest,
false,
nullptr};
{
auto next = std::make_shared<Ledger>(
*parent, env.app().timeKeeper().closeTime());
OpenView accum(&*next);
auto const result = ripple::apply(
env.app(), accum, *exportTx, tapNONE, env.journal, liveOptions);
BEAST_EXPECT(result.ter == terRETRY_EXPORT);
BEAST_EXPECT(!result.applied);
}
auto const expectedSignedTxHash =
ExportResultBuilder::assembleDirect(
innerTx, signatures, applySeq, txHash)
.signedTxHash;
// A live consensus build only reaches Export::doApply after onPreBuild
// has replaced witnesses with material from the accepted sidecar root.
// That tx-stream witness is the membership source; the current
// ManifestCache is not.
ApplyOptions const consensusOptions{
&exportSignatureWitnesses,
ApplyOptions::ExportWitnessMembership::TrustConsensusMaterialized,
false,
nullptr};
{
auto next = std::make_shared<Ledger>(
*parent, env.app().timeKeeper().closeTime());
OpenView accum(&*next);
auto const result = ripple::apply(
env.app(),
accum,
*exportTx,
tapNONE,
env.journal,
consensusOptions);
BEAST_EXPECT(result.ter == tesSUCCESS);
BEAST_EXPECT(result.applied);
accum.apply(*next);
auto const st =
next->read(keylet::shadowTicket(alice.id(), ticketSeq));
BEAST_EXPECT(st);
if (st)
BEAST_EXPECT(
st->getFieldH256(sfTransactionHash) ==
expectedSignedTxHash);
}
ApplyOptions const missingParentReplayOptions{
&exportSignatureWitnesses,
ApplyOptions::ExportWitnessMembership::TrustHistoricalReplay,
true,
nullptr};
{
auto next = std::make_shared<Ledger>(
*parent, env.app().timeKeeper().closeTime());
OpenView accum(&*next);
auto const result = ripple::apply(
env.app(),
accum,
*exportTx,
tapNONE,
env.journal,
missingParentReplayOptions);
BEAST_EXPECT(result.ter == terRETRY_EXPORT);
BEAST_EXPECT(!result.applied);
}
// Historical replay reconstructs an already-validated ledger. The
// persisted witness supplies the historical signing-key membership;
// current ManifestCache may no longer know a rotated signing key. The
// replay parent must be threaded explicitly so apply never consults
// process-global ledger state for the validator view.
ApplyOptions const replayOptions{
&exportSignatureWitnesses,
ApplyOptions::ExportWitnessMembership::TrustHistoricalReplay,
true,
parent};
auto replayed = std::make_shared<Ledger>(
*parent, env.app().timeKeeper().closeTime());
OpenView accum(&*replayed);
auto const replayResult = ripple::apply(
env.app(), accum, *exportTx, tapNONE, env.journal, replayOptions);
BEAST_EXPECT(replayResult.ter == tesSUCCESS);
BEAST_EXPECT(replayResult.applied);
accum.apply(*replayed);
auto const st =
replayed->read(keylet::shadowTicket(alice.id(), ticketSeq));
BEAST_EXPECT(st);
if (st)
BEAST_EXPECT(
st->getFieldH256(sfTransactionHash) == expectedSignedTxHash);
}
void
testBuildLedgerReplayPrescansExportWitness(FeatureBitset features)
{
testcase("BuildLedger replay pre-scans persisted export witness");
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& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
seedUNLReportLedger(env, {valKeys.keys->publicKey});
forceNonStandalone(env.app());
BEAST_EXPECT(!env.app().config().standalone());
auto const parent = env.app().getLedgerMaster().getClosedLedger();
auto const applySeq = parent->seq() + 1;
auto const ticketSeq = std::uint32_t{1};
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), applySeq, applySeq + 5, ticketSeq);
auto const innerTx = makeSTTx(innerObj);
auto jt = makeExportJTx(env, alice, innerObj, applySeq + 5);
auto const exportTx = jt.stx;
BEAST_EXPECT(exportTx);
if (!exportTx)
return;
auto const txHash = exportTx->getTransactionID();
ExportResultBuilder::SignatureSnapshot signatures;
signatures.emplace(
valKeys.keys->publicKey,
ExportResultBuilder::signExportedTxn(
innerTx, valKeys.keys->publicKey, valKeys.keys->secretKey));
auto const witnessTx = std::make_shared<STTx const>(
ExportResultBuilder::buildSignatureWitness(
txHash, signatures, applySeq));
CanonicalTXSet txns{parent->info().hash};
txns.insert(exportTx);
txns.insert(witnessTx);
std::set<TxID> failed;
auto const built = buildLedger(
parent,
env.app().timeKeeper().closeTime(),
true,
parent->info().closeTimeResolution,
env.app(),
txns,
failed,
env.journal);
BEAST_EXPECT(txns.empty());
BEAST_EXPECT(failed.empty());
auto const expectedSignedTxHash =
ExportResultBuilder::assembleDirect(
innerTx, signatures, applySeq, txHash)
.signedTxHash;
auto const builtTicket =
built->read(keylet::shadowTicket(alice.id(), ticketSeq));
BEAST_EXPECT(builtTicket);
if (builtTicket)
BEAST_EXPECT(
builtTicket->getFieldH256(sfTransactionHash) ==
expectedSignedTxHash);
auto const replayed = buildLedger(
LedgerReplay(parent, built), tapNONE, env.app(), env.journal);
BEAST_EXPECT(replayed->info().hash == built->info().hash);
}
void
testExportNetworkRetryWithoutUNLReport(FeatureBitset features)
{
testcase("ttEXPORT network mode retries without UNLReport view");
using namespace jtx;
Env env{*this, exportTestConfig(), features};
Account const alice{"alice"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, carol);
env.close();
forceNonStandalone(env.app());
BEAST_EXPECT(!env.app().config().standalone());
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& valPK = valKeys.keys->publicKey;
auto const& valSK = valKeys.keys->secretKey;
auto const seq = env.current()->seq();
auto const ticketSeq = std::uint32_t{1};
auto const lls = seq + 5;
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), seq + 1, lls, ticketSeq);
auto const innerTx = makeSTTx(innerObj);
auto jt = makeExportJTx(env, alice, innerObj, lls);
auto const exportTx = jt.stx;
BEAST_EXPECT(exportTx);
if (!exportTx)
return;
auto const txHash = exportTx->getTransactionID();
auto& ce = env.app().getConsensusExtensions();
ce.setExportEnabledThisRound(true);
ce.cacheUNLReport(env.app().getLedgerMaster().getClosedLedger());
auto const view = ce.activeValidatorView();
BEAST_EXPECT(!view->fromUNLReport);
ce.cacheConsensusTxSet(makeRCLTxSet(env.app(), {exportTx}));
auto const sig =
ExportResultBuilder::signExportedTxn(innerTx, valPK, valSK);
auto const applySeq = env.closed()->seq() + 1;
ce.exportSigCollector().addVerifiedSignature(
txHash, valPK, sig, applySeq);
auto const agreedHash = ce.buildExportSigSet(applySeq);
BEAST_EXPECT(ce.isSidecarSet(agreedHash));
ce.acceptExportSigSet(agreedHash);
auto const parent = env.app().getLedgerMaster().getClosedLedger();
auto next = std::make_shared<Ledger>(
*parent, env.app().timeKeeper().closeTime());
OpenView accum(&*next);
auto const result =
ripple::apply(env.app(), accum, *exportTx, tapNONE, env.journal);
BEAST_EXPECT(result.ter == terRETRY_EXPORT);
BEAST_EXPECT(!result.applied);
BEAST_EXPECT(!next->read(keylet::shadowTicket(alice.id(), ticketSeq)));
}
void
testExportNetworkLastLedgerSequenceBoundary(FeatureBitset features)
{
testcase("ttEXPORT network mode LastLedgerSequence boundary");
using namespace jtx;
auto applyAtLastLedger = [&](bool withQuorum) {
Env env{*this, exportTestConfig(), features};
Account const alice{"alice"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, carol);
env.close();
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& valPK = valKeys.keys->publicKey;
auto const& valSK = valKeys.keys->secretKey;
seedUNLReportLedger(env, {valPK});
forceNonStandalone(env.app());
BEAST_EXPECT(!env.app().config().standalone());
auto const parent = env.app().getLedgerMaster().getClosedLedger();
auto const applySeq = parent->seq() + 1;
auto const ticketSeq =
withQuorum ? std::uint32_t{1} : std::uint32_t{2};
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), applySeq, applySeq, ticketSeq);
auto const innerTx = makeSTTx(innerObj);
auto jt = makeExportJTx(env, alice, innerObj, applySeq);
auto const exportTx = jt.stx;
BEAST_EXPECT(exportTx);
if (!exportTx)
return;
auto const txHash = exportTx->getTransactionID();
auto& ce = env.app().getConsensusExtensions();
ce.setExportEnabledThisRound(true);
ce.cacheUNLReport(parent);
auto const view = ce.activeValidatorView();
BEAST_EXPECT(view->fromUNLReport);
ce.cacheConsensusTxSet(makeRCLTxSet(env.app(), {exportTx}));
ExportResultBuilder::SignatureWitnesses exportSignatureWitnesses;
if (withQuorum)
{
auto const sig =
ExportResultBuilder::signExportedTxn(innerTx, valPK, valSK);
ce.exportSigCollector().addVerifiedSignature(
txHash, valPK, sig, applySeq);
auto const agreedHash = ce.buildExportSigSet(applySeq);
BEAST_EXPECT(ce.isSidecarSet(agreedHash));
ce.acceptExportSigSet(agreedHash);
ExportResultBuilder::SignatureSnapshot signatures;
signatures.emplace(valPK, sig);
exportSignatureWitnesses =
makeExportSignatureWitnesses(txHash, signatures, applySeq);
}
ApplyOptions const applyOptions{
&exportSignatureWitnesses,
ApplyOptions::ExportWitnessMembership::
TrustConsensusMaterialized,
false,
nullptr};
auto next = std::make_shared<Ledger>(
*parent, env.app().timeKeeper().closeTime());
BEAST_EXPECT(next->seq() == applySeq);
OpenView accum(&*next);
auto const result = ripple::apply(
env.app(),
accum,
*exportTx,
tapNONE,
env.journal,
applyOptions);
if (withQuorum)
{
BEAST_EXPECT(result.ter == tesSUCCESS);
BEAST_EXPECT(result.applied);
accum.apply(*next);
BEAST_EXPECT(
next->read(keylet::shadowTicket(alice.id(), ticketSeq)));
}
else
{
BEAST_EXPECT(result.ter == tecEXPORT_EXPIRED);
// tecEXPORT_EXPIRED is a terminal tec result: it applies to
// consume the sequence/fee, but must not create export state.
BEAST_EXPECT(result.applied);
accum.apply(*next);
BEAST_EXPECT(
!next->read(keylet::shadowTicket(alice.id(), ticketSeq)));
}
};
// Quorum at ledger == LastLedgerSequence still succeeds; no quorum at
// the same boundary expires cleanly instead of falling through to a
// later tefMAX_LEDGER preclaim.
applyAtLastLedger(true);
applyAtLastLedger(false);
}
void
testOpenLedgerExportLimit(FeatureBitset features)
{
testcase("ttEXPORT open ledger limit");
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 submitExport = [&](std::uint32_t ticketSeq, TER expected) {
auto const seq = env.current()->seq();
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), seq + 1, seq + 50, ticketSeq);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = seq + 50;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
env(jv, fee(XRP(1)), ter(expected));
};
for (std::uint32_t i = 1; i <= ExportLimits::maxPendingExports; ++i)
submitExport(i, tesSUCCESS);
submitExport(ExportLimits::maxPendingExports + 1, tecDIR_FULL);
}
void
testShadowTicketLimit(FeatureBitset features)
{
testcase("shadow ticket pending export limit");
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 submitClosedExport = [&](std::uint32_t ticketSeq,
TER expected,
bool expectShadow) {
auto const seq = env.current()->seq();
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), seq + 1, seq + 50, ticketSeq);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = seq + 50;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
env(jv, fee(XRP(1)), ter(tesSUCCESS));
auto const meta = env.meta();
BEAST_EXPECT(meta);
BEAST_EXPECT(
(*meta)[sfTransactionResult] ==
static_cast<std::uint8_t>(TERtoInt(expected)));
auto const shadow =
env.le(keylet::shadowTicket(alice.id(), ticketSeq));
BEAST_EXPECT(expectShadow == static_cast<bool>(shadow));
env.close();
};
for (std::uint32_t i = 1; i <= ExportLimits::maxPendingExports; ++i)
submitClosedExport(i, tesSUCCESS, true);
submitClosedExport(1, tecDUPLICATE, true);
submitClosedExport(
ExportLimits::maxPendingExports + 1, tecDIR_FULL, false);
}
void
testShadowTicketLifecycle(FeatureBitset features)
{
testcase("Shadow ticket lifecycle");
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, 42);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = seq + 5;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
env(jv, fee(XRP(1)), ter(tesSUCCESS));
env.close();
// In single-node test env, no validator sigs → no quorum →
// shadow ticket not created (only created on quorum success).
// The export retries or expires without creating the ticket.
}
void
testCancelShadowTicketViaTxn(FeatureBitset features)
{
testcase("ttEXPORT cancels shadow ticket via sfCancelTicketSequence");
using namespace jtx;
Env env{*this, exportTestConfig(), features};
Account const alice{"alice"};
env.fund(XRP(10000), alice);
env.close();
// Cancel non-existent ticket → tecNO_ENTRY
Json::Value jvCancel;
jvCancel[jss::TransactionType] = jss::Export;
jvCancel[jss::Account] = alice.human();
jvCancel[sfCancelTicketSequence.jsonName] = 42;
env(jvCancel, fee(XRP(1)), ter(tecNO_ENTRY));
env.close();
}
void
testExportRejectsNoTicketSequence(FeatureBitset features)
{
testcase("ttEXPORT rejects export without TicketSequence");
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, std::nullopt);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = seq + 5;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
env(jv, fee(XRP(1)), ter(temMALFORMED));
env.close();
}
void
testExportRejectsMissingLastLedgerSequence(FeatureBitset features)
{
testcase("ttEXPORT rejects export without LastLedgerSequence");
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);
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(temMALFORMED));
env.close();
}
void
testExportRejectsMalformed(FeatureBitset features)
{
testcase("ttEXPORT rejects when neither export nor cancel present");
using namespace jtx;
Env env{*this, exportTestConfig(), features};
Account const alice{"alice"};
env.fund(XRP(10000), alice);
env.close();
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
env(jv, fee(XRP(1)), ter(temMALFORMED));
env.close();
}
void
testExportImportRoundTrip(FeatureBitset features)
{
testcase("Export → XPOP → Import round-trip");
using namespace jtx;
// The export round-trip is a 3-way handshake:
// 1. Xahau: ttEXPORT → validators sign the inner tx →
// shadow ticket + metadata pointing to the signature witness
// 2. XRPL: submit the multisigned blob raw (alice's
// SignerList on XRPL contains the Xahau validator keys)
// 3. Xahau: build XPOP from execution, import it back →
// shadow ticket consumed
//
// In standalone mode, the same ttEXPORT_SIGNATURES witness is
// synthesized locally from the node's validator key — no consensus
// proposals needed.
auto const xpopCtx = xpop::TestXPOPContext::create(3);
Account const alice{"alice"};
Account const carol{"carol"};
std::uint32_t const xahauNetworkID = 21337;
std::uint32_t const targetNetworkID = 31337;
// ── Xahau env: export the inner tx ─────────────────────────────
Env xahau{*this, xpopCtx.makeEnvConfig(xahauNetworkID), features};
xahau.fund(XRP(10000), alice, carol);
xahau.close();
// Burn XRP so B2M crediting works on import.
auto const master = Account("masterpassphrase");
xahau(noop(master), fee(10'000'000'000), ter(tesSUCCESS));
xahau.close();
std::uint32_t const ticketSeq = 2; // alice's first ticket on XRPL
auto const callback = buildExportCallbackXPOP(
xahau, xpopCtx, alice, carol, targetNetworkID, ticketSeq);
if (callback.vlInfo)
{
BEAST_EXPECT(importVLSequence(xahau, callback.vlInfo->second) == 0);
}
// ── Back to Xahau: import the XPOP ────────────────────────────
auto const feeDrops = xahau.current()->fees().base;
xahau(
import::import(alice, callback.xpopJson),
fee(feeDrops * 10),
ter(tesSUCCESS));
xahau.close();
// Shadow ticket should be consumed after import.
BEAST_EXPECT(!xahau.current()->exists(
keylet::shadowTicket(alice.id(), callback.ticketSeq)));
if (callback.vlInfo)
{
BEAST_EXPECT(
importVLSequence(xahau, callback.vlInfo->second) ==
callback.vlInfo->first);
}
}
void
testExportImportRejectsStaleImportVL(FeatureBitset features)
{
testcase("Export callback rejects stale ImportVL");
using namespace jtx;
auto const xpopCtx = xpop::TestXPOPContext::create(3);
Account const alice{"alice"};
Account const carol{"carol"};
Account const dave{"dave"};
std::uint32_t const xahauNetworkID = 21337;
std::uint32_t const targetNetworkID = 31337;
std::uint32_t const ticketSeq = 2;
Env xahau{*this, xpopCtx.makeEnvConfig(xahauNetworkID), features};
xahau.fund(XRP(10000), alice, carol);
xahau.close();
auto const master = Account("masterpassphrase");
xahau(noop(master), fee(10'000'000'000), ter(tesSUCCESS));
xahau.close();
auto const callback = buildExportCallbackXPOP(
xahau, xpopCtx, alice, carol, targetNetworkID, ticketSeq);
BEAST_EXPECT(callback.vlInfo);
if (!callback.vlInfo)
return;
auto const freshSeq = callback.vlInfo->first + 1;
ratchetImportVLWithBurnToMint(
xahau, xpopCtx, xahauNetworkID, dave, freshSeq);
BEAST_EXPECT(
importVLSequence(xahau, callback.vlInfo->second) == freshSeq);
auto const feeDrops = xahau.current()->fees().base;
xahau(
import::import(alice, callback.xpopJson),
fee(feeDrops * 10),
ter(tefPAST_IMPORT_VL_SEQ));
xahau.close();
BEAST_EXPECT(xahau.current()->exists(
keylet::shadowTicket(alice.id(), callback.ticketSeq)));
BEAST_EXPECT(
importVLSequence(xahau, callback.vlInfo->second) == freshSeq);
}
// Override focused_test() to run a specific test in isolation.
// Set FOCUSED_TEST=1 to skip the full suite and run only this.
void
focused_test(FeatureBitset const& features)
{
testXportPayment(features);
}
void
run() override
{
using namespace test::jtx;
FeatureBitset const all{supported_amendments()};
FeatureBitset const allWithExport{all | featureExport};
if (std::getenv("FOCUSED_TEST"))
{
focused_test(allWithExport);
return;
}
// Hook-based xport tests
testXportPayment(allWithExport);
testXportRejectsLocalNetworkID(allWithExport);
testXportRejectsUnconfiguredNetworkID(allWithExport);
testExportRejectsAmbiguousLowNetworkID();
testXportEmissionLimit(allWithExport);
// ttEXPORT transactor tests
testExportTxnOpenLedger(allWithExport);
testExportNetworkRetryWithoutQuorum(allWithExport);
testExportNetworkApplyUsesAgreedSidecar(allWithExport);
testExportShadowTicketInsufficientReserve(allWithExport);
testExportHistoricalReplayIgnoresCurrentManifestMap(allWithExport);
testBuildLedgerReplayPrescansExportWitness(allWithExport);
testExportNetworkRetryWithoutUNLReport(allWithExport);
testExportNetworkLastLedgerSequenceBoundary(allWithExport);
testOpenLedgerExportLimit(allWithExport);
testShadowTicketLimit(allWithExport);
testShadowTicketLifecycle(allWithExport);
testCancelShadowTicketViaTxn(allWithExport);
testExportRejectsNoTicketSequence(allWithExport);
testExportRejectsMissingLastLedgerSequence(allWithExport);
testExportRejectsMalformed(allWithExport);
// Round-trip test
testExportImportRoundTrip(allWithExport);
testExportImportRejectsStaleImportVL(allWithExport);
}
};
BEAST_DEFINE_TESTSUITE(Export, app, ripple);
} // namespace test
} // namespace ripple