//------------------------------------------------------------------------------ /* 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace ripple { namespace test { using TestHook = std::vector const&; // Large fee for hook operations #define HSFEE fee(100'000'000) struct Export_test : public beast::unit_test::suite { static std::unique_ptr exportTestConfig() { auto cfg = jtx::envconfig(jtx::validator, ""); cfg->NETWORK_ID = 21337; return cfg; } static void forceNonStandalone(Application& app) { const_cast(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> const& txns) { auto map = std::make_shared( 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> 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(); 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 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 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 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(); 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 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 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 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 exportHash; std::optional 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(TERtoInt(tesSUCCESS))) ++accepted; else if ( (*meta)[sfTransactionResult] == static_cast(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 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(); 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( *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( *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(*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( *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( *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( *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( *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( ExportResultBuilder::buildSignatureWitness( txHash, signatures, applySeq)); CanonicalTXSet txns{parent->info().hash}; txns.insert(exportTx); txns.insert(witnessTx); std::set 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( *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( *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(TERtoInt(expected))); auto const shadow = env.le(keylet::shadowTicket(alice.id(), ticketSeq)); BEAST_EXPECT(expectShadow == static_cast(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