From c4bbf04dd10e033b3e2c68fdb7f703ae5505920d Mon Sep 17 00:00:00 2001 From: RichardAH Date: Fri, 18 Aug 2023 03:43:47 +0200 Subject: [PATCH] feat: support Concise Transaction Identifier (CTID) (XLS-37) (#4418) The XLS-37 CTID (Concise Transaction ID) is a network-aware tx identifier which provides a way to efficiently locate a specific transaction without relying on transaction hashes. A CTID encodes the sequence number of the ledger that includes the tx, the transaction's index in that ledger, and the network ID. With the CTID, users can identify transactions and confirm their results. This applies even for transactions on sidechains, which may be difficult to find with only a transaction hash. Additionally, CTIDs require less storage space than transaction hashes, which can be beneficial for databases storing millions of transactions. The XLS-37 specification can be found at: https://github.com/XRPLF/XRPL-Standards/pull/111 Add support for running a node on a different network. There is a new error code, `rpcWRONG_NETWORK`, returned when the requested CTID's network ID does not match the node's network ID. The error message looks like: Wrong network. You should submit this request to a node running on NetworkID: * Add RPC support for the CTID format * tx - you can specify "ctid", which is the CTID (16 hex digits, in a string, always starting with "C") * When retrieving a tx, the "ctid" may be returned * Add support for encoding, decoding, and validating CTIDs * Add tests --------- Co-authored-by: Rome Reginelli Co-authored-by: Denis Angell Signed-off-by: Manoj Doshi --- Builds/CMake/RippledCore.cmake | 1 + src/ripple/app/ledger/LedgerMaster.h | 4 + src/ripple/app/ledger/impl/LedgerMaster.cpp | 21 + src/ripple/net/impl/RPCCall.cpp | 6 +- src/ripple/protocol/ErrorCodes.h | 2 +- src/ripple/protocol/jss.h | 1 + src/ripple/rpc/CTID.h | 88 ++++ src/ripple/rpc/handlers/Tx.cpp | 90 +++- src/ripple/rpc/impl/RPCHelpers.cpp | 1 + src/test/app/LedgerMaster_test.cpp | 139 +++++++ src/test/app/NetworkID_test.cpp | 26 +- src/test/jtx/Env.h | 12 + src/test/jtx/impl/Env.cpp | 5 + src/test/rpc/RPCCall_test.cpp | 44 +- src/test/rpc/Transaction_test.cpp | 431 +++++++++++++++++++- 15 files changed, 843 insertions(+), 28 deletions(-) create mode 100644 src/ripple/rpc/CTID.h create mode 100644 src/test/app/LedgerMaster_test.cpp diff --git a/Builds/CMake/RippledCore.cmake b/Builds/CMake/RippledCore.cmake index b676c5ff5e9..3240f79f896 100644 --- a/Builds/CMake/RippledCore.cmake +++ b/Builds/CMake/RippledCore.cmake @@ -768,6 +768,7 @@ if (tests) src/test/app/HashRouter_test.cpp src/test/app/LedgerHistory_test.cpp src/test/app/LedgerLoad_test.cpp + src/test/app/LedgerMaster_test.cpp src/test/app/LedgerReplay_test.cpp src/test/app/LoadFeeTrack_test.cpp src/test/app/Manifest_test.cpp diff --git a/src/ripple/app/ledger/LedgerMaster.h b/src/ripple/app/ledger/LedgerMaster.h index 802df8eb5cb..3d7adc86223 100644 --- a/src/ripple/app/ledger/LedgerMaster.h +++ b/src/ripple/app/ledger/LedgerMaster.h @@ -292,6 +292,10 @@ class LedgerMaster : public AbstractFetchPackContainer std::optional minSqlSeq(); + // Iff a txn exists at the specified ledger and offset then return its txnid + std::optional + txnIdFromIndex(uint32_t ledgerSeq, uint32_t txnIndex); + private: void setValidLedger(std::shared_ptr const& l); diff --git a/src/ripple/app/ledger/impl/LedgerMaster.cpp b/src/ripple/app/ledger/impl/LedgerMaster.cpp index 7ae7476948b..ff42a88a84f 100644 --- a/src/ripple/app/ledger/impl/LedgerMaster.cpp +++ b/src/ripple/app/ledger/impl/LedgerMaster.cpp @@ -2369,4 +2369,25 @@ LedgerMaster::minSqlSeq() return app_.getRelationalDatabase().getMinLedgerSeq(); } +std::optional +LedgerMaster::txnIdFromIndex(uint32_t ledgerSeq, uint32_t txnIndex) +{ + uint32_t first = 0, last = 0; + + if (!getValidatedRange(first, last) || last < ledgerSeq) + return {}; + + auto const lgr = getLedgerBySeq(ledgerSeq); + if (!lgr || lgr->txs.empty()) + return {}; + + for (auto it = lgr->txs.begin(); it != lgr->txs.end(); ++it) + if (it->first && it->second && + it->second->isFieldPresent(sfTransactionIndex) && + it->second->getFieldU32(sfTransactionIndex) == txnIndex) + return it->first->getTransactionID(); + + return {}; +} + } // namespace ripple diff --git a/src/ripple/net/impl/RPCCall.cpp b/src/ripple/net/impl/RPCCall.cpp index ed103ec7fca..df758646540 100644 --- a/src/ripple/net/impl/RPCCall.cpp +++ b/src/ripple/net/impl/RPCCall.cpp @@ -1096,7 +1096,11 @@ class RPCParser jvRequest[jss::max_ledger] = jvParams[2u + offset].asString(); } - jvRequest[jss::transaction] = jvParams[0u].asString(); + if (jvParams[0u].asString().length() == 16) + jvRequest[jss::ctid] = jvParams[0u].asString(); + else + jvRequest[jss::transaction] = jvParams[0u].asString(); + return jvRequest; } diff --git a/src/ripple/protocol/ErrorCodes.h b/src/ripple/protocol/ErrorCodes.h index 14166358f8c..87323b0dea8 100644 --- a/src/ripple/protocol/ErrorCodes.h +++ b/src/ripple/protocol/ErrorCodes.h @@ -47,8 +47,8 @@ enum error_code_i { rpcJSON_RPC = 2, rpcFORBIDDEN = 3, + rpcWRONG_NETWORK = 4, // Misc failure - // unused 4, // unused 5, rpcNO_PERMISSION = 6, rpcNO_EVENTS = 7, diff --git a/src/ripple/protocol/jss.h b/src/ripple/protocol/jss.h index c7faa4ff98b..2a7e2b14e82 100644 --- a/src/ripple/protocol/jss.h +++ b/src/ripple/protocol/jss.h @@ -226,6 +226,7 @@ JSS(converge_time_s); // out: NetworkOPs JSS(cookie); // out: NetworkOPs JSS(count); // in: AccountTx*, ValidatorList JSS(counters); // in/out: retrieve counters +JSS(ctid); // in/out: Tx RPC JSS(currency_a); // out: BookChanges JSS(currency_b); // out: BookChanges JSS(currentShard); // out: NodeToShardStatus diff --git a/src/ripple/rpc/CTID.h b/src/ripple/rpc/CTID.h new file mode 100644 index 00000000000..8f6c64bc028 --- /dev/null +++ b/src/ripple/rpc/CTID.h @@ -0,0 +1,88 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2019 Ripple Labs Inc. + + 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. +*/ +//============================================================================== + +#ifndef RIPPLE_RPC_CTID_H_INCLUDED +#define RIPPLE_RPC_CTID_H_INCLUDED + +#include +#include +#include +#include +#include + +namespace ripple { + +namespace RPC { + +inline std::optional +encodeCTID( + uint32_t ledger_seq, + uint16_t txn_index, + uint16_t network_id) noexcept +{ + if (ledger_seq > 0x0FFF'FFFF) + return {}; + + uint64_t ctidValue = + ((0xC000'0000ULL + static_cast(ledger_seq)) << 32) + + (static_cast(txn_index) << 16) + network_id; + + std::stringstream buffer; + buffer << std::hex << std::uppercase << std::setfill('0') << std::setw(16) + << ctidValue; + return {buffer.str()}; +} + +template +inline std::optional> +decodeCTID(const T ctid) noexcept +{ + uint64_t ctidValue{0}; + if constexpr ( + std::is_same_v || std::is_same_v || + std::is_same_v || std::is_same_v) + { + std::string const ctidString(ctid); + + if (ctidString.length() != 16) + return {}; + + if (!boost::regex_match(ctidString, boost::regex("^[0-9A-F]+$"))) + return {}; + + ctidValue = std::stoull(ctidString, nullptr, 16); + } + else if constexpr (std::is_integral_v) + ctidValue = ctid; + else + return {}; + + if ((ctidValue & 0xF000'0000'0000'0000ULL) != 0xC000'0000'0000'0000ULL) + return {}; + + uint32_t ledger_seq = (ctidValue >> 32) & 0xFFFF'FFFUL; + uint16_t txn_index = (ctidValue >> 16) & 0xFFFFU; + uint16_t network_id = ctidValue & 0xFFFFU; + return {{ledger_seq, txn_index, network_id}}; +} + +} // namespace RPC +} // namespace ripple + +#endif diff --git a/src/ripple/rpc/handlers/Tx.cpp b/src/ripple/rpc/handlers/Tx.cpp index e79997ec8f1..c8b00b96fa4 100644 --- a/src/ripple/rpc/handlers/Tx.cpp +++ b/src/ripple/rpc/handlers/Tx.cpp @@ -25,18 +25,17 @@ #include #include #include +#include #include #include #include #include #include +#include +#include namespace ripple { -// { -// transaction: -// } - static bool isValidated(LedgerMaster& ledgerMaster, std::uint32_t seq, uint256 const& hash) { @@ -54,12 +53,14 @@ struct TxResult Transaction::pointer txn; std::variant, Blob> meta; bool validated = false; + std::optional ctid; TxSearched searchedAll; }; struct TxArgs { - uint256 hash; + std::optional hash; + std::optional> ctid; bool binary = false; std::optional> ledgerRange; }; @@ -73,11 +74,19 @@ doTxPostgres(RPC::Context& context, TxArgs const& args) Throw( "Called doTxPostgres yet not in reporting mode"); } + TxResult res; res.searchedAll = TxSearched::unknown; + if (!args.hash) + return { + res, + {rpcNOT_IMPL, + "Use of CTIDs on reporting mode is not currently supported."}}; + JLOG(context.j.debug()) << "Fetching from postgres"; - Transaction::Locator locator = Transaction::locate(args.hash, context.app); + Transaction::Locator locator = + Transaction::locate(*(args.hash), context.app); std::pair, std::shared_ptr> pair; @@ -127,7 +136,7 @@ doTxPostgres(RPC::Context& context, TxArgs const& args) else { res.meta = std::make_shared( - args.hash, res.txn->getLedger(), *meta); + *(args.hash), res.txn->getLedger(), *meta); } res.validated = true; return {res, rpcSUCCESS}; @@ -168,7 +177,7 @@ doTxPostgres(RPC::Context& context, TxArgs const& args) } std::pair -doTxHelp(RPC::Context& context, TxArgs const& args) +doTxHelp(RPC::Context& context, TxArgs args) { if (context.app.config().reporting()) return doTxPostgres(context, args); @@ -196,15 +205,28 @@ doTxHelp(RPC::Context& context, TxArgs const& args) std::pair, std::shared_ptr>; result.searchedAll = TxSearched::unknown; - std::variant v; + + if (args.ctid) + { + args.hash = context.app.getLedgerMaster().txnIdFromIndex( + args.ctid->first, args.ctid->second); + + if (args.hash) + range = + ClosedInterval(args.ctid->first, args.ctid->second); + } + + if (!args.hash) + return {result, rpcTXN_NOT_FOUND}; + if (args.ledgerRange) { - v = context.app.getMasterTransaction().fetch(args.hash, range, ec); + v = context.app.getMasterTransaction().fetch(*(args.hash), range, ec); } else { - v = context.app.getMasterTransaction().fetch(args.hash, ec); + v = context.app.getMasterTransaction().fetch(*(args.hash), ec); } if (auto e = std::get_if(&v)) @@ -246,6 +268,15 @@ doTxHelp(RPC::Context& context, TxArgs const& args) } result.validated = isValidated( context.ledgerMaster, ledger->info().seq, ledger->info().hash); + + // compute outgoing CTID + uint32_t lgrSeq = ledger->info().seq; + uint32_t txnIdx = meta->getAsObject().getFieldU32(sfTransactionIndex); + uint32_t netID = context.app.config().NETWORK_ID; + + if (txnIdx <= 0xFFFFU && netID < 0xFFFFU && lgrSeq < 0x0FFF'FFFFUL) + result.ctid = + RPC::encodeCTID(lgrSeq, (uint16_t)txnIdx, (uint16_t)netID); } return {result, rpcSUCCESS}; @@ -301,6 +332,9 @@ populateJsonResponse( } } response[jss::validated] = result.validated; + + if (result.ctid) + response[jss::ctid] = *(result.ctid); } return response; } @@ -313,13 +347,39 @@ doTxJson(RPC::JsonContext& context) // Deserialize and validate JSON arguments - if (!context.params.isMember(jss::transaction)) + TxArgs args; + + if (context.params.isMember(jss::transaction) && + context.params.isMember(jss::ctid)) + // specifying both is ambiguous return rpcError(rpcINVALID_PARAMS); - TxArgs args; + if (context.params.isMember(jss::transaction)) + { + uint256 hash; + if (!hash.parseHex(context.params[jss::transaction].asString())) + return rpcError(rpcNOT_IMPL); + args.hash = hash; + } + else if (context.params.isMember(jss::ctid)) + { + auto ctid = RPC::decodeCTID(context.params[jss::ctid].asString()); + if (!ctid) + return rpcError(rpcINVALID_PARAMS); - if (!args.hash.parseHex(context.params[jss::transaction].asString())) - return rpcError(rpcNOT_IMPL); + auto const [lgr_seq, txn_idx, net_id] = *ctid; + if (net_id != context.app.config().NETWORK_ID) + { + std::stringstream out; + out << "Wrong network. You should submit this request to a node " + "running on NetworkID: " + << net_id; + return RPC::make_error(rpcWRONG_NETWORK, out.str()); + } + args.ctid = {lgr_seq, txn_idx}; + } + else + return rpcError(rpcINVALID_PARAMS); args.binary = context.params.isMember(jss::binary) && context.params[jss::binary].asBool(); diff --git a/src/ripple/rpc/impl/RPCHelpers.cpp b/src/ripple/rpc/impl/RPCHelpers.cpp index bc38df62fc9..7ad7fda4940 100644 --- a/src/ripple/rpc/impl/RPCHelpers.cpp +++ b/src/ripple/rpc/impl/RPCHelpers.cpp @@ -34,6 +34,7 @@ #include #include #include +#include namespace ripple { namespace RPC { diff --git a/src/test/app/LedgerMaster_test.cpp b/src/test/app/LedgerMaster_test.cpp new file mode 100644 index 00000000000..87639c42fcd --- /dev/null +++ b/src/test/app/LedgerMaster_test.cpp @@ -0,0 +1,139 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 XRPLF + + 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 + +namespace ripple { +namespace test { + +class LedgerMaster_test : public beast::unit_test::suite +{ + std::unique_ptr + makeNetworkConfig(uint32_t networkID) + { + using namespace jtx; + return envconfig([&](std::unique_ptr cfg) { + cfg->NETWORK_ID = networkID; + return cfg; + }); + } + + void + testTxnIdFromIndex(FeatureBitset features) + { + testcase("tx_id_from_index"); + + using namespace test::jtx; + using namespace std::literals; + + test::jtx::Env env{*this, makeNetworkConfig(11111)}; + + auto const alice = Account("alice"); + env.fund(XRP(1000), alice); + env.close(); + + // build ledgers + std::vector> txns; + std::vector> metas; + auto const startLegSeq = env.current()->info().seq; + for (int i = 0; i < 2; ++i) + { + env(noop(alice)); + txns.emplace_back(env.tx()); + env.close(); + metas.emplace_back( + env.closed()->txRead(env.tx()->getTransactionID()).second); + } + // add last (empty) ledger + env.close(); + auto const endLegSeq = env.closed()->info().seq; + + // test invalid range + { + std::uint32_t ledgerSeq = -1; + std::uint32_t txnIndex = 0; + auto result = + env.app().getLedgerMaster().txnIdFromIndex(ledgerSeq, txnIndex); + BEAST_EXPECT(!result); + } + // test not in ledger + { + uint32_t txnIndex = metas[0]->getFieldU32(sfTransactionIndex); + auto result = + env.app().getLedgerMaster().txnIdFromIndex(0, txnIndex); + BEAST_EXPECT(!result); + } + // test empty ledger + { + auto result = + env.app().getLedgerMaster().txnIdFromIndex(endLegSeq, 0); + BEAST_EXPECT(!result); + } + // ended without result + { + uint32_t txnIndex = metas[0]->getFieldU32(sfTransactionIndex); + auto result = env.app().getLedgerMaster().txnIdFromIndex( + endLegSeq + 1, txnIndex); + BEAST_EXPECT(!result); + } + // success (first tx) + { + uint32_t txnIndex = metas[0]->getFieldU32(sfTransactionIndex); + auto result = env.app().getLedgerMaster().txnIdFromIndex( + startLegSeq, txnIndex); + BEAST_EXPECT( + *result == + uint256("277F4FD89C20B92457FEF05FF63F6405563AD0563C73D967A29727" + "72679ADC65")); + } + // success (second tx) + { + uint32_t txnIndex = metas[1]->getFieldU32(sfTransactionIndex); + auto result = env.app().getLedgerMaster().txnIdFromIndex( + startLegSeq + 1, txnIndex); + BEAST_EXPECT( + *result == + uint256("293DF7335EBBAF4420D52E70ABF470EB4C5792CAEA2F91F76193C2" + "819F538FDE")); + } + } + +public: + void + run() override + { + using namespace test::jtx; + FeatureBitset const all{supported_amendments()}; + testWithFeats(all); + } + + void + testWithFeats(FeatureBitset features) + { + testTxnIdFromIndex(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LedgerMaster, app, ripple); + +} // namespace test +} // namespace ripple diff --git a/src/test/app/NetworkID_test.cpp b/src/test/app/NetworkID_test.cpp index e650667f842..8d1b891345d 100644 --- a/src/test/app/NetworkID_test.cpp +++ b/src/test/app/NetworkID_test.cpp @@ -68,14 +68,9 @@ class NetworkID_test : public beast::unit_test::suite jv[jss::Destination] = alice.human(); jv[jss::TransactionType] = "Payment"; jv[jss::Amount] = "10000000000"; - if (env.app().config().NETWORK_ID > 1024) - jv[jss::NetworkID] = - std::to_string(env.app().config().NETWORK_ID); - env(jv, fee(1000), sig(env.master)); } - // run tx env(jv, fee(1000), ter(expectedOutcome)); env.close(); }; @@ -127,12 +122,29 @@ class NetworkID_test : public beast::unit_test::suite { test::jtx::Env env{*this, makeNetworkConfig(1025)}; BEAST_EXPECT(env.app().config().NETWORK_ID == 1025); + { + env.fund(XRP(200), alice); + // try to submit a txn without network id, this should not work + Json::Value jvn; + jvn[jss::Account] = alice.human(); + jvn[jss::TransactionType] = jss::AccountSet; + jvn[jss::Fee] = to_string(env.current()->fees().base); + jvn[jss::Sequence] = env.seq(alice); + jvn[jss::LastLedgerSequence] = env.current()->info().seq + 2; + auto jt = env.jtnofill(jvn); + Serializer s; + jt.stx->add(s); + BEAST_EXPECT( + env.rpc( + "submit", + strHex(s.slice()))[jss::result][jss::engine_result] == + "telREQUIRES_NETWORK_ID"); + env.close(); + } - // try to submit a txn without network id, this should not work Json::Value jv; jv[jss::Account] = alice.human(); jv[jss::TransactionType] = jss::AccountSet; - runTx(env, jv, telREQUIRES_NETWORK_ID); // try to submit with wrong network id jv[jss::NetworkID] = 0; diff --git a/src/test/jtx/Env.h b/src/test/jtx/Env.h index 2a85a57e1db..57e69ef79df 100644 --- a/src/test/jtx/Env.h +++ b/src/test/jtx/Env.h @@ -445,6 +445,18 @@ class Env return jt; } + /** Create a JTx from parameters. */ + template + JTx + jtnofill(JsonValue&& jv, FN const&... fN) + { + JTx jt(std::forward(jv)); + invoke(jt, fN...); + autofill_sig(jt); + jt.stx = st(jt); + return jt; + } + /** Create JSON from parameters. This will apply funclets and autofill. */ diff --git a/src/test/jtx/impl/Env.cpp b/src/test/jtx/impl/Env.cpp index e0126d86854..5e1aaa166f0 100644 --- a/src/test/jtx/impl/Env.cpp +++ b/src/test/jtx/impl/Env.cpp @@ -415,6 +415,11 @@ Env::autofill(JTx& jt) jtx::fill_fee(jv, *current()); if (jt.fill_seq) jtx::fill_seq(jv, *current()); + + uint32_t networkID = app().config().NETWORK_ID; + if (!jv.isMember(jss::NetworkID) && networkID > 1024) + jv[jss::NetworkID] = std::to_string(networkID); + // Must come last try { diff --git a/src/test/rpc/RPCCall_test.cpp b/src/test/rpc/RPCCall_test.cpp index 1ae15afa303..5385ba768e6 100644 --- a/src/test/rpc/RPCCall_test.cpp +++ b/src/test/rpc/RPCCall_test.cpp @@ -5593,6 +5593,37 @@ static RPCCallTestData const rpcCallTestArray[] = { // tx // -------------------------------------------------------------------------- + {"tx: ctid. minimal", + __LINE__, + {"tx", "FFFFFFFFFFFFFFFF", "1", "2"}, + RPCCallTestData::no_exception, + R"({ + "method" : "tx", + "params" : [ + { + "api_version" : %MAX_API_VER%, + "ctid" : "FFFFFFFFFFFFFFFF", + "max_ledger" : "2", + "min_ledger" : "1" + } + ] + })"}, + {"tx: ctid. binary", + __LINE__, + {"tx", "FFFFFFFFFFFFFFFF", "binary", "1", "2"}, + RPCCallTestData::no_exception, + R"({ + "method" : "tx", + "params" : [ + { + "api_version" : %MAX_API_VER%, + "binary" : true, + "ctid" : "FFFFFFFFFFFFFFFF", + "max_ledger" : "2", + "min_ledger" : "1" + } + ] + })"}, {"tx: minimal.", __LINE__, {"tx", "transaction_hash_is_not_validated"}, @@ -6379,6 +6410,16 @@ updateAPIVersionString(const char* const req) return jr; } +std::unique_ptr +makeNetworkConfig(uint32_t networkID) +{ + using namespace test::jtx; + return envconfig([&](std::unique_ptr cfg) { + cfg->NETWORK_ID = networkID; + return cfg; + }); +} + class RPCCall_test : public beast::unit_test::suite { public: @@ -6387,7 +6428,8 @@ class RPCCall_test : public beast::unit_test::suite { testcase << "RPCCall"; - test::jtx::Env env(*this); // Used only for its Journal. + test::jtx::Env env( + *this, makeNetworkConfig(11111)); // Used only for its Journal. // For each RPCCall test. for (RPCCallTestData const& rpcCallTest : rpcCallTestArray) diff --git a/src/test/rpc/Transaction_test.cpp b/src/test/rpc/Transaction_test.cpp index 08e97c1c20a..cc3f70a1516 100644 --- a/src/test/rpc/Transaction_test.cpp +++ b/src/test/rpc/Transaction_test.cpp @@ -20,16 +20,29 @@ #include #include #include +#include +#include #include #include #include +#include namespace ripple { class Transaction_test : public beast::unit_test::suite { + std::unique_ptr + makeNetworkConfig(uint32_t networkID) + { + using namespace test::jtx; + return envconfig([&](std::unique_ptr cfg) { + cfg->NETWORK_ID = networkID; + return cfg; + }); + } + void - testRangeRequest() + testRangeRequest(FeatureBitset features) { testcase("Test Range Request"); @@ -43,7 +56,7 @@ class Transaction_test : public beast::unit_test::suite const char* EXCESSIVE = RPC::get_error_info(rpcEXCESSIVE_LGR_RANGE).token; - Env env(*this); + Env env{*this, features}; auto const alice = Account("alice"); env.fund(XRP(1000), alice); env.close(); @@ -278,11 +291,423 @@ class Transaction_test : public beast::unit_test::suite } } + void + testRangeCTIDRequest(FeatureBitset features) + { + testcase("ctid_range"); + + using namespace test::jtx; + using std::to_string; + + const char* COMMAND = jss::tx.c_str(); + const char* BINARY = jss::binary.c_str(); + const char* NOT_FOUND = RPC::get_error_info(rpcTXN_NOT_FOUND).token; + const char* INVALID = RPC::get_error_info(rpcINVALID_LGR_RANGE).token; + const char* EXCESSIVE = + RPC::get_error_info(rpcEXCESSIVE_LGR_RANGE).token; + + Env env{*this, makeNetworkConfig(11111)}; + uint32_t netID = env.app().config().NETWORK_ID; + + auto const alice = Account("alice"); + env.fund(XRP(1000), alice); + env.close(); + + std::vector> txns; + std::vector> metas; + auto const startLegSeq = env.current()->info().seq; + for (int i = 0; i < 750; ++i) + { + env(noop(alice)); + txns.emplace_back(env.tx()); + env.close(); + metas.emplace_back( + env.closed()->txRead(env.tx()->getTransactionID()).second); + } + auto const endLegSeq = env.closed()->info().seq; + + // Find the existing transactions + for (size_t i = 0; i < txns.size(); ++i) + { + auto const& tx = txns[i]; + auto const& meta = metas[i]; + uint32_t txnIdx = meta->getFieldU32(sfTransactionIndex); + auto const result = env.rpc( + COMMAND, + *RPC::encodeCTID(startLegSeq + i, txnIdx, netID), + BINARY, + to_string(startLegSeq), + to_string(endLegSeq)); + + BEAST_EXPECT(result[jss::result][jss::status] == jss::success); + BEAST_EXPECT( + result[jss::result][jss::tx] == + strHex(tx->getSerializer().getData())); + BEAST_EXPECT( + result[jss::result][jss::meta] == + strHex(meta->getSerializer().getData())); + } + + auto const tx = env.jt(noop(alice), seq(env.seq(alice))).stx; + auto const ctid = + *RPC::encodeCTID(endLegSeq, tx->getSeqProxy().value(), netID); + for (int deltaEndSeq = 0; deltaEndSeq < 2; ++deltaEndSeq) + { + auto const result = env.rpc( + COMMAND, + ctid, + BINARY, + to_string(startLegSeq), + to_string(endLegSeq + deltaEndSeq)); + + BEAST_EXPECT( + result[jss::result][jss::status] == jss::error && + result[jss::result][jss::error] == NOT_FOUND); + + if (deltaEndSeq) + BEAST_EXPECT(!result[jss::result][jss::searched_all].asBool()); + else + BEAST_EXPECT(!result[jss::result][jss::searched_all].asBool()); + } + + // Find transactions outside of provided range. + for (size_t i = 0; i < txns.size(); ++i) + { + // auto const& tx = txns[i]; + auto const& meta = metas[i]; + uint32_t txnIdx = meta->getFieldU32(sfTransactionIndex); + auto const result = env.rpc( + COMMAND, + *RPC::encodeCTID(startLegSeq + i, txnIdx, netID), + BINARY, + to_string(endLegSeq + 1), + to_string(endLegSeq + 100)); + + BEAST_EXPECT(result[jss::result][jss::status] == jss::success); + BEAST_EXPECT(!result[jss::result][jss::searched_all].asBool()); + } + + const auto deletedLedger = (startLegSeq + endLegSeq) / 2; + { + // Remove one of the ledgers from the database directly + dynamic_cast(&env.app().getRelationalDatabase()) + ->deleteTransactionByLedgerSeq(deletedLedger); + } + + for (int deltaEndSeq = 0; deltaEndSeq < 2; ++deltaEndSeq) + { + auto const result = env.rpc( + COMMAND, + ctid, + BINARY, + to_string(startLegSeq), + to_string(endLegSeq + deltaEndSeq)); + + BEAST_EXPECT( + result[jss::result][jss::status] == jss::error && + result[jss::result][jss::error] == NOT_FOUND); + BEAST_EXPECT(!result[jss::result][jss::searched_all].asBool()); + } + + // Provide range without providing the `binary` + // field. (Tests parameter parsing) + { + auto const result = env.rpc( + COMMAND, ctid, to_string(startLegSeq), to_string(endLegSeq)); + + BEAST_EXPECT( + result[jss::result][jss::status] == jss::error && + result[jss::result][jss::error] == NOT_FOUND); + + BEAST_EXPECT(!result[jss::result][jss::searched_all].asBool()); + } + + // Provide range without providing the `binary` + // field. (Tests parameter parsing) + { + auto const result = env.rpc( + COMMAND, + ctid, + to_string(startLegSeq), + to_string(deletedLedger - 1)); + + BEAST_EXPECT( + result[jss::result][jss::status] == jss::error && + result[jss::result][jss::error] == NOT_FOUND); + + BEAST_EXPECT(!result[jss::result][jss::searched_all].asBool()); + } + + // Provide range without providing the `binary` + // field. (Tests parameter parsing) + { + auto const& meta = metas[0]; + uint32_t txnIdx = meta->getFieldU32(sfTransactionIndex); + auto const result = env.rpc( + COMMAND, + *RPC::encodeCTID(endLegSeq, txnIdx, netID), + to_string(startLegSeq), + to_string(deletedLedger - 1)); + + BEAST_EXPECT(result[jss::result][jss::status] == jss::success); + BEAST_EXPECT(!result[jss::result].isMember(jss::searched_all)); + } + + // Provide an invalid range: (min > max) + { + auto const result = env.rpc( + COMMAND, + ctid, + BINARY, + to_string(deletedLedger - 1), + to_string(startLegSeq)); + + BEAST_EXPECT( + result[jss::result][jss::status] == jss::error && + result[jss::result][jss::error] == INVALID); + + BEAST_EXPECT(!result[jss::result].isMember(jss::searched_all)); + } + + // Provide an invalid range: (min < 0) + { + auto const result = env.rpc( + COMMAND, + ctid, + BINARY, + to_string(-1), + to_string(deletedLedger - 1)); + + BEAST_EXPECT( + result[jss::result][jss::status] == jss::error && + result[jss::result][jss::error] == INVALID); + + BEAST_EXPECT(!result[jss::result].isMember(jss::searched_all)); + } + + // Provide an invalid range: (min < 0, max < 0) + { + auto const result = + env.rpc(COMMAND, ctid, BINARY, to_string(-20), to_string(-10)); + + BEAST_EXPECT( + result[jss::result][jss::status] == jss::error && + result[jss::result][jss::error] == INVALID); + + BEAST_EXPECT(!result[jss::result].isMember(jss::searched_all)); + } + + // Provide an invalid range: (only one value) + { + auto const result = env.rpc(COMMAND, ctid, BINARY, to_string(20)); + + BEAST_EXPECT( + result[jss::result][jss::status] == jss::error && + result[jss::result][jss::error] == INVALID); + + BEAST_EXPECT(!result[jss::result].isMember(jss::searched_all)); + } + + // Provide an invalid range: (only one value) + { + auto const result = env.rpc(COMMAND, ctid, to_string(20)); + + // Since we only provided one value for the range, + // the interface parses it as a false binary flag, + // as single-value ranges are not accepted. Since + // the error this causes differs depending on the platform + // we don't call out a specific error here. + BEAST_EXPECT(result[jss::result][jss::status] == jss::error); + + BEAST_EXPECT(!result[jss::result].isMember(jss::searched_all)); + } + + // Provide an invalid range: (max - min > 1000) + { + auto const result = env.rpc( + COMMAND, + ctid, + BINARY, + to_string(startLegSeq), + to_string(startLegSeq + 1001)); + + BEAST_EXPECT( + result[jss::result][jss::status] == jss::error && + result[jss::result][jss::error] == EXCESSIVE); + + BEAST_EXPECT(!result[jss::result].isMember(jss::searched_all)); + } + } + + void + testCTIDValidation(FeatureBitset features) + { + testcase("ctid_validation"); + + using namespace test::jtx; + using std::to_string; + + Env env{*this, makeNetworkConfig(11111)}; + + // Test case 1: Valid input values + auto const expected11 = std::optional("CFFFFFFFFFFFFFFF"); + BEAST_EXPECT( + RPC::encodeCTID(0x0FFF'FFFFUL, 0xFFFFU, 0xFFFFU) == expected11); + auto const expected12 = std::optional("C000000000000000"); + BEAST_EXPECT(RPC::encodeCTID(0, 0, 0) == expected12); + auto const expected13 = std::optional("C000000100020003"); + BEAST_EXPECT(RPC::encodeCTID(1U, 2U, 3U) == expected13); + auto const expected14 = std::optional("C0CA2AA7326FFFFF"); + BEAST_EXPECT(RPC::encodeCTID(13249191UL, 12911U, 65535U) == expected14); + + // Test case 2: ledger_seq greater than 0xFFFFFFF + BEAST_EXPECT(!RPC::encodeCTID(0x1000'0000UL, 0xFFFFU, 0xFFFFU)); + + // Test case 3: txn_index greater than 0xFFFF + // this test case is impossible in c++ due to the type, left in for + // completeness + auto const expected3 = std::optional("CFFFFFFF0000FFFF"); + BEAST_EXPECT( + RPC::encodeCTID(0x0FFF'FFFF, (uint16_t)0x10000, 0xFFFF) == + expected3); + + // Test case 4: network_id greater than 0xFFFF + // this test case is impossible in c++ due to the type, left in for + // completeness + auto const expected4 = std::optional("CFFFFFFFFFFF0000"); + BEAST_EXPECT( + RPC::encodeCTID(0x0FFF'FFFFUL, 0xFFFFU, (uint16_t)0x1000'0U) == + expected4); + + // Test case 5: Valid input values + auto const expected51 = + std::optional>( + std::make_tuple(0, 0, 0)); + BEAST_EXPECT(RPC::decodeCTID("C000000000000000") == expected51); + auto const expected52 = + std::optional>( + std::make_tuple(1U, 2U, 3U)); + BEAST_EXPECT(RPC::decodeCTID("C000000100020003") == expected52); + auto const expected53 = + std::optional>( + std::make_tuple(13249191UL, 12911U, 49221U)); + BEAST_EXPECT(RPC::decodeCTID("C0CA2AA7326FC045") == expected53); + + // Test case 6: ctid not a string or big int + BEAST_EXPECT(!RPC::decodeCTID(0xCFF)); + + // Test case 7: ctid not a hexadecimal string + BEAST_EXPECT(!RPC::decodeCTID("C003FFFFFFFFFFFG")); + + // Test case 8: ctid not exactly 16 nibbles + BEAST_EXPECT(!RPC::decodeCTID("C003FFFFFFFFFFF")); + + // Test case 9: ctid too large to be a valid CTID value + BEAST_EXPECT(!RPC::decodeCTID("CFFFFFFFFFFFFFFFF")); + + // Test case 10: ctid doesn't start with a C nibble + BEAST_EXPECT(!RPC::decodeCTID("FFFFFFFFFFFFFFFF")); + + // Test case 11: Valid input values + BEAST_EXPECT( + (RPC::decodeCTID(0xCFFF'FFFF'FFFF'FFFFULL) == + std::optional>( + std::make_tuple(0x0FFF'FFFFUL, 0xFFFFU, 0xFFFFU)))); + BEAST_EXPECT( + (RPC::decodeCTID(0xC000'0000'0000'0000ULL) == + std::optional>( + std::make_tuple(0, 0, 0)))); + BEAST_EXPECT( + (RPC::decodeCTID(0xC000'0001'0002'0003ULL) == + std::optional>( + std::make_tuple(1U, 2U, 3U)))); + BEAST_EXPECT( + (RPC::decodeCTID(0xC0CA'2AA7'326F'C045ULL) == + std::optional>( + std::make_tuple(1324'9191UL, 12911U, 49221U)))); + + // Test case 12: ctid not exactly 16 nibbles + BEAST_EXPECT(!RPC::decodeCTID(0xC003'FFFF'FFFF'FFF)); + + // Test case 13: ctid too large to be a valid CTID value + // this test case is not possible in c++ because it would overflow the + // type, left in for completeness + // BEAST_EXPECT(!RPC::decodeCTID(0xCFFFFFFFFFFFFFFFFULL)); + + // Test case 14: ctid doesn't start with a C nibble + BEAST_EXPECT(!RPC::decodeCTID(0xFFFF'FFFF'FFFF'FFFFULL)); + } + + void + testCTIDRPC(FeatureBitset features) + { + testcase("ctid_rpc"); + + using namespace test::jtx; + + // test that the ctid AND the hash are in the response + { + Env env{*this, makeNetworkConfig(11111)}; + uint32_t netID = env.app().config().NETWORK_ID; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + auto const startLegSeq = env.current()->info().seq; + env.fund(XRP(10000), alice, bob); + env(pay(alice, bob, XRP(10))); + env.close(); + + auto const ctid = *RPC::encodeCTID(startLegSeq, 0, netID); + Json::Value jsonTx; + jsonTx[jss::binary] = false; + jsonTx[jss::ctid] = ctid; + jsonTx[jss::id] = 1; + auto jrr = env.rpc("json", "tx", to_string(jsonTx))[jss::result]; + BEAST_EXPECT(jrr[jss::ctid] == ctid); + BEAST_EXPECT(jrr[jss::hash]); + } + + // test that if the network is 65535 the ctid is not in the response + { + Env env{*this, makeNetworkConfig(65535)}; + uint32_t netID = env.app().config().NETWORK_ID; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + auto const startLegSeq = env.current()->info().seq; + env.fund(XRP(10000), alice, bob); + env(pay(alice, bob, XRP(10))); + env.close(); + + auto const ctid = *RPC::encodeCTID(startLegSeq, 0, netID); + Json::Value jsonTx; + jsonTx[jss::binary] = false; + jsonTx[jss::ctid] = ctid; + jsonTx[jss::id] = 1; + auto jrr = env.rpc("json", "tx", to_string(jsonTx))[jss::result]; + BEAST_EXPECT(!jrr[jss::ctid]); + BEAST_EXPECT(jrr[jss::hash]); + } + } + public: void run() override { - testRangeRequest(); + using namespace test::jtx; + FeatureBitset const all{supported_amendments()}; + testWithFeats(all); + } + + void + testWithFeats(FeatureBitset features) + { + testRangeRequest(features); + testRangeCTIDRequest(features); + testCTIDValidation(features); + testCTIDRPC(features); } };